+7
Cargo.lock
+7
Cargo.lock
···
465
465
"serde",
466
466
"serde_json",
467
467
"tokio",
468
+
"urlencoding",
468
469
]
469
470
470
471
[[package]]
···
3574
3575
"percent-encoding",
3575
3576
"serde",
3576
3577
]
3578
+
3579
+
[[package]]
3580
+
name = "urlencoding"
3581
+
version = "2.1.3"
3582
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3583
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
3577
3584
3578
3585
[[package]]
3579
3586
name = "utf-8"
+1
Cargo.toml
+1
Cargo.toml
+2
src/constants.rs
+2
src/constants.rs
···
4
4
pub const BSKY_API_RESOLVE_HANDLE: &str =
5
5
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle";
6
6
pub const BSKY_API_GET_PROFILE: &str = "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile";
7
+
pub const BSKY_API_SEARCH_ACTORS: &str =
8
+
"https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors";
7
9
pub const PLC_DIRECTORY: &str = "https://plc.directory";
8
10
9
11
// Server Configuration
+1
src/main.rs
+1
src/main.rs
+71
src/routes.rs
+71
src/routes.rs
···
608
608
}
609
609
610
610
#[derive(Deserialize)]
611
+
pub struct SearchHandlesQuery {
612
+
q: String,
613
+
}
614
+
615
+
#[derive(Serialize)]
616
+
#[serde(rename_all = "camelCase")]
617
+
pub struct HandleSearchResult {
618
+
did: String,
619
+
handle: String,
620
+
display_name: String,
621
+
avatar_url: Option<String>,
622
+
}
623
+
624
+
#[get("/api/search/handles")]
625
+
pub async fn search_handles(query: web::Query<SearchHandlesQuery>) -> HttpResponse {
626
+
let q = &query.q;
627
+
628
+
if q.len() < 2 {
629
+
return HttpResponse::Ok().json(serde_json::json!({
630
+
"results": []
631
+
}));
632
+
}
633
+
634
+
let search_url = format!(
635
+
"{}?q={}&limit=8",
636
+
constants::BSKY_API_SEARCH_ACTORS,
637
+
urlencoding::encode(q)
638
+
);
639
+
640
+
match http_get(&search_url).await {
641
+
Ok(response) => match response.json::<serde_json::Value>().await {
642
+
Ok(data) => {
643
+
let results: Vec<HandleSearchResult> = data["actors"]
644
+
.as_array()
645
+
.map(|actors| {
646
+
actors
647
+
.iter()
648
+
.map(|actor| HandleSearchResult {
649
+
did: actor["did"].as_str().unwrap_or("").to_string(),
650
+
handle: actor["handle"].as_str().unwrap_or("").to_string(),
651
+
display_name: actor["displayName"]
652
+
.as_str()
653
+
.unwrap_or_else(|| actor["handle"].as_str().unwrap_or(""))
654
+
.to_string(),
655
+
avatar_url: actor["avatar"].as_str().map(String::from),
656
+
})
657
+
.collect()
658
+
})
659
+
.unwrap_or_default();
660
+
661
+
HttpResponse::Ok().json(serde_json::json!({
662
+
"results": results
663
+
}))
664
+
}
665
+
Err(e) => {
666
+
log::error!("Failed to parse search response: {}", e);
667
+
HttpResponse::Ok().json(serde_json::json!({
668
+
"results": []
669
+
}))
670
+
}
671
+
},
672
+
Err(e) => {
673
+
log::error!("Failed to search handles: {}", e);
674
+
HttpResponse::Ok().json(serde_json::json!({
675
+
"results": []
676
+
}))
677
+
}
678
+
}
679
+
}
680
+
681
+
#[derive(Deserialize)]
611
682
pub struct AvatarQuery {
612
683
namespace: String,
613
684
}
+201
-1
src/templates/landing.html
+201
-1
src/templates/landing.html
···
161
161
color: rgba(255, 255, 255, 0.3);
162
162
}
163
163
164
+
.input-wrapper {
165
+
position: relative;
166
+
width: 100%;
167
+
}
168
+
169
+
.autocomplete-results {
170
+
position: absolute;
171
+
z-index: 100;
172
+
width: 100%;
173
+
max-height: 240px;
174
+
overflow-y: auto;
175
+
background: rgba(10, 10, 15, 0.98);
176
+
border: 1px solid rgba(255, 255, 255, 0.2);
177
+
border-radius: 4px;
178
+
margin-top: 0.25rem;
179
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
180
+
display: none;
181
+
}
182
+
183
+
.autocomplete-results.show {
184
+
display: block;
185
+
}
186
+
187
+
.autocomplete-item {
188
+
width: 100%;
189
+
display: flex;
190
+
align-items: center;
191
+
gap: 0.75rem;
192
+
padding: 0.75rem;
193
+
background: transparent;
194
+
border: none;
195
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
196
+
color: #e5e5e5;
197
+
text-align: left;
198
+
font-family: inherit;
199
+
cursor: pointer;
200
+
transition: background 0.15s;
201
+
}
202
+
203
+
.autocomplete-item:last-child {
204
+
border-bottom: none;
205
+
}
206
+
207
+
.autocomplete-item:hover {
208
+
background: rgba(255, 255, 255, 0.1);
209
+
}
210
+
211
+
.autocomplete-avatar {
212
+
width: 36px;
213
+
height: 36px;
214
+
border-radius: 50%;
215
+
object-fit: cover;
216
+
border: 1px solid rgba(255, 255, 255, 0.2);
217
+
flex-shrink: 0;
218
+
}
219
+
220
+
.autocomplete-avatar-placeholder {
221
+
width: 36px;
222
+
height: 36px;
223
+
border-radius: 50%;
224
+
background: rgba(255, 255, 255, 0.1);
225
+
flex-shrink: 0;
226
+
display: flex;
227
+
align-items: center;
228
+
justify-content: center;
229
+
font-size: 0.9rem;
230
+
color: rgba(255, 255, 255, 0.5);
231
+
}
232
+
233
+
.autocomplete-info {
234
+
flex: 1;
235
+
min-width: 0;
236
+
overflow: hidden;
237
+
}
238
+
239
+
.autocomplete-name {
240
+
font-weight: 500;
241
+
color: rgba(255, 255, 255, 0.9);
242
+
margin-bottom: 0.125rem;
243
+
overflow: hidden;
244
+
text-overflow: ellipsis;
245
+
white-space: nowrap;
246
+
font-size: 0.85rem;
247
+
}
248
+
249
+
.autocomplete-handle {
250
+
font-size: 0.75rem;
251
+
color: rgba(255, 255, 255, 0.5);
252
+
overflow: hidden;
253
+
text-overflow: ellipsis;
254
+
white-space: nowrap;
255
+
}
256
+
257
+
.search-spinner {
258
+
position: absolute;
259
+
right: 0.75rem;
260
+
top: 50%;
261
+
transform: translateY(-50%);
262
+
color: rgba(255, 255, 255, 0.4);
263
+
font-size: 0.75rem;
264
+
}
265
+
164
266
button {
165
267
font-family: inherit;
166
268
font-size: 0.9rem;
···
327
429
<h1>@me</h1>
328
430
<div class="subtitle">explore the atmosphere</div>
329
431
<form id="searchForm" onsubmit="event.preventDefault(); handleSearch();">
330
-
<input type="text" id="handleInput" placeholder="enter any handle" autofocus>
432
+
<div class="input-wrapper">
433
+
<input type="text" id="handleInput" placeholder="enter any handle" autofocus autocomplete="off" autocapitalize="off" spellcheck="false">
434
+
<span class="search-spinner" id="searchSpinner" style="display: none;">...</span>
435
+
<div class="autocomplete-results" id="autocompleteResults"></div>
436
+
</div>
331
437
<button type="submit">explore</button>
332
438
</form>
333
439
···
361
467
</div>
362
468
363
469
<script>
470
+
// Autocomplete state
471
+
let searchTimeout = null;
472
+
let autocompleteResults = [];
473
+
364
474
function handleSearch() {
365
475
const handle = document.getElementById('handleInput').value.trim();
366
476
if (handle) {
···
375
485
function toggleInfo() {
376
486
document.getElementById('infoContent').classList.toggle('expanded');
377
487
}
488
+
489
+
// Autocomplete functionality
490
+
const handleInput = document.getElementById('handleInput');
491
+
const resultsDiv = document.getElementById('autocompleteResults');
492
+
const spinner = document.getElementById('searchSpinner');
493
+
494
+
async function searchHandles(query) {
495
+
if (query.length < 2) {
496
+
autocompleteResults = [];
497
+
hideResults();
498
+
return;
499
+
}
500
+
501
+
spinner.style.display = 'block';
502
+
503
+
try {
504
+
const response = await fetch(`/api/search/handles?q=${encodeURIComponent(query)}`);
505
+
if (response.ok) {
506
+
const data = await response.json();
507
+
autocompleteResults = data.results;
508
+
renderResults();
509
+
}
510
+
} catch (e) {
511
+
console.error('search failed:', e);
512
+
} finally {
513
+
spinner.style.display = 'none';
514
+
}
515
+
}
516
+
517
+
function renderResults() {
518
+
if (autocompleteResults.length === 0) {
519
+
hideResults();
520
+
return;
521
+
}
522
+
523
+
resultsDiv.innerHTML = autocompleteResults.map(result => `
524
+
<button type="button" class="autocomplete-item" onclick="selectHandle('${result.handle}')">
525
+
${result.avatarUrl
526
+
? `<img src="${result.avatarUrl}" alt="" class="autocomplete-avatar">`
527
+
: `<div class="autocomplete-avatar-placeholder">${result.handle[0].toUpperCase()}</div>`
528
+
}
529
+
<div class="autocomplete-info">
530
+
<div class="autocomplete-name">${escapeHtml(result.displayName)}</div>
531
+
<div class="autocomplete-handle">@${escapeHtml(result.handle)}</div>
532
+
</div>
533
+
</button>
534
+
`).join('');
535
+
536
+
resultsDiv.classList.add('show');
537
+
}
538
+
539
+
function escapeHtml(text) {
540
+
const div = document.createElement('div');
541
+
div.textContent = text;
542
+
return div.innerHTML;
543
+
}
544
+
545
+
function hideResults() {
546
+
resultsDiv.classList.remove('show');
547
+
}
548
+
549
+
function selectHandle(handle) {
550
+
handleInput.value = handle;
551
+
autocompleteResults = [];
552
+
hideResults();
553
+
viewHandle(handle);
554
+
}
555
+
556
+
handleInput.addEventListener('input', () => {
557
+
if (searchTimeout) clearTimeout(searchTimeout);
558
+
searchTimeout = setTimeout(() => searchHandles(handleInput.value), 300);
559
+
});
560
+
561
+
handleInput.addEventListener('keydown', (e) => {
562
+
if (e.key === 'Escape') {
563
+
hideResults();
564
+
}
565
+
});
566
+
567
+
handleInput.addEventListener('focus', () => {
568
+
if (autocompleteResults.length > 0) {
569
+
resultsDiv.classList.add('show');
570
+
}
571
+
});
572
+
573
+
document.addEventListener('click', (e) => {
574
+
if (!e.target.closest('.input-wrapper')) {
575
+
hideResults();
576
+
}
577
+
});
378
578
379
579
// Atmosphere rendering
380
580
async function fetchAtmosphere() {