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

feat: add guestbook signatures viewer and fix firehose collection tracking

adds full-screen modal to view all guestbook signatures with avatars and timestamps. fixes critical firehose bug where guestbook collection wasn't being watched if it didn't exist yet.

backend changes:
- always include app.at-me.visit in watched collections (routes.rs:1184-1187)
- add /api/guestbook/signatures endpoint with caching (routes.rs:981-1096)
- cache invalidation on sign/unsign for real-time updates

frontend changes:
- add view guestbook button (👥 icon) positioned left of sign button
- full-screen modal with loading, empty, and error states
- fetch and display signatures sorted by most recent first
- modal shows handle, avatar, and formatted timestamp for each signer

fixes:
- guestbook collection now tracked even before first signature
- real-time particle animations work on first sign/unsign
- dynamic circle appearance/removal works correctly

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

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

+531 -37
+1
src/main.rs
··· 55 .service(routes::auth_status) 56 .service(routes::sign_guestbook) 57 .service(routes::unsign_guestbook) 58 .service(routes::firehose_watch) 59 .service(routes::favicon) 60 .service(Files::new("/static", "./static"))
··· 55 .service(routes::auth_status) 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"))
+185 -7
src/routes.rs
··· 37 static DID_CACHE: Lazy<DashMap<String, CachedDid>> = 38 Lazy::new(|| DashMap::new()); 39 40 // OAuth session type matching our OAuth client configuration 41 type OAuthSessionType = OAuthSession< 42 DefaultHttpClient, ··· 799 .create_record(input.into()) 800 .await 801 { 802 - Ok(output) => HttpResponse::Ok().json(serde_json::json!({ 803 - "success": true, 804 - "uri": output.data.uri, 805 - "cid": output.data.cid, 806 - })), 807 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 808 "error": format!("failed to create record: {}", e) 809 })), ··· 934 } 935 } 936 937 HttpResponse::Ok().json(serde_json::json!({ 938 "success": true, 939 "deleted": deleted_count ··· 945 did: String, 946 } 947 948 #[get("/api/firehose/watch")] 949 pub async fn firehose_watch( 950 query: web::Query<FirehoseQuery>, ··· 985 986 // Fetch collections from PDS 987 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 988 - let collections = match reqwest::get(&repo_url).await { 989 Ok(r) => match r.json::<serde_json::Value>().await { 990 Ok(repo_data) => { 991 repo_data["collections"] ··· 1008 } 1009 }; 1010 1011 - log::info!("Fetched {} collections for DID: {}", collections.len(), did); 1012 1013 // Get or create a broadcaster for this DID with its collections 1014 let broadcaster = crate::firehose::get_or_create_broadcaster(&manager, did.clone(), collections).await;
··· 37 static DID_CACHE: Lazy<DashMap<String, CachedDid>> = 38 Lazy::new(|| DashMap::new()); 39 40 + // Guestbook signatures cache with aggressive TTL 41 + struct CachedSignatures { 42 + signatures: Vec<GuestbookSignature>, 43 + timestamp: Instant, 44 + } 45 + 46 + static SIGNATURES_CACHE: Lazy<DashMap<String, CachedSignatures>> = 47 + Lazy::new(|| DashMap::new()); 48 + 49 // OAuth session type matching our OAuth client configuration 50 type OAuthSessionType = OAuthSession< 51 DefaultHttpClient, ··· 808 .create_record(input.into()) 809 .await 810 { 811 + Ok(output) => { 812 + // Invalidate signatures cache 813 + SIGNATURES_CACHE.remove(&did); 814 + log::info!("Invalidated signatures cache for DID: {}", did); 815 + 816 + HttpResponse::Ok().json(serde_json::json!({ 817 + "success": true, 818 + "uri": output.data.uri, 819 + "cid": output.data.cid, 820 + })) 821 + }, 822 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 823 "error": format!("failed to create record: {}", e) 824 })), ··· 949 } 950 } 951 952 + // Invalidate signatures cache 953 + SIGNATURES_CACHE.remove(&did); 954 + log::info!("Invalidated signatures cache for DID: {}", did); 955 + 956 HttpResponse::Ok().json(serde_json::json!({ 957 "success": true, 958 "deleted": deleted_count ··· 964 did: String, 965 } 966 967 + #[derive(Serialize, Clone)] 968 + #[serde(rename_all = "camelCase")] 969 + pub struct GuestbookSignature { 970 + pub did: String, 971 + pub handle: Option<String>, 972 + pub avatar: Option<String>, 973 + pub timestamp: String, 974 + } 975 + 976 + #[derive(Deserialize)] 977 + pub struct SignaturesQuery { 978 + did: String, 979 + } 980 + 981 + #[get("/api/guestbook/signatures")] 982 + pub async fn get_guestbook_signatures(query: web::Query<SignaturesQuery>) -> HttpResponse { 983 + let target_did = &query.did; 984 + 985 + // Check cache first 986 + if let Some(cached) = SIGNATURES_CACHE.get(target_did) { 987 + if cached.timestamp.elapsed() < constants::CACHE_TTL { 988 + log::info!("Returning cached guestbook signatures for DID: {}", target_did); 989 + return HttpResponse::Ok() 990 + .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 991 + .json(cached.signatures.clone()); 992 + } 993 + } 994 + 995 + log::info!("Fetching guestbook signatures for DID: {}", target_did); 996 + 997 + // Fetch DID document to get PDS 998 + let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, target_did); 999 + let did_doc = match reqwest::get(&did_doc_url).await { 1000 + Ok(r) => match r.json::<serde_json::Value>().await { 1001 + Ok(d) => d, 1002 + Err(e) => { 1003 + log::error!("Failed to parse DID document: {}", e); 1004 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1005 + "error": "failed to fetch user data" 1006 + })); 1007 + } 1008 + }, 1009 + Err(e) => { 1010 + log::error!("Failed to fetch DID document: {}", e); 1011 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1012 + "error": "failed to fetch user data" 1013 + })); 1014 + } 1015 + }; 1016 + 1017 + let pds = did_doc["service"] 1018 + .as_array() 1019 + .and_then(|services| { 1020 + services.iter().find(|s| { 1021 + s["type"].as_str() == Some("AtprotoPersonalDataServer") 1022 + }) 1023 + }) 1024 + .and_then(|s| s["serviceEndpoint"].as_str()) 1025 + .unwrap_or("") 1026 + .to_string(); 1027 + 1028 + // Fetch all guestbook records from the PDS 1029 + let records_url = format!( 1030 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100", 1031 + pds, target_did, constants::GUESTBOOK_COLLECTION 1032 + ); 1033 + 1034 + let records = match reqwest::get(&records_url).await { 1035 + Ok(r) => match r.json::<serde_json::Value>().await { 1036 + Ok(data) => data["records"] 1037 + .as_array() 1038 + .cloned() 1039 + .unwrap_or_default(), 1040 + Err(e) => { 1041 + log::error!("Failed to parse records: {}", e); 1042 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1043 + "error": "failed to parse records" 1044 + })); 1045 + } 1046 + }, 1047 + Err(e) => { 1048 + log::error!("Failed to fetch records: {}", e); 1049 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1050 + "error": "failed to fetch records" 1051 + })); 1052 + } 1053 + }; 1054 + 1055 + // Extract signer DIDs and fetch their profile info 1056 + let mut signatures = Vec::new(); 1057 + for record in records { 1058 + // Extract DID from URI (at://did:plc:xxx/collection/rkey) 1059 + if let Some(uri) = record["uri"].as_str() { 1060 + let uri_parts: Vec<&str> = uri.split('/').collect(); 1061 + if uri_parts.len() >= 3 { 1062 + let signer_did = uri_parts[2].to_string(); 1063 + 1064 + // Get timestamp from record 1065 + let timestamp = record["value"]["timestamp"] 1066 + .as_str() 1067 + .or_else(|| record["value"]["createdAt"].as_str()) 1068 + .unwrap_or("") 1069 + .to_string(); 1070 + 1071 + // Fetch profile info for signer 1072 + let (handle, avatar) = fetch_profile_info(&signer_did).await; 1073 + 1074 + signatures.push(GuestbookSignature { 1075 + did: signer_did, 1076 + handle, 1077 + avatar, 1078 + timestamp, 1079 + }); 1080 + } 1081 + } 1082 + } 1083 + 1084 + // Sort by timestamp (most recent first) 1085 + signatures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 1086 + 1087 + // Cache the result 1088 + SIGNATURES_CACHE.insert(target_did.clone(), CachedSignatures { 1089 + signatures: signatures.clone(), 1090 + timestamp: Instant::now(), 1091 + }); 1092 + 1093 + HttpResponse::Ok() 1094 + .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 1095 + .json(signatures) 1096 + } 1097 + 1098 + async fn fetch_profile_info(did: &str) -> (Option<String>, Option<String>) { 1099 + // Fetch DID document for handle 1100 + let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 1101 + let handle = if let Ok(response) = reqwest::get(&did_doc_url).await { 1102 + if let Ok(doc) = response.json::<serde_json::Value>().await { 1103 + doc["alsoKnownAs"] 1104 + .as_array() 1105 + .and_then(|aka| aka.get(0)) 1106 + .and_then(|v| v.as_str()) 1107 + .map(|s| s.replace("at://", "")) 1108 + } else { 1109 + None 1110 + } 1111 + } else { 1112 + None 1113 + }; 1114 + 1115 + // Fetch avatar 1116 + let avatar = fetch_user_avatar(did).await; 1117 + 1118 + (handle, avatar) 1119 + } 1120 + 1121 #[get("/api/firehose/watch")] 1122 pub async fn firehose_watch( 1123 query: web::Query<FirehoseQuery>, ··· 1158 1159 // Fetch collections from PDS 1160 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 1161 + let mut collections = match reqwest::get(&repo_url).await { 1162 Ok(r) => match r.json::<serde_json::Value>().await { 1163 Ok(repo_data) => { 1164 repo_data["collections"] ··· 1181 } 1182 }; 1183 1184 + // Always include guestbook collection, even if it doesn't exist yet 1185 + if !collections.contains(&constants::GUESTBOOK_COLLECTION.to_string()) { 1186 + collections.push(constants::GUESTBOOK_COLLECTION.to_string()); 1187 + } 1188 + 1189 + log::info!("Fetched {} collections for DID: {} (including guestbook)", collections.len(), did); 1190 1191 // Get or create a broadcaster for this DID with its collections 1192 let broadcaster = crate::firehose::get_or_create_broadcaster(&manager, did.clone(), collections).await;
+254 -30
src/templates/app.html
··· 1003 .home-btn { 1004 position: fixed; 1005 top: clamp(1rem, 2vmin, 1.5rem); 1006 - right: clamp(1rem, 2vmin, 1.5rem); 1007 font-family: inherit; 1008 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1009 color: var(--text-light); ··· 1019 align-items: center; 1020 } 1021 1022 - .home-btn:hover { 1023 background: var(--surface); 1024 color: var(--text); 1025 border-color: var(--text-light); ··· 1028 .watch-live-btn { 1029 position: fixed; 1030 top: clamp(1rem, 2vmin, 1.5rem); 1031 - right: clamp(6rem, 14vmin, 9rem); 1032 font-family: inherit; 1033 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1034 color: var(--text-light); 1035 - border: 1px solid var(--border); 1036 background: var(--bg); 1037 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1038 transition: all 0.2s ease; 1039 z-index: 100; 1040 cursor: pointer; 1041 - border-radius: 2px; 1042 display: flex; 1043 align-items: center; 1044 - gap: 0.5rem; 1045 } 1046 1047 - .watch-live-btn:hover { 1048 background: var(--surface); 1049 color: var(--text); 1050 border-color: var(--text-light); 1051 } 1052 1053 .watch-live-btn.active { ··· 1057 } 1058 1059 .watch-indicator { 1060 - width: 8px; 1061 - height: 8px; 1062 border-radius: 50%; 1063 background: var(--text-light); 1064 display: none; ··· 1072 @keyframes pulse { 1073 0%, 100% { opacity: 1; } 1074 50% { opacity: 0.3; } 1075 } 1076 1077 .firehose-toast { ··· 1126 border-bottom-color: var(--text); 1127 } 1128 1129 - @media (max-width: 768px) { 1130 - .watch-live-btn { 1131 - right: clamp(1rem, 2vmin, 1.5rem); 1132 - top: clamp(4rem, 8vmin, 5rem); 1133 - } 1134 - 1135 - .firehose-toast { 1136 - top: clamp(7rem, 12vmin, 8rem); 1137 - } 1138 - 1139 - .sign-guestbook-btn { 1140 - right: clamp(1rem, 2vmin, 1.5rem); 1141 - top: clamp(7rem, 14vmin, 8.5rem); 1142 - } 1143 - } 1144 - 1145 .sign-guestbook-btn { 1146 position: fixed; 1147 - top: clamp(1rem, 2vmin, 1.5rem); 1148 - right: clamp(14rem, 30vmin, 19rem); 1149 font-family: inherit; 1150 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1151 color: var(--text-light); ··· 1158 border-radius: 2px; 1159 } 1160 1161 - .sign-guestbook-btn:hover { 1162 background: var(--surface); 1163 color: var(--text); 1164 border-color: var(--text-light); ··· 1168 opacity: 0.5; 1169 cursor: not-allowed; 1170 } 1171 </style> 1172 </head> 1173 <body> ··· 1179 </button> 1180 <button class="sign-guestbook-btn" id="signGuestbookBtn"> 1181 📖 sign guestbook 1182 </button> 1183 1184 <div class="firehose-toast" id="firehoseToast"> ··· 1201 <div class="onboarding-overlay" id="onboardingOverlay"> 1202 <div class="onboarding-spotlight" id="onboardingSpotlight"></div> 1203 <div class="onboarding-content" id="onboardingContent"></div> 1204 </div> 1205 1206 <div class="canvas">
··· 1003 .home-btn { 1004 position: fixed; 1005 top: clamp(1rem, 2vmin, 1.5rem); 1006 + left: clamp(4.5rem, 10vmin, 6rem); 1007 font-family: inherit; 1008 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1009 color: var(--text-light); ··· 1019 align-items: center; 1020 } 1021 1022 + .home-btn:hover, .home-btn:active { 1023 background: var(--surface); 1024 color: var(--text); 1025 border-color: var(--text-light); ··· 1028 .watch-live-btn { 1029 position: fixed; 1030 top: clamp(1rem, 2vmin, 1.5rem); 1031 + right: clamp(1rem, 2vmin, 1.5rem); 1032 font-family: inherit; 1033 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1034 color: var(--text-light); 1035 + border: 2px solid var(--border); 1036 background: var(--bg); 1037 + padding: clamp(0.6rem, 1.4vmin, 0.75rem) clamp(1rem, 2.4vmin, 1.25rem); 1038 transition: all 0.2s ease; 1039 z-index: 100; 1040 cursor: pointer; 1041 + border-radius: 4px; 1042 display: flex; 1043 align-items: center; 1044 + gap: clamp(0.5rem, 1.2vmin, 0.6rem); 1045 + font-weight: 500; 1046 } 1047 1048 + .watch-live-btn:hover, .watch-live-btn:active { 1049 background: var(--surface); 1050 color: var(--text); 1051 border-color: var(--text-light); 1052 + transform: translateY(-1px); 1053 } 1054 1055 .watch-live-btn.active { ··· 1059 } 1060 1061 .watch-indicator { 1062 + width: clamp(8px, 2vmin, 10px); 1063 + height: clamp(8px, 2vmin, 10px); 1064 border-radius: 50%; 1065 background: var(--text-light); 1066 display: none; ··· 1074 @keyframes pulse { 1075 0%, 100% { opacity: 1; } 1076 50% { opacity: 0.3; } 1077 + } 1078 + 1079 + @keyframes pulse-glow { 1080 + 0%, 100% { 1081 + transform: scale(1); 1082 + box-shadow: 0 0 0 rgba(255, 255, 255, 0); 1083 + } 1084 + 50% { 1085 + transform: scale(1.05); 1086 + box-shadow: 0 0 15px rgba(255, 255, 255, 0.3); 1087 + } 1088 } 1089 1090 .firehose-toast { ··· 1139 border-bottom-color: var(--text); 1140 } 1141 1142 .sign-guestbook-btn { 1143 position: fixed; 1144 + bottom: clamp(0.75rem, 2vmin, 1rem); 1145 + right: clamp(0.75rem, 2vmin, 1rem); 1146 font-family: inherit; 1147 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1148 color: var(--text-light); ··· 1155 border-radius: 2px; 1156 } 1157 1158 + .sign-guestbook-btn:hover, .sign-guestbook-btn:active { 1159 background: var(--surface); 1160 color: var(--text); 1161 border-color: var(--text-light); ··· 1165 opacity: 0.5; 1166 cursor: not-allowed; 1167 } 1168 + 1169 + .view-guestbook-btn { 1170 + position: fixed; 1171 + bottom: clamp(0.75rem, 2vmin, 1rem); 1172 + right: clamp(10rem, 22vmin, 13rem); 1173 + font-family: inherit; 1174 + font-size: clamp(0.8rem, 1.6vmin, 1rem); 1175 + color: var(--text-light); 1176 + border: 1px solid var(--border); 1177 + background: var(--bg); 1178 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1179 + transition: all 0.2s ease; 1180 + z-index: 100; 1181 + cursor: pointer; 1182 + border-radius: 2px; 1183 + width: clamp(32px, 7vmin, 40px); 1184 + height: clamp(32px, 7vmin, 40px); 1185 + display: flex; 1186 + align-items: center; 1187 + justify-content: center; 1188 + } 1189 + 1190 + .view-guestbook-btn:hover, .view-guestbook-btn:active { 1191 + background: var(--surface); 1192 + color: var(--text); 1193 + border-color: var(--text-light); 1194 + } 1195 + 1196 + @media (max-width: 768px) { 1197 + .view-guestbook-btn { 1198 + right: clamp(1rem, 2vmin, 1.5rem); 1199 + bottom: clamp(3.5rem, 8vmin, 4.5rem); 1200 + } 1201 + } 1202 + 1203 + @media (max-width: 768px) { 1204 + .home-btn { 1205 + font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1206 + padding: clamp(0.5rem, 1.2vmin, 0.6rem) clamp(0.7rem, 1.8vmin, 0.9rem); 1207 + } 1208 + 1209 + .watch-live-btn { 1210 + font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 1211 + padding: clamp(0.5rem, 1.3vmin, 0.65rem) clamp(0.9rem, 2.2vmin, 1.1rem); 1212 + } 1213 + 1214 + .firehose-toast { 1215 + top: clamp(4.5rem, 9vmin, 5.5rem); 1216 + } 1217 + } 1218 + 1219 + .guestbook-modal { 1220 + position: fixed; 1221 + inset: 0; 1222 + background: var(--bg); 1223 + z-index: 2000; 1224 + display: none; 1225 + overflow-y: auto; 1226 + padding: clamp(4rem, 8vmin, 6rem) clamp(1rem, 3vmin, 2rem) clamp(2rem, 4vmin, 3rem); 1227 + } 1228 + 1229 + .guestbook-modal.visible { 1230 + display: block; 1231 + } 1232 + 1233 + .guestbook-close { 1234 + position: fixed; 1235 + top: clamp(1rem, 2vmin, 1.5rem); 1236 + right: clamp(1rem, 2vmin, 1.5rem); 1237 + width: clamp(40px, 8vmin, 48px); 1238 + height: clamp(40px, 8vmin, 48px); 1239 + border: 2px solid var(--border); 1240 + background: var(--surface); 1241 + color: var(--text-light); 1242 + cursor: pointer; 1243 + display: flex; 1244 + align-items: center; 1245 + justify-content: center; 1246 + font-size: clamp(1.2rem, 3vmin, 1.5rem); 1247 + line-height: 1; 1248 + transition: all 0.2s ease; 1249 + border-radius: 4px; 1250 + z-index: 2001; 1251 + } 1252 + 1253 + .guestbook-close:hover, .guestbook-close:active { 1254 + background: var(--surface-hover); 1255 + border-color: var(--text-light); 1256 + color: var(--text); 1257 + } 1258 + 1259 + .guestbook-header { 1260 + max-width: 800px; 1261 + margin: 0 auto 2rem; 1262 + } 1263 + 1264 + .guestbook-header h2 { 1265 + font-size: clamp(1.2rem, 3vmin, 1.5rem); 1266 + margin-bottom: 0.5rem; 1267 + color: var(--text); 1268 + font-weight: 500; 1269 + } 1270 + 1271 + .guestbook-header p { 1272 + font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 1273 + color: var(--text-light); 1274 + line-height: 1.5; 1275 + } 1276 + 1277 + .guestbook-list { 1278 + max-width: 800px; 1279 + margin: 0 auto; 1280 + } 1281 + 1282 + .guestbook-signature { 1283 + background: var(--surface); 1284 + border: 1px solid var(--border); 1285 + border-radius: 4px; 1286 + padding: clamp(0.75rem, 2vmin, 1rem); 1287 + margin-bottom: clamp(0.75rem, 2vmin, 1rem); 1288 + display: flex; 1289 + align-items: center; 1290 + gap: clamp(0.75rem, 2vmin, 1rem); 1291 + transition: all 0.2s ease; 1292 + } 1293 + 1294 + .guestbook-signature:hover { 1295 + border-color: var(--text-light); 1296 + background: var(--surface-hover); 1297 + } 1298 + 1299 + .guestbook-avatar { 1300 + width: clamp(40px, 8vmin, 48px); 1301 + height: clamp(40px, 8vmin, 48px); 1302 + border-radius: 50%; 1303 + background: var(--bg); 1304 + border: 1px solid var(--border); 1305 + object-fit: cover; 1306 + flex-shrink: 0; 1307 + } 1308 + 1309 + .guestbook-avatar-placeholder { 1310 + width: clamp(40px, 8vmin, 48px); 1311 + height: clamp(40px, 8vmin, 48px); 1312 + border-radius: 50%; 1313 + background: var(--bg); 1314 + border: 1px solid var(--border); 1315 + display: flex; 1316 + align-items: center; 1317 + justify-content: center; 1318 + font-size: clamp(1rem, 2.5vmin, 1.25rem); 1319 + color: var(--text-light); 1320 + flex-shrink: 0; 1321 + } 1322 + 1323 + .guestbook-info { 1324 + flex: 1; 1325 + min-width: 0; 1326 + } 1327 + 1328 + .guestbook-handle { 1329 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1330 + color: var(--text); 1331 + font-weight: 500; 1332 + margin-bottom: 0.25rem; 1333 + overflow: hidden; 1334 + text-overflow: ellipsis; 1335 + white-space: nowrap; 1336 + } 1337 + 1338 + .guestbook-timestamp { 1339 + font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1340 + color: var(--text-light); 1341 + } 1342 + 1343 + .guestbook-empty { 1344 + max-width: 800px; 1345 + margin: 0 auto; 1346 + text-align: center; 1347 + padding: clamp(3rem, 8vmin, 5rem) clamp(1rem, 3vmin, 2rem); 1348 + } 1349 + 1350 + .guestbook-empty-icon { 1351 + font-size: clamp(2rem, 6vmin, 3rem); 1352 + margin-bottom: clamp(1rem, 3vmin, 1.5rem); 1353 + opacity: 0.3; 1354 + } 1355 + 1356 + .guestbook-empty-text { 1357 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1358 + color: var(--text-light); 1359 + line-height: 1.5; 1360 + } 1361 + 1362 + .guestbook-loading { 1363 + max-width: 800px; 1364 + margin: 0 auto; 1365 + text-align: center; 1366 + padding: clamp(3rem, 8vmin, 5rem) clamp(1rem, 3vmin, 2rem); 1367 + } 1368 + 1369 + .guestbook-loading-spinner { 1370 + width: 40px; 1371 + height: 40px; 1372 + border: 3px solid var(--border); 1373 + border-top-color: var(--text); 1374 + border-radius: 50%; 1375 + animation: spin 0.8s linear infinite; 1376 + margin: 0 auto clamp(1rem, 3vmin, 1.5rem); 1377 + } 1378 + 1379 + .guestbook-loading-text { 1380 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1381 + color: var(--text-light); 1382 + } 1383 </style> 1384 </head> 1385 <body> ··· 1391 </button> 1392 <button class="sign-guestbook-btn" id="signGuestbookBtn"> 1393 📖 sign guestbook 1394 + </button> 1395 + <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 1396 + 👥 1397 </button> 1398 1399 <div class="firehose-toast" id="firehoseToast"> ··· 1416 <div class="onboarding-overlay" id="onboardingOverlay"> 1417 <div class="onboarding-spotlight" id="onboardingSpotlight"></div> 1418 <div class="onboarding-content" id="onboardingContent"></div> 1419 + </div> 1420 + 1421 + <div class="guestbook-modal" id="guestbookModal"> 1422 + <button class="guestbook-close" id="guestbookClose">×</button> 1423 + <div class="guestbook-header"> 1424 + <h2>guestbook</h2> 1425 + <p>everyone who signed the guestbook for this PDS</p> 1426 + </div> 1427 + <div id="guestbookContent"></div> 1428 </div> 1429 1430 <div class="canvas">
+91
static/app.js
··· 1945 } 1946 }); 1947 }); 1948 });
··· 1945 } 1946 }); 1947 }); 1948 + 1949 + // View guestbook button handler 1950 + const viewGuestbookBtn = document.getElementById('viewGuestbookBtn'); 1951 + const guestbookModal = document.getElementById('guestbookModal'); 1952 + const guestbookClose = document.getElementById('guestbookClose'); 1953 + const guestbookContent = document.getElementById('guestbookContent'); 1954 + 1955 + if (viewGuestbookBtn && guestbookModal && guestbookClose && guestbookContent) { 1956 + viewGuestbookBtn.addEventListener('click', () => { 1957 + showGuestbookModal(); 1958 + }); 1959 + 1960 + guestbookClose.addEventListener('click', () => { 1961 + guestbookModal.classList.remove('visible'); 1962 + }); 1963 + } 1964 }); 1965 + 1966 + async function showGuestbookModal() { 1967 + const guestbookModal = document.getElementById('guestbookModal'); 1968 + const guestbookContent = document.getElementById('guestbookContent'); 1969 + 1970 + if (!guestbookModal || !guestbookContent) return; 1971 + 1972 + // Show modal with loading state 1973 + guestbookModal.classList.add('visible'); 1974 + guestbookContent.innerHTML = ` 1975 + <div class="guestbook-loading"> 1976 + <div class="guestbook-loading-spinner"></div> 1977 + <div class="guestbook-loading-text">loading signatures...</div> 1978 + </div> 1979 + `; 1980 + 1981 + try { 1982 + const response = await fetch(`/api/guestbook/signatures?did=${encodeURIComponent(did)}`); 1983 + if (!response.ok) { 1984 + throw new Error('Failed to fetch signatures'); 1985 + } 1986 + 1987 + const signatures = await response.json(); 1988 + 1989 + if (signatures.length === 0) { 1990 + guestbookContent.innerHTML = ` 1991 + <div class="guestbook-empty"> 1992 + <div class="guestbook-empty-icon">📖</div> 1993 + <div class="guestbook-empty-text">no signatures yet. be the first to sign!</div> 1994 + </div> 1995 + `; 1996 + return; 1997 + } 1998 + 1999 + // Render signatures 2000 + let html = '<div class="guestbook-list">'; 2001 + signatures.forEach(sig => { 2002 + const handle = sig.handle || 'unknown'; 2003 + const avatarHtml = sig.avatar 2004 + ? `<img src="${sig.avatar}" class="guestbook-avatar" alt="${handle}" />` 2005 + : `<div class="guestbook-avatar-placeholder">👤</div>`; 2006 + 2007 + // Format timestamp 2008 + const date = new Date(sig.timestamp); 2009 + const formattedTime = date.toLocaleDateString('en-US', { 2010 + month: 'short', 2011 + day: 'numeric', 2012 + year: 'numeric', 2013 + hour: 'numeric', 2014 + minute: '2-digit' 2015 + }); 2016 + 2017 + html += ` 2018 + <div class="guestbook-signature"> 2019 + ${avatarHtml} 2020 + <div class="guestbook-info"> 2021 + <div class="guestbook-handle">@${handle}</div> 2022 + <div class="guestbook-timestamp">${formattedTime}</div> 2023 + </div> 2024 + </div> 2025 + `; 2026 + }); 2027 + html += '</div>'; 2028 + 2029 + guestbookContent.innerHTML = html; 2030 + } catch (error) { 2031 + console.error('[Guestbook] Error loading signatures:', error); 2032 + guestbookContent.innerHTML = ` 2033 + <div class="guestbook-empty"> 2034 + <div class="guestbook-empty-icon">⚠️</div> 2035 + <div class="guestbook-empty-text">error loading signatures. please try again.</div> 2036 + </div> 2037 + `; 2038 + } 2039 + }