interactive intro to open social

feat: add URL validation for app namespace links

validates all app URLs concurrently on page load:
- created /api/validate-url endpoint with HEAD request and 3s timeout
- grays out invalid links with reduced opacity
- adds tooltip explaining domain is not reachable
- disables clicking on invalid links

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

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

Changed files
+53 -1
src
static
+1
src/main.rs
··· 40 40 .service(routes::get_mst) 41 41 .service(routes::init) 42 42 .service(routes::get_avatar) 43 + .service(routes::validate_url) 43 44 .service(routes::favicon) 44 45 .service(Files::new("/static", "./static")) 45 46 })
+25
src/routes.rs
··· 346 346 "avatarUrl": null 347 347 })) 348 348 } 349 + 350 + #[derive(Deserialize)] 351 + pub struct ValidateUrlQuery { 352 + url: String, 353 + } 354 + 355 + #[get("/api/validate-url")] 356 + pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse { 357 + let url = &query.url; 358 + 359 + // Try to make a HEAD request with a short timeout 360 + let client = reqwest::Client::builder() 361 + .timeout(std::time::Duration::from_secs(3)) 362 + .build() 363 + .unwrap(); 364 + 365 + let is_valid = match client.head(url).send().await { 366 + Ok(response) => response.status().is_success() || response.status().is_redirection(), 367 + Err(_) => false, 368 + }; 369 + 370 + HttpResponse::Ok().json(serde_json::json!({ 371 + "valid": is_valid 372 + })) 373 + }
+11
src/templates.rs
··· 534 534 color: var(--text); 535 535 }} 536 536 537 + .app-name.invalid-link {{ 538 + color: var(--text-light); 539 + opacity: 0.5; 540 + cursor: not-allowed; 541 + }} 542 + 543 + .app-name.invalid-link:hover {{ 544 + text-decoration: none; 545 + color: var(--text-light); 546 + }} 547 + 537 548 .detail-panel {{ 538 549 position: fixed; 539 550 top: 0;
+16 -1
static/app.js
··· 156 156 157 157 div.innerHTML = ` 158 158 <div class="app-circle" data-namespace="${namespace}">${firstLetter}</div> 159 - <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name">${displayName}</a> 159 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName}</a> 160 160 `; 161 161 162 162 // Try to fetch and display avatar ··· 166 166 circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 167 167 } 168 168 }); 169 + 170 + // Validate URL 171 + fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 172 + .then(r => r.json()) 173 + .then(data => { 174 + const link = div.querySelector('.app-name'); 175 + if (!data.valid) { 176 + link.classList.add('invalid-link'); 177 + link.setAttribute('title', 'this domain is not reachable'); 178 + link.style.pointerEvents = 'none'; 179 + } 180 + }) 181 + .catch(() => { 182 + // Silently fail validation check 183 + }); 169 184 170 185 div.addEventListener('click', () => { 171 186 const detail = document.getElementById('detail');