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

feat: improve guestbook UX and add POV indicator

- add drop shadow to guestbook avatar button
- show authenticated user's avatar in sign button (not page owner's)
- add confirmation modal for unauthenticated users: "are you @handle?"
- implement global guestbook signatures cache (all users, not per-DID)
- invalidate cache on sign/delete operations
- return handle and avatar in /api/auth/status
- add "point of view: @handle" indicator on left side with neon styling

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

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

+869 -312
+10 -3
README.md
··· 15 15 ## running locally 16 16 17 17 ```bash 18 - cargo run 18 + just dev 19 19 ``` 20 20 21 - then visit http://localhost:8080 and sign in with any atproto handle. 21 + this starts the app with hot reloading using `cargo watch`. visit http://localhost:8080 and sign in with any atproto handle. 22 22 23 23 to use a different port: 24 24 ```bash 25 - PORT=3000 cargo run 25 + just dev-port 3000 26 26 ``` 27 + 28 + see the [justfile](./justfile) for all available commands: 29 + - `just build` - build release binary 30 + - `just test` - run tests 31 + - `just deploy` - deploy to fly.io 32 + - `just check` - run clippy 33 + - `just fmt` - format code
+92
docs/firehose.md
··· 1 + # real-time updates via firehose 2 + 3 + at-me visualizes your atproto activity in real-time using the jetstream firehose. 4 + 5 + ## what is the firehose? 6 + 7 + the [atproto firehose](https://docs.bsky.app/docs/advanced-guides/firehose) is a WebSocket stream of all repository events across the network. when you create, update, or delete records in your PDS, these events flow through the firehose. 8 + 9 + we use [jetstream](https://github.com/ericvolp12/jetstream), a more efficient firehose consumer that filters and transforms events. 10 + 11 + ## architecture 12 + 13 + ### backend: rust + server-sent events 14 + 15 + **firehose manager** (`src/firehose.rs`) 16 + - maintains WebSocket connections to jetstream 17 + - one broadcaster per DID being watched 18 + - smart reconnection with exponential backoff 19 + - thread-safe using `tokio` and `Arc<Mutex>` 20 + 21 + **dynamic collection registration** 22 + - when you click "watch live", we fetch your repo's collections via `com.atproto.repo.describeRepo` 23 + - registers event ingesters for ALL collections (not just bluesky) 24 + - this means whitewind, tangled, guestbook, and any future app automatically work 25 + 26 + **event broadcasting** (`src/routes.rs:firehose_watch`) 27 + - server-sent events (SSE) endpoint at `/api/firehose/watch?did=<your-did>` 28 + - filters jetstream events to only those matching your DID and collections 29 + - broadcasts as JSON: `{action, collection, namespace, did, rkey}` 30 + 31 + ### frontend: particles + circles 32 + 33 + **WebSocket to SSE bridge** (`static/app.js`) 34 + - `EventSource` connects to SSE endpoint 35 + - parses incoming events 36 + - creates particle animations 37 + - shows toast notifications 38 + 39 + **particle system** 40 + - creates colored particles (green=create, blue=update, red=delete) 41 + - animates from app circle → identity (your PDS) 42 + - uses `requestAnimationFrame` for smooth 60fps 43 + - easing with cubic bezier for natural motion 44 + 45 + **dynamic circle management** 46 + - new app? → `addAppCircle()` creates it on the fly 47 + - delete event? → `removeAppCircle()` cleans up when particle completes 48 + - circles automatically reposition to maintain even spacing 49 + 50 + ## event flow 51 + 52 + ``` 53 + 1. you create a post in bluesky 54 + 2. bluesky writes to your PDS 55 + 3. your PDS emits event to firehose 56 + 4. jetstream filters and forwards to our backend 57 + 5. backend matches your DID + collection 58 + 6. SSE pushes event to your browser 59 + 7. particle animates from bluesky circle to center 60 + 8. identity pulses when particle arrives 61 + 9. toast shows "created post: hello world..." 62 + ``` 63 + 64 + ## why it works for any app 65 + 66 + traditional approaches hardcode collections like `app.bsky.feed.post`. we don't. 67 + 68 + instead, we: 69 + 1. call `describeRepo` to get YOUR actual collections 70 + 2. register ingesters for everything you have 71 + 3. dynamically create/remove app circles as events flow 72 + 73 + this means if you use: 74 + - whitewind → see blog posts flow in 75 + - tangled → see commits flow in 76 + - at-me guestbook → see signatures flow in 77 + - future apps → automatically supported 78 + 79 + ## performance notes 80 + 81 + - **caching**: DID resolution cached for 1 hour (`constants::CACHE_TTL`) 82 + - **buffer**: broadcast channel with 100-event buffer 83 + - **reconnection**: 5-second delay between retries 84 + - **cleanup**: connections close when SSE client disconnects 85 + 86 + ## code references 87 + 88 + - firehose manager: `src/firehose.rs` 89 + - SSE endpoint: `src/routes.rs:951` (`firehose_watch`) 90 + - dynamic registration: `src/routes.rs:985` (fetch collections via `describeRepo`) 91 + - particle animation: `static/app.js:1037` (`animateFirehoseParticles`) 92 + - circle lifecycle: `static/app.js:1419` (`addAppCircle`), `static/app.js:1646` (`removeAppCircle`)
+42 -134
src/routes.rs
··· 37 37 static DID_CACHE: Lazy<DashMap<String, CachedDid>> = 38 38 Lazy::new(|| DashMap::new()); 39 39 40 - // Guestbook signatures cache with aggressive TTL 41 - struct CachedSignatures { 42 - signatures: Vec<GuestbookSignature>, 43 - timestamp: Instant, 40 + // Guestbook signature struct 41 + #[derive(Serialize, Clone)] 42 + #[serde(rename_all = "camelCase")] 43 + pub struct GuestbookSignature { 44 + pub did: String, 45 + pub handle: Option<String>, 46 + pub avatar: Option<String>, 47 + pub timestamp: String, 44 48 } 45 49 46 - static SIGNATURES_CACHE: Lazy<DashMap<String, CachedSignatures>> = 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>> = 47 53 Lazy::new(|| DashMap::new()); 48 54 49 55 // OAuth session type matching our OAuth client configuration ··· 192 198 return HttpResponse::InternalServerError().body(format!("session error: {}", e)); 193 199 } 194 200 HttpResponse::SeeOther() 195 - .append_header(("Location", format!("/view?did={}", did_string))) 201 + .append_header(("Location", format!("/view?did={}&auth=success", did_string))) 196 202 .finish() 197 203 } else { 198 204 HttpResponse::InternalServerError().body("no did") ··· 809 815 .await 810 816 { 811 817 Ok(output) => { 812 - // Invalidate signatures cache 813 - SIGNATURES_CACHE.remove(&did); 814 - log::info!("Invalidated signatures cache for DID: {}", did); 818 + // Add to global signatures cache 819 + let (handle, avatar) = fetch_profile_info(&did).await; 820 + GLOBAL_SIGNATURES.insert(did.clone(), GuestbookSignature { 821 + did: did.clone(), 822 + handle, 823 + avatar, 824 + timestamp: chrono::Utc::now().to_rfc3339(), 825 + }); 826 + log::info!("Added signature to global cache for DID: {}", did); 815 827 816 828 HttpResponse::Ok().json(serde_json::json!({ 817 829 "success": true, ··· 832 844 let did: Option<String> = session.get(constants::SESSION_KEY_DID).unwrap_or(None); 833 845 834 846 let mut has_records = false; 847 + let mut handle: Option<String> = None; 848 + let mut avatar: Option<String> = None; 835 849 836 - // If authenticated, check if user has guestbook records 850 + // If authenticated, check if user has guestbook records and fetch their profile 837 851 if let Some(ref did_str) = did { 838 852 if let Some(agent) = AGENT_CACHE.get(did_str) { 839 853 let list_input = atrium_api::com::atproto::repo::list_records::ParametersData { ··· 850 864 has_records = !output.data.records.is_empty(); 851 865 } 852 866 } 867 + 868 + // Fetch profile info for authenticated user 869 + let (fetched_handle, fetched_avatar) = fetch_profile_info(did_str).await; 870 + handle = fetched_handle; 871 + avatar = fetched_avatar; 853 872 } 854 873 855 874 HttpResponse::Ok().json(serde_json::json!({ 856 875 "authenticated": did.is_some(), 857 876 "did": did, 877 + "handle": handle, 878 + "avatar": avatar, 858 879 "hasRecords": has_records 859 880 })) 860 881 } ··· 949 970 } 950 971 } 951 972 952 - // Invalidate signatures cache 953 - SIGNATURES_CACHE.remove(&did); 954 - log::info!("Invalidated signatures cache for DID: {}", did); 973 + // Remove from global signatures cache 974 + GLOBAL_SIGNATURES.remove(&did); 975 + log::info!("Removed signature from global cache for DID: {}", did); 955 976 956 977 HttpResponse::Ok().json(serde_json::json!({ 957 978 "success": true, ··· 964 985 did: String, 965 986 } 966 987 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 988 #[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 - } 989 + pub async fn get_guestbook_signatures() -> HttpResponse { 990 + // Return all signatures from global cache 991 + let mut signatures: Vec<GuestbookSignature> = GLOBAL_SIGNATURES 992 + .iter() 993 + .map(|entry| entry.value().clone()) 994 + .collect(); 1083 995 1084 996 // Sort by timestamp (most recent first) 1085 997 signatures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 1086 998 1087 - // Cache the result 1088 - SIGNATURES_CACHE.insert(target_did.clone(), CachedSignatures { 1089 - signatures: signatures.clone(), 1090 - timestamp: Instant::now(), 1091 - }); 999 + log::info!("Returning {} signatures from global cache", signatures.len()); 1092 1000 1093 1001 HttpResponse::Ok() 1094 - .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 1002 + .insert_header(("Cache-Control", "public, max-age=10")) 1095 1003 .json(signatures) 1096 1004 } 1097 1005
+464 -72
src/templates/app.html
··· 63 63 64 64 .info { 65 65 position: fixed; 66 - top: clamp(1rem, 2vmin, 1.5rem); 67 - left: clamp(1rem, 2vmin, 1.5rem); 68 - width: clamp(32px, 6vmin, 40px); 69 - height: clamp(32px, 6vmin, 40px); 70 - border-radius: 50%; 71 - border: 1px solid var(--border); 66 + bottom: clamp(0.75rem, 2vmin, 1rem); 67 + left: clamp(0.75rem, 2vmin, 1rem); 68 + width: clamp(32px, 7vmin, 40px); 69 + height: clamp(32px, 7vmin, 40px); 72 70 display: flex; 73 71 align-items: center; 74 72 justify-content: center; 75 - font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 73 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 76 74 color: var(--text-light); 77 75 cursor: pointer; 78 76 transition: all 0.2s ease; ··· 81 79 } 82 80 83 81 .info:hover, .info:active { 84 - background: var(--surface); 85 82 color: var(--text); 86 - border-color: var(--text-light); 87 83 } 88 84 89 85 .info-modal { ··· 1003 999 .home-btn { 1004 1000 position: fixed; 1005 1001 top: clamp(1rem, 2vmin, 1.5rem); 1006 - left: clamp(4.5rem, 10vmin, 6rem); 1002 + left: clamp(1rem, 2vmin, 1.5rem); 1007 1003 font-family: inherit; 1008 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1004 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 1009 1005 color: var(--text-light); 1010 1006 border: 1px solid var(--border); 1011 1007 background: var(--bg); 1012 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1008 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1013 1009 transition: all 0.2s ease; 1014 1010 z-index: 100; 1015 1011 cursor: pointer; ··· 1017 1013 text-decoration: none; 1018 1014 display: inline-flex; 1019 1015 align-items: center; 1016 + justify-content: center; 1017 + width: clamp(32px, 7vmin, 40px); 1018 + height: clamp(32px, 7vmin, 40px); 1020 1019 } 1021 1020 1022 1021 .home-btn:hover, .home-btn:active { ··· 1030 1029 top: clamp(1rem, 2vmin, 1.5rem); 1031 1030 right: clamp(1rem, 2vmin, 1.5rem); 1032 1031 font-family: inherit; 1033 - font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1032 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1034 1033 color: var(--text-light); 1035 - border: 2px solid var(--border); 1034 + border: 1px solid var(--border); 1036 1035 background: var(--bg); 1037 - padding: clamp(0.6rem, 1.4vmin, 0.75rem) clamp(1rem, 2.4vmin, 1.25rem); 1036 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1038 1037 transition: all 0.2s ease; 1039 1038 z-index: 100; 1040 1039 cursor: pointer; 1041 - border-radius: 4px; 1040 + border-radius: 2px; 1042 1041 display: flex; 1043 1042 align-items: center; 1044 - gap: clamp(0.5rem, 1.2vmin, 0.6rem); 1045 - font-weight: 500; 1043 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1046 1044 } 1047 1045 1048 1046 .watch-live-btn:hover, .watch-live-btn:active { 1049 1047 background: var(--surface); 1050 1048 color: var(--text); 1051 1049 border-color: var(--text-light); 1052 - transform: translateY(-1px); 1053 1050 } 1054 1051 1055 1052 .watch-live-btn.active { ··· 1087 1084 } 1088 1085 } 1089 1086 1087 + @keyframes gentle-pulse { 1088 + 0%, 100% { 1089 + transform: scale(1); 1090 + box-shadow: 0 0 0 0 var(--text-light); 1091 + } 1092 + 50% { 1093 + transform: scale(1.02); 1094 + box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); 1095 + } 1096 + } 1097 + 1098 + .sign-guestbook-btn.pulse { 1099 + animation: gentle-pulse 2s ease-in-out infinite; 1100 + } 1101 + 1090 1102 .firehose-toast { 1091 1103 position: fixed; 1092 1104 top: clamp(4rem, 8vmin, 5rem); ··· 1140 1152 } 1141 1153 1142 1154 .sign-guestbook-btn { 1143 - position: fixed; 1144 - bottom: clamp(0.75rem, 2vmin, 1rem); 1145 - right: clamp(0.75rem, 2vmin, 1rem); 1146 1155 font-family: inherit; 1147 1156 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1148 1157 color: var(--text-light); ··· 1150 1159 background: var(--bg); 1151 1160 padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1152 1161 transition: all 0.2s ease; 1153 - z-index: 100; 1154 1162 cursor: pointer; 1155 1163 border-radius: 2px; 1164 + display: flex; 1165 + align-items: center; 1166 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1167 + height: clamp(32px, 7vmin, 40px); 1156 1168 } 1157 1169 1158 1170 .sign-guestbook-btn:hover, .sign-guestbook-btn:active { ··· 1166 1178 cursor: not-allowed; 1167 1179 } 1168 1180 1169 - .view-guestbook-btn { 1181 + .sign-guestbook-btn.signed { 1182 + border-color: var(--text-light); 1183 + background: var(--surface); 1184 + } 1185 + 1186 + .guestbook-icon { 1187 + display: flex; 1188 + align-items: center; 1189 + line-height: 1; 1190 + } 1191 + 1192 + .guestbook-icon svg, 1193 + .home-btn svg, 1194 + .info svg, 1195 + .view-guestbook-btn svg { 1196 + display: block; 1197 + } 1198 + 1199 + .guestbook-avatar { 1200 + width: clamp(20px, 4.5vmin, 24px); 1201 + height: clamp(20px, 4.5vmin, 24px); 1202 + border-radius: 50%; 1203 + object-fit: cover; 1204 + border: 1px solid var(--border); 1205 + flex-shrink: 0; 1206 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1); 1207 + } 1208 + 1209 + @media (prefers-color-scheme: dark) { 1210 + .guestbook-avatar { 1211 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); 1212 + } 1213 + } 1214 + 1215 + .guestbook-buttons-container { 1170 1216 position: fixed; 1171 1217 bottom: clamp(0.75rem, 2vmin, 1rem); 1172 - right: clamp(10rem, 22vmin, 13rem); 1218 + right: clamp(0.75rem, 2vmin, 1rem); 1219 + display: flex; 1220 + align-items: center; 1221 + gap: clamp(0.5rem, 1.5vmin, 0.75rem); 1222 + z-index: 100; 1223 + } 1224 + 1225 + .view-guestbook-btn { 1173 1226 font-family: inherit; 1174 - font-size: clamp(0.8rem, 1.6vmin, 1rem); 1227 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 1175 1228 color: var(--text-light); 1176 1229 border: 1px solid var(--border); 1177 1230 background: var(--bg); 1178 1231 padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1179 1232 transition: all 0.2s ease; 1180 - z-index: 100; 1181 1233 cursor: pointer; 1182 1234 border-radius: 2px; 1183 1235 width: clamp(32px, 7vmin, 40px); ··· 1194 1246 } 1195 1247 1196 1248 @media (max-width: 768px) { 1197 - .view-guestbook-btn { 1198 - right: clamp(1rem, 2vmin, 1.5rem); 1199 - bottom: clamp(3.5rem, 8vmin, 4.5rem); 1249 + .guestbook-buttons-container { 1250 + flex-direction: column-reverse; 1251 + gap: clamp(0.5rem, 1.5vmin, 0.75rem); 1200 1252 } 1201 1253 } 1202 1254 1203 1255 @media (max-width: 768px) { 1204 1256 .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); 1257 + width: clamp(36px, 8vmin, 44px); 1258 + height: clamp(36px, 8vmin, 44px); 1212 1259 } 1213 1260 1214 1261 .firehose-toast { ··· 1230 1277 display: block; 1231 1278 } 1232 1279 1280 + /* Paper guestbook aesthetic */ 1281 + .guestbook-paper { 1282 + max-width: 700px; 1283 + margin: 0 auto; 1284 + background: linear-gradient(to bottom, #f9f7f1 0%, #f5f1e8 100%); 1285 + border: 1px solid #d4c5a8; 1286 + box-shadow: 1287 + 0 2px 4px rgba(0, 0, 0, 0.1), 1288 + inset 0 0 60px rgba(255, 248, 240, 0.5); 1289 + padding: clamp(2.5rem, 6vmin, 4rem) clamp(2rem, 5vmin, 3rem); 1290 + position: relative; 1291 + } 1292 + 1293 + @media (prefers-color-scheme: dark) { 1294 + .guestbook-paper { 1295 + background: linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1296 + border-color: #3a3530; 1297 + box-shadow: 1298 + 0 2px 4px rgba(0, 0, 0, 0.5), 1299 + inset 0 0 60px rgba(60, 50, 40, 0.3); 1300 + } 1301 + } 1302 + 1303 + .guestbook-paper::before { 1304 + content: ''; 1305 + position: absolute; 1306 + top: 0; 1307 + left: clamp(2rem, 5vmin, 3rem); 1308 + width: 2px; 1309 + height: 100%; 1310 + background: linear-gradient(to bottom, 1311 + transparent 0%, 1312 + rgba(212, 100, 100, 0.2) 5%, 1313 + rgba(212, 100, 100, 0.2) 95%, 1314 + transparent 100% 1315 + ); 1316 + } 1317 + 1318 + @media (prefers-color-scheme: dark) { 1319 + .guestbook-paper::before { 1320 + background: linear-gradient(to bottom, 1321 + transparent 0%, 1322 + rgba(180, 80, 80, 0.15) 5%, 1323 + rgba(180, 80, 80, 0.15) 95%, 1324 + transparent 100% 1325 + ); 1326 + } 1327 + } 1328 + 1329 + .guestbook-paper-title { 1330 + font-family: 'Georgia', 'Times New Roman', serif; 1331 + font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 1332 + color: #3a2f25; 1333 + text-align: center; 1334 + margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1335 + font-weight: 400; 1336 + letter-spacing: 0.05em; 1337 + } 1338 + 1339 + @media (prefers-color-scheme: dark) { 1340 + .guestbook-paper-title { 1341 + color: #d4c5a8; 1342 + } 1343 + } 1344 + 1345 + .guestbook-paper-subtitle { 1346 + font-family: 'Georgia', 'Times New Roman', serif; 1347 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1348 + color: #6b5d4f; 1349 + text-align: center; 1350 + margin-bottom: clamp(2rem, 5vmin, 3rem); 1351 + font-style: italic; 1352 + } 1353 + 1354 + @media (prefers-color-scheme: dark) { 1355 + .guestbook-paper-subtitle { 1356 + color: #8a7a6a; 1357 + } 1358 + } 1359 + 1360 + .guestbook-signatures-list { 1361 + margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1362 + } 1363 + 1364 + .guestbook-paper-signature { 1365 + padding: clamp(1rem, 2.5vmin, 1.5rem) 0; 1366 + border-bottom: 1px solid rgba(212, 197, 168, 0.3); 1367 + position: relative; 1368 + cursor: pointer; 1369 + transition: all 0.3s ease; 1370 + } 1371 + 1372 + .guestbook-paper-signature:last-child { 1373 + border-bottom: none; 1374 + } 1375 + 1376 + .guestbook-paper-signature:hover { 1377 + background: rgba(255, 248, 240, 0.3); 1378 + padding-left: 0.5rem; 1379 + padding-right: 0.5rem; 1380 + margin-left: -0.5rem; 1381 + margin-right: -0.5rem; 1382 + } 1383 + 1384 + @media (prefers-color-scheme: dark) { 1385 + .guestbook-paper-signature { 1386 + border-bottom-color: rgba(90, 80, 70, 0.3); 1387 + } 1388 + 1389 + .guestbook-paper-signature:hover { 1390 + background: rgba(60, 50, 40, 0.3); 1391 + } 1392 + } 1393 + 1394 + .guestbook-did { 1395 + font-family: 'Brush Script MT', cursive, 'Georgia', serif; 1396 + font-size: clamp(1.1rem, 2.5vmin, 1.4rem); 1397 + color: #2a2520; 1398 + margin-bottom: clamp(0.3rem, 0.8vmin, 0.5rem); 1399 + font-weight: 400; 1400 + letter-spacing: 0.02em; 1401 + word-break: break-all; 1402 + cursor: pointer; 1403 + transition: all 0.2s ease; 1404 + position: relative; 1405 + } 1406 + 1407 + .guestbook-did:hover { 1408 + color: #4a4238; 1409 + transform: translateX(2px); 1410 + } 1411 + 1412 + .guestbook-did.copied { 1413 + color: #4a4238; 1414 + } 1415 + 1416 + .guestbook-did-tooltip { 1417 + position: absolute; 1418 + top: 50%; 1419 + left: 100%; 1420 + transform: translate(10px, -50%); 1421 + background: var(--surface); 1422 + border: 1px solid var(--border); 1423 + padding: 0.25rem 0.5rem; 1424 + font-size: 0.65rem; 1425 + font-family: ui-monospace, monospace; 1426 + color: var(--text); 1427 + border-radius: 2px; 1428 + white-space: nowrap; 1429 + opacity: 0; 1430 + pointer-events: none; 1431 + transition: opacity 0.2s ease; 1432 + } 1433 + 1434 + .guestbook-did.copied .guestbook-did-tooltip { 1435 + opacity: 1; 1436 + } 1437 + 1438 + @media (prefers-color-scheme: dark) { 1439 + .guestbook-did { 1440 + color: #c9bfa8; 1441 + } 1442 + 1443 + .guestbook-did:hover { 1444 + color: #d4c5a8; 1445 + } 1446 + 1447 + .guestbook-did.copied { 1448 + color: #d4c5a8; 1449 + } 1450 + } 1451 + 1452 + .guestbook-metadata { 1453 + display: flex; 1454 + flex-direction: column; 1455 + gap: clamp(0.25rem, 0.6vmin, 0.4rem); 1456 + opacity: 0; 1457 + max-height: 0; 1458 + overflow: hidden; 1459 + transition: all 0.3s ease; 1460 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1461 + color: #6b5d4f; 1462 + font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1463 + } 1464 + 1465 + @media (prefers-color-scheme: dark) { 1466 + .guestbook-metadata { 1467 + color: #8a7a6a; 1468 + } 1469 + } 1470 + 1471 + .guestbook-paper-signature:hover .guestbook-metadata { 1472 + opacity: 1; 1473 + max-height: 100px; 1474 + margin-top: clamp(0.5rem, 1.2vmin, 0.75rem); 1475 + } 1476 + 1477 + .guestbook-metadata-item { 1478 + display: flex; 1479 + align-items: center; 1480 + gap: 0.5rem; 1481 + } 1482 + 1483 + .guestbook-metadata-label { 1484 + font-weight: 500; 1485 + color: #8a7a6a; 1486 + } 1487 + 1488 + @media (prefers-color-scheme: dark) { 1489 + .guestbook-metadata-label { 1490 + color: #6b5d4f; 1491 + } 1492 + } 1493 + 1494 + .guestbook-metadata-value { 1495 + color: #4a4238; 1496 + } 1497 + 1498 + @media (prefers-color-scheme: dark) { 1499 + .guestbook-metadata-value { 1500 + color: #a0a0a0; 1501 + } 1502 + } 1503 + 1504 + .guestbook-metadata-link { 1505 + color: #6b5d4f; 1506 + text-decoration: none; 1507 + border-bottom: 1px solid transparent; 1508 + transition: all 0.2s ease; 1509 + } 1510 + 1511 + .guestbook-metadata-link:hover { 1512 + color: #4a4238; 1513 + border-bottom-color: #4a4238; 1514 + } 1515 + 1516 + @media (prefers-color-scheme: dark) { 1517 + .guestbook-metadata-link { 1518 + color: #8a7a6a; 1519 + } 1520 + 1521 + .guestbook-metadata-link:hover { 1522 + color: #c9bfa8; 1523 + border-bottom-color: #c9bfa8; 1524 + } 1525 + } 1526 + 1233 1527 .guestbook-close { 1234 1528 position: fixed; 1235 1529 top: clamp(1rem, 2vmin, 1.5rem); ··· 1254 1548 background: var(--surface-hover); 1255 1549 border-color: var(--text-light); 1256 1550 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 1551 } 1281 1552 1282 1553 .guestbook-signature { ··· 1380 1651 font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1381 1652 color: var(--text-light); 1382 1653 } 1654 + 1655 + /* Retro neon guestbook sign */ 1656 + .guestbook-sign { 1657 + position: fixed; 1658 + bottom: clamp(3.5rem, 8vmin, 5rem); 1659 + right: clamp(0.75rem, 2vmin, 1rem); 1660 + font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1661 + font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1662 + color: var(--text-light); 1663 + text-transform: lowercase; 1664 + letter-spacing: 0.1em; 1665 + z-index: 50; 1666 + opacity: 0.6; 1667 + text-shadow: 0 0 4px currentColor; 1668 + animation: neon-flicker 8s infinite; 1669 + pointer-events: none; 1670 + user-select: none; 1671 + } 1672 + 1673 + @media (prefers-color-scheme: dark) { 1674 + .guestbook-sign { 1675 + color: #ff6b9d; 1676 + opacity: 0.5; 1677 + text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1678 + } 1679 + } 1680 + 1681 + /* POV indicator */ 1682 + .pov-indicator { 1683 + position: fixed; 1684 + left: clamp(0.75rem, 2vmin, 1rem); 1685 + top: 50%; 1686 + transform: translateY(-50%); 1687 + font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1688 + font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1689 + color: var(--text-light); 1690 + text-transform: lowercase; 1691 + letter-spacing: 0.1em; 1692 + z-index: 50; 1693 + opacity: 0.6; 1694 + text-shadow: 0 0 4px currentColor; 1695 + animation: neon-flicker 8s infinite; 1696 + pointer-events: none; 1697 + user-select: none; 1698 + text-align: left; 1699 + max-width: clamp(120px, 25vw, 180px); 1700 + line-height: 1.6; 1701 + } 1702 + 1703 + .pov-handle { 1704 + display: block; 1705 + margin-top: 0.25rem; 1706 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1707 + opacity: 0.8; 1708 + } 1709 + 1710 + @media (prefers-color-scheme: dark) { 1711 + .pov-indicator { 1712 + color: #6b9dff; 1713 + opacity: 0.5; 1714 + text-shadow: 0 0 6px currentColor, 0 0 12px rgba(107, 157, 255, 0.3); 1715 + } 1716 + } 1717 + 1718 + @media (max-width: 768px) { 1719 + .pov-indicator { 1720 + top: auto; 1721 + bottom: clamp(3.5rem, 8vmin, 5rem); 1722 + left: clamp(0.75rem, 2vmin, 1rem); 1723 + right: auto; 1724 + transform: none; 1725 + text-align: left; 1726 + max-width: 60vw; 1727 + } 1728 + } 1729 + 1730 + @keyframes neon-flicker { 1731 + 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1732 + opacity: 0.6; 1733 + text-shadow: 0 0 4px currentColor; 1734 + } 1735 + 20%, 24%, 55% { 1736 + opacity: 0.2; 1737 + text-shadow: none; 1738 + } 1739 + } 1740 + 1741 + @media (prefers-color-scheme: dark) { 1742 + @keyframes neon-flicker { 1743 + 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1744 + opacity: 0.5; 1745 + text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1746 + } 1747 + 20%, 24%, 55% { 1748 + opacity: 0.15; 1749 + text-shadow: 0 0 2px currentColor; 1750 + } 1751 + } 1752 + } 1753 + 1754 + @media (max-width: 768px) { 1755 + .guestbook-sign { 1756 + bottom: auto; 1757 + top: clamp(1rem, 2vmin, 1.5rem); 1758 + right: auto; 1759 + left: 50%; 1760 + transform: translateX(-50%); 1761 + font-size: clamp(0.55rem, 1.2vmin, 0.65rem); 1762 + } 1763 + } 1383 1764 </style> 1384 1765 </head> 1385 1766 <body> 1386 - <div class="info" id="infoBtn">?</div> 1387 - <a href="/" class="home-btn">home</a> 1767 + <a href="/" class="home-btn" title="back to landing"> 1768 + <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> 1769 + </a> 1770 + <div class="info" id="infoBtn" title="learn about your data"> 1771 + <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> 1772 + </div> 1388 1773 <button class="watch-live-btn" id="watchLiveBtn"> 1389 1774 <span class="watch-indicator"></span> 1390 1775 <span class="watch-label">watch live</span> 1391 1776 </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> 1777 + <div class="pov-indicator"> 1778 + point of view: 1779 + <span class="pov-handle" id="povHandle"></span> 1780 + </div> 1781 + <div class="guestbook-sign">sign the guest list</div> 1782 + <div class="guestbook-buttons-container"> 1783 + <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 1784 + <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> 1785 + </button> 1786 + <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 1787 + <span class="guestbook-icon"> 1788 + <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> 1789 + </span> 1790 + <span class="guestbook-text">sign guestbook</span> 1791 + <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> 1792 + </button> 1793 + </div> 1398 1794 1399 1795 <div class="firehose-toast" id="firehoseToast"> 1400 1796 <div class="firehose-toast-action"></div> ··· 1420 1816 1421 1817 <div class="guestbook-modal" id="guestbookModal"> 1422 1818 <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 1819 <div id="guestbookContent"></div> 1428 1820 </div> 1429 1821
+261 -103
static/app.js
··· 86 86 globalPds = initData.pds; 87 87 globalHandle = initData.handle; 88 88 89 + // Store viewed person's info for guestbook button 90 + viewedHandle = initData.handle; 91 + viewedAvatar = initData.avatar; 92 + 93 + // Update POV indicator 94 + const povHandleEl = document.getElementById('povHandle'); 95 + if (povHandleEl) { 96 + povHandleEl.textContent = `@${viewedHandle}`; 97 + } 98 + 89 99 // Display user's avatar if available 90 100 const avatarEl = document.getElementById('avatar'); 91 101 if (initData.avatar && avatarEl) { 92 102 avatarEl.src = initData.avatar; 93 103 avatarEl.alt = initData.handle; 94 104 } 105 + 106 + // Update guestbook button with viewed person's info 107 + updateGuestbookButton(); 95 108 96 109 // Convert apps array to object for easier access 97 110 const apps = {}; ··· 1413 1426 // ============================================================================ 1414 1427 1415 1428 let isAuthenticated = false; 1429 + let authenticatedDid = null; // The DID of the logged-in user 1430 + let authenticatedHandle = null; // The handle of the logged-in user 1431 + let authenticatedAvatar = null; // The avatar of the logged-in user 1416 1432 let hasRecords = false; 1433 + let viewedHandle = null; // Handle of the person whose page we're viewing 1434 + let viewedAvatar = null; // Avatar of the person whose page we're viewing 1417 1435 1418 1436 // Function to dynamically add an app circle to the UI 1419 1437 function addAppCircle(namespace, url) { ··· 1669 1687 try { 1670 1688 const response = await fetch('/api/auth/status'); 1671 1689 const data = await response.json(); 1690 + const wasAuthenticated = isAuthenticated; 1672 1691 isAuthenticated = data.authenticated; 1692 + authenticatedDid = data.did || null; 1693 + authenticatedHandle = data.handle || null; 1694 + authenticatedAvatar = data.avatar || null; 1673 1695 hasRecords = data.hasRecords; 1674 1696 updateGuestbookButton(); 1697 + 1698 + // Show welcome toast if just authenticated AND viewing own page 1699 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 1700 + if (isAuthenticated && !wasAuthenticated && viewingOwnPage) { 1701 + const urlParams = new URLSearchParams(window.location.search); 1702 + if (urlParams.get('auth') === 'success') { 1703 + showAuthSuccessToast(); 1704 + // Clean up URL 1705 + urlParams.delete('auth'); 1706 + const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); 1707 + window.history.replaceState({}, '', newUrl); 1708 + } 1709 + } 1675 1710 } catch (e) { 1676 1711 console.error('[Guestbook] Failed to check auth status:', e); 1677 1712 } 1678 1713 } 1679 1714 1715 + function showAuthSuccessToast() { 1716 + const toast = document.getElementById('firehoseToast'); 1717 + const actionEl = toast.querySelector('.firehose-toast-action'); 1718 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 1719 + const linkEl = document.getElementById('firehoseToastLink'); 1720 + 1721 + // Hide link for this toast 1722 + linkEl.style.display = 'none'; 1723 + 1724 + actionEl.textContent = 'signed in successfully'; 1725 + collectionEl.innerHTML = 'you may now sign the guestbook with your identity'; 1726 + 1727 + toast.classList.add('visible'); 1728 + setTimeout(() => { 1729 + toast.classList.remove('visible'); 1730 + }, 5000); 1731 + } 1732 + 1680 1733 function updateGuestbookButton() { 1681 1734 const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 1682 1735 if (!signGuestbookBtn) return; 1683 1736 1684 - if (hasRecords) { 1685 - // User already has a visit record 1686 - signGuestbookBtn.innerHTML = '📖 signed'; 1687 - signGuestbookBtn.style.background = 'var(--surface-hover)'; 1688 - signGuestbookBtn.style.color = 'var(--text)'; 1737 + const avatarImg = document.getElementById('guestbookAvatar'); 1738 + const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 1739 + const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 1740 + 1741 + if (!iconSpan || !textSpan) { 1742 + console.warn('[Guestbook] Button structure missing icon or text span'); 1743 + return; 1744 + } 1745 + 1746 + // Remove all state classes 1747 + signGuestbookBtn.classList.remove('signed', 'pulse'); 1748 + signGuestbookBtn.style.background = ''; 1749 + signGuestbookBtn.style.color = ''; 1750 + signGuestbookBtn.style.opacity = ''; 1751 + signGuestbookBtn.style.cursor = ''; 1752 + 1753 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 1754 + 1755 + console.log('[Guestbook] Button update:', { 1756 + isAuthenticated, 1757 + authenticatedDid, 1758 + pageDid: did, 1759 + viewingOwnPage, 1760 + hasRecords 1761 + }); 1762 + 1763 + if (viewingOwnPage && hasRecords) { 1764 + // Viewing own page and already signed - show checkmark 1765 + if (avatarImg) avatarImg.style.display = 'none'; 1766 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 1767 + iconSpan.style.display = 'flex'; 1768 + textSpan.textContent = 'signed'; 1769 + signGuestbookBtn.classList.add('signed'); 1770 + signGuestbookBtn.setAttribute('title', 'you\'ve signed the guestbook'); 1771 + signGuestbookBtn.disabled = false; 1689 1772 } else if (isAuthenticated) { 1690 - // Authenticated but no records yet 1691 - signGuestbookBtn.innerHTML = '📖 ready to sign'; 1692 - signGuestbookBtn.style.background = 'var(--surface)'; 1693 - signGuestbookBtn.style.color = 'var(--text)'; 1773 + // Authenticated user - show THEIR avatar (not the page owner's) 1774 + if (authenticatedAvatar && avatarImg) { 1775 + avatarImg.src = authenticatedAvatar; 1776 + avatarImg.style.display = 'block'; 1777 + iconSpan.style.display = 'none'; 1778 + } else { 1779 + // No avatar - hide both avatar and icon, just show text 1780 + if (avatarImg) avatarImg.style.display = 'none'; 1781 + iconSpan.style.display = 'none'; 1782 + } 1783 + 1784 + textSpan.textContent = 'sign as'; 1785 + 1786 + if (viewingOwnPage && !hasRecords) { 1787 + // Viewing own page, authenticated, ready to sign 1788 + signGuestbookBtn.style.background = 'var(--surface)'; 1789 + signGuestbookBtn.style.color = 'var(--text)'; 1790 + signGuestbookBtn.classList.add('pulse'); 1791 + signGuestbookBtn.setAttribute('title', 'click to sign the guestbook'); 1792 + signGuestbookBtn.disabled = false; 1793 + } else { 1794 + // Authenticated but viewing someone else's page - disabled 1795 + signGuestbookBtn.setAttribute('title', 'visit your own page to sign'); 1796 + signGuestbookBtn.disabled = true; 1797 + signGuestbookBtn.style.opacity = '0.5'; 1798 + signGuestbookBtn.style.cursor = 'not-allowed'; 1799 + } 1694 1800 } else { 1695 - // Not authenticated 1696 - signGuestbookBtn.innerHTML = '📖 sign guestbook'; 1697 - signGuestbookBtn.style.background = 'var(--bg)'; 1698 - signGuestbookBtn.style.color = 'var(--text-light)'; 1801 + // Not authenticated - show page owner's avatar 1802 + if (viewedAvatar && avatarImg) { 1803 + avatarImg.src = viewedAvatar; 1804 + avatarImg.style.display = 'block'; 1805 + iconSpan.style.display = 'none'; 1806 + } else { 1807 + // No avatar - hide both avatar and icon, just show text 1808 + if (avatarImg) avatarImg.style.display = 'none'; 1809 + iconSpan.style.display = 'none'; 1810 + } 1811 + 1812 + textSpan.textContent = 'sign as'; 1813 + signGuestbookBtn.setAttribute('title', `sign in as @${viewedHandle || 'user'}`); 1814 + signGuestbookBtn.disabled = false; 1699 1815 } 1700 1816 } 1701 1817 1702 - function showHandleModal() { 1818 + function showHandleConfirmation(suggestedHandle) { 1703 1819 // Create modal overlay 1704 1820 const overlay = document.createElement('div'); 1705 1821 overlay.className = 'overlay'; ··· 1712 1828 modal.style.maxWidth = '400px'; 1713 1829 1714 1830 modal.innerHTML = ` 1715 - <h2>sign the guestbook</h2> 1716 - <p style="margin-bottom: 1rem;">enter your atproto handle to authenticate</p> 1717 - <input type="text" id="handleInput" placeholder="user.bsky.social" 1718 - style="width: 100%; padding: 0.5rem; font-family: inherit; font-size: 0.75rem; 1719 - border: 1px solid var(--border); background: var(--bg); color: var(--text); 1720 - border-radius: 2px; margin-bottom: 1rem;"> 1831 + <h2>confirm identity</h2> 1832 + <p style="margin-bottom: 1rem;">are you <strong>@${suggestedHandle}</strong>?</p> 1833 + <p style="margin-bottom: 1rem; color: var(--text-light); font-size: 0.7rem;">only you can authenticate as this person. you'll be redirected to sign in.</p> 1721 1834 <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 1722 - <button id="cancelBtn" style="background: var(--bg);">cancel</button> 1723 - <button id="continueBtn" style="background: var(--surface-hover);">continue</button> 1835 + <button id="cancelBtn" style="background: var(--bg);">no, cancel</button> 1836 + <button id="confirmBtn" style="background: var(--surface-hover);">yes, that's me</button> 1724 1837 </div> 1725 1838 `; 1726 1839 1727 1840 document.body.appendChild(overlay); 1728 1841 document.body.appendChild(modal); 1729 1842 1730 - const input = document.getElementById('handleInput'); 1731 1843 const cancelBtn = document.getElementById('cancelBtn'); 1732 - const continueBtn = document.getElementById('continueBtn'); 1733 - 1734 - // Focus input 1735 - setTimeout(() => input.focus(), 100); 1736 - 1737 - // Handle enter key 1738 - input.addEventListener('keypress', (e) => { 1739 - if (e.key === 'Enter') continueBtn.click(); 1740 - }); 1844 + const confirmBtn = document.getElementById('confirmBtn'); 1741 1845 1742 1846 // Cancel 1743 1847 const closeModal = () => { ··· 1748 1852 cancelBtn.addEventListener('click', closeModal); 1749 1853 overlay.addEventListener('click', closeModal); 1750 1854 1751 - // Continue 1752 - continueBtn.addEventListener('click', () => { 1753 - const handle = input.value.trim(); 1754 - if (!handle) { 1755 - input.style.borderColor = 'var(--text)'; 1756 - return; 1757 - } 1758 - 1759 - // Submit login form 1855 + // Confirm 1856 + confirmBtn.addEventListener('click', () => { 1857 + // Submit login form with the suggested handle 1760 1858 const form = document.createElement('form'); 1761 1859 form.method = 'POST'; 1762 1860 form.action = '/login'; 1763 1861 const hiddenInput = document.createElement('input'); 1764 1862 hiddenInput.type = 'hidden'; 1765 1863 hiddenInput.name = 'handle'; 1766 - hiddenInput.value = handle; 1864 + hiddenInput.value = suggestedHandle; 1767 1865 form.appendChild(hiddenInput); 1768 1866 document.body.appendChild(form); 1769 1867 form.submit(); ··· 1897 1995 checkAuthStatus(); 1898 1996 1899 1997 signGuestbookBtn.addEventListener('click', async () => { 1998 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 1999 + 2000 + // Only allow actions if viewing own page OR not authenticated (to show login) 2001 + if (!viewingOwnPage && isAuthenticated) { 2002 + // Authenticated but viewing someone else's page - do nothing 2003 + return; 2004 + } 2005 + 1900 2006 // If user already has records, show unsign modal 1901 - if (hasRecords) { 2007 + if (hasRecords && viewingOwnPage) { 1902 2008 showUnsignModal(); 1903 2009 return; 1904 2010 } 1905 2011 1906 - // If not authenticated, show modal to get handle 2012 + // If not authenticated, show confirmation with the viewed handle 1907 2013 if (!isAuthenticated) { 1908 - showHandleModal(); 2014 + if (viewedHandle) { 2015 + showHandleConfirmation(viewedHandle); 2016 + } 1909 2017 return; 1910 2018 } 1911 2019 1912 - // Authenticated - show watch prompt, then sign 1913 - showWatchPrompt(async () => { 2020 + // Authenticated and viewing own page - show watch prompt, then sign 2021 + if (viewingOwnPage) { 2022 + showWatchPrompt(async () => { 1914 2023 signGuestbookBtn.disabled = true; 1915 - const originalText = signGuestbookBtn.innerHTML; 1916 - signGuestbookBtn.innerHTML = '📖 signing...'; 2024 + const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2025 + const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 1917 2026 1918 - try { 1919 - const response = await fetch('/api/sign-guestbook', { 1920 - method: 'POST', 1921 - headers: { 1922 - 'Content-Type': 'application/json', 1923 - } 1924 - }); 2027 + if (iconSpan && textSpan) { 2028 + const originalIcon = iconSpan.innerHTML; 2029 + const originalText = textSpan.textContent; 2030 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; 2031 + textSpan.textContent = 'signing...'; 1925 2032 1926 - const data = await response.json(); 2033 + try { 2034 + const response = await fetch('/api/sign-guestbook', { 2035 + method: 'POST', 2036 + headers: { 2037 + 'Content-Type': 'application/json', 2038 + } 2039 + }); 1927 2040 1928 - if (data.success) { 1929 - signGuestbookBtn.innerHTML = '📖 signed!'; 1930 - // Refresh auth status to update button 1931 - await checkAuthStatus(); 2041 + const data = await response.json(); 2042 + 2043 + if (data.success) { 2044 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 2045 + textSpan.textContent = 'signed!'; 2046 + // Refresh auth status to update button 2047 + await checkAuthStatus(); 2048 + setTimeout(() => { 2049 + signGuestbookBtn.disabled = false; 2050 + }, 1000); 2051 + } else { 2052 + throw new Error(data.error || 'Unknown error'); 2053 + } 2054 + } catch (error) { 2055 + console.error('[Guestbook] Error signing guestbook:', error); 2056 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>'; 2057 + textSpan.textContent = 'error'; 1932 2058 setTimeout(() => { 2059 + iconSpan.innerHTML = originalIcon; 2060 + textSpan.textContent = originalText; 1933 2061 signGuestbookBtn.disabled = false; 1934 - }, 1000); 1935 - } else { 1936 - throw new Error(data.error || 'Unknown error'); 2062 + }, 2000); 1937 2063 } 1938 - } catch (error) { 1939 - console.error('[Guestbook] Error signing guestbook:', error); 1940 - signGuestbookBtn.innerHTML = '📖 error'; 1941 - setTimeout(() => { 1942 - signGuestbookBtn.innerHTML = originalText; 1943 - signGuestbookBtn.disabled = false; 1944 - }, 2000); 1945 2064 } 1946 2065 }); 2066 + } 1947 2067 }); 1948 2068 1949 2069 // View guestbook button handler ··· 1972 2092 // Show modal with loading state 1973 2093 guestbookModal.classList.add('visible'); 1974 2094 guestbookContent.innerHTML = ` 1975 - <div class="guestbook-loading"> 1976 - <div class="guestbook-loading-spinner"></div> 1977 - <div class="guestbook-loading-text">loading signatures...</div> 2095 + <div class="guestbook-paper"> 2096 + <div class="guestbook-loading"> 2097 + <div class="guestbook-loading-spinner"></div> 2098 + <div class="guestbook-loading-text">loading signatures...</div> 2099 + </div> 1978 2100 </div> 1979 2101 `; 1980 2102 1981 2103 try { 1982 - const response = await fetch(`/api/guestbook/signatures?did=${encodeURIComponent(did)}`); 2104 + // Fetch ALL signatures globally (not filtered by DID) 2105 + const response = await fetch(`/api/guestbook/signatures`); 1983 2106 if (!response.ok) { 1984 2107 throw new Error('Failed to fetch signatures'); 1985 2108 } ··· 1988 2111 1989 2112 if (signatures.length === 0) { 1990 2113 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> 2114 + <div class="guestbook-paper"> 2115 + <h1 class="guestbook-paper-title">Guestbook</h1> 2116 + <p class="guestbook-paper-subtitle">Visitors to this Personal Data Server</p> 2117 + <div class="guestbook-empty"> 2118 + <div class="guestbook-empty-text">No signatures yet. Be the first to sign!</div> 2119 + </div> 1994 2120 </div> 1995 2121 `; 1996 2122 return; 1997 2123 } 1998 2124 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>`; 2125 + // Render signatures with paper aesthetic 2126 + let html = ` 2127 + <div class="guestbook-paper"> 2128 + <h1 class="guestbook-paper-title">Guestbook</h1> 2129 + <p class="guestbook-paper-subtitle">Visitors to this application</p> 2130 + <div class="guestbook-signatures-list"> 2131 + `; 2006 2132 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 - }); 2133 + signatures.forEach((sig, index) => { 2134 + const handle = sig.handle || 'unknown'; 2135 + const did = sig.did || 'did:unknown'; 2016 2136 2017 2137 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> 2138 + <div class="guestbook-paper-signature"> 2139 + <div class="guestbook-did" data-did="${did}" data-index="${index}"> 2140 + ${did} 2141 + <span class="guestbook-did-tooltip">copied!</span> 2142 + </div> 2143 + <div class="guestbook-metadata"> 2144 + <div class="guestbook-metadata-item"> 2145 + <span class="guestbook-metadata-label">handle:</span> 2146 + <span class="guestbook-metadata-value">@${handle}</span> 2147 + </div> 2148 + <div class="guestbook-metadata-item"> 2149 + <span class="guestbook-metadata-label">bluesky:</span> 2150 + <a href="https://bsky.app/profile/${handle}" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view profile ↗</a> 2151 + </div> 2023 2152 </div> 2024 2153 </div> 2025 2154 `; 2026 2155 }); 2027 - html += '</div>'; 2156 + 2157 + html += ` 2158 + </div> 2159 + </div> 2160 + `; 2028 2161 2029 2162 guestbookContent.innerHTML = html; 2163 + 2164 + // Add click handlers to DIDs for copying 2165 + document.querySelectorAll('.guestbook-did').forEach(didElement => { 2166 + didElement.addEventListener('click', async (e) => { 2167 + e.stopPropagation(); 2168 + const did = didElement.dataset.did; 2169 + 2170 + try { 2171 + await navigator.clipboard.writeText(did); 2172 + 2173 + // Add copied class for animation 2174 + didElement.classList.add('copied'); 2175 + 2176 + // Remove after animation 2177 + setTimeout(() => { 2178 + didElement.classList.remove('copied'); 2179 + }, 2000); 2180 + } catch (err) { 2181 + console.error('[Guestbook] Failed to copy DID:', err); 2182 + } 2183 + }); 2184 + }); 2030 2185 } catch (error) { 2031 2186 console.error('[Guestbook] Error loading signatures:', error); 2032 2187 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> 2188 + <div class="guestbook-paper"> 2189 + <h1 class="guestbook-paper-title">Guestbook</h1> 2190 + <p class="guestbook-paper-subtitle">Visitors to this Personal Data Server</p> 2191 + <div class="guestbook-empty"> 2192 + <div class="guestbook-empty-text">Error loading signatures. Please try again.</div> 2193 + </div> 2036 2194 </div> 2037 2195 `; 2038 2196 }