+374
-135
main.tsx
+374
-135
main.tsx
···
75
75
</html>
76
76
);
77
77
78
-
const SearchPage = () => (
78
+
const RecentSearches = ({
79
+
recentRecords,
80
+
actors,
81
+
}: {
82
+
recentRecords: IndexedRecord[];
83
+
actors: Map<string, Actor>;
84
+
}) => {
85
+
return (
86
+
<div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm mb-4">
87
+
<div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10">
88
+
<div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider">
89
+
RECENT_ACTIVITY // QUICK_ACCESS
90
+
</div>
91
+
</div>
92
+
<div class="p-2 sm:p-3">
93
+
<div class="flex gap-2 overflow-x-auto scrollbar-thin scrollbar-thumb-cyan-500/30 scrollbar-track-black/50">
94
+
{recentRecords.map((record, index) => {
95
+
const actor = actors.get(record.did);
96
+
const isRepo = record.collection === "sh.tangled.repo";
97
+
const repo = isRepo
98
+
? (record.value as unknown as ShTangledRepo)
99
+
: null;
100
+
const profile = !isRepo
101
+
? (record.value as unknown as AppBskyActorProfile)
102
+
: null;
103
+
104
+
const url =
105
+
isRepo && actor?.handle && repo?.name
106
+
? `https://tangled.sh/@${actor.handle}/${repo.name}`
107
+
: !isRepo && actor?.handle
108
+
? `https://tangled.sh/@${actor.handle}`
109
+
: "#";
110
+
111
+
return (
112
+
<div class="flex-shrink-0 flex items-center gap-2 px-3 py-2 border border-cyan-500/20 bg-black/50 hover:bg-cyan-500/5 transition-all text-xs group">
113
+
<a
114
+
href={url}
115
+
target="_blank"
116
+
rel="noopener noreferrer"
117
+
class="flex items-center gap-2 no-underline"
118
+
//@ts-expect-error hyperscript
119
+
_={`on click
120
+
fetch /track-click {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text`}
121
+
>
122
+
<span class="text-cyan-400">
123
+
[{String(index + 1).padStart(2, "0")}]
124
+
</span>
125
+
<span class="text-green-400 font-mono max-w-[150px] truncate">
126
+
{isRepo ? repo?.name : profile?.displayName || actor?.handle}
127
+
</span>
128
+
<span class="text-pink-400/70 text-[10px]">
129
+
{isRepo ? "R" : "P"}
130
+
</span>
131
+
</a>
132
+
<button
133
+
type="button"
134
+
class="ml-1 text-red-400 hover:text-red-300 leading-none text-xs font-mono"
135
+
title="Remove from recent"
136
+
//@ts-expect-error hyperscript
137
+
_={`on click
138
+
halt the event
139
+
fetch /remove-recent {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text
140
+
then window.location.reload()`}
141
+
>
142
+
[-]
143
+
</button>
144
+
</div>
145
+
);
146
+
})}
147
+
</div>
148
+
</div>
149
+
</div>
150
+
);
151
+
};
152
+
153
+
const SearchPage = ({
154
+
recentRecords,
155
+
actors,
156
+
}: {
157
+
recentRecords?: IndexedRecord[];
158
+
actors?: Map<string, Actor>;
159
+
}) => (
79
160
<Layout>
80
161
<div class="w-full max-w-4xl relative z-10 min-h-screen sm:min-h-0 px-3 sm:px-6">
81
162
<div
···
166
247
</div>
167
248
168
249
<div id="search-container" class="">
250
+
<div id="recent-container">
251
+
{recentRecords && recentRecords.length > 0 && actors && (
252
+
<RecentSearches recentRecords={recentRecords} actors={actors} />
253
+
)}
254
+
</div>
169
255
<div
170
256
id="loading"
171
257
class="htmx-indicator items-center justify-center py-8"
···
223
309
});
224
310
225
311
return (
226
-
<div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm">
227
-
<div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10">
228
-
<div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider flex flex-col sm:flex-row justify-between gap-1 sm:gap-0">
229
-
<span class="truncate">SEARCH_RESULTS // QUERY: "{query}"</span>
230
-
<span class="text-[10px] sm:text-xs">
231
-
TYPE: {searchType.toUpperCase()}
232
-
</span>
312
+
<>
313
+
<div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm">
314
+
<div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10">
315
+
<div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider flex flex-col sm:flex-row justify-between gap-1 sm:gap-0">
316
+
<span class="truncate">SEARCH_RESULTS // QUERY: "{query}"</span>
317
+
<span class="text-[10px] sm:text-xs">
318
+
TYPE: {searchType.toUpperCase()}
319
+
</span>
320
+
</div>
233
321
</div>
234
-
</div>
235
322
236
-
{displayResults.length === 0 ? (
237
-
<div class="p-3 sm:p-4 text-red-400 border-l-2 border-red-500 text-sm">
238
-
ERROR: NO_RESULTS_FOUND // QUERY: "{query}"
239
-
</div>
240
-
) : (
241
-
<div class="p-3 sm:p-4">
242
-
<div class="text-green-400 text-[10px] sm:text-xs mb-3 sm:mb-4 flex items-center gap-2">
243
-
<span class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-400 rounded-full animate-pulse"></span>
244
-
FOUND {displayResults.length} RESULT
245
-
{displayResults.length !== 1 ? "S" : ""} // STATUS: OK
323
+
{displayResults.length === 0 ? (
324
+
<div class="p-3 sm:p-4 text-red-400 border-l-2 border-red-500 text-sm">
325
+
ERROR: NO_RESULTS_FOUND // QUERY: "{query}"
246
326
</div>
327
+
) : (
328
+
<div class="p-3 sm:p-4">
329
+
<div class="text-green-400 text-[10px] sm:text-xs mb-3 sm:mb-4 flex items-center gap-2">
330
+
<span class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-400 rounded-full animate-pulse"></span>
331
+
FOUND {displayResults.length} RESULT
332
+
{displayResults.length !== 1 ? "S" : ""} // STATUS: OK
333
+
</div>
247
334
248
-
<div class="space-y-3 sm:space-y-4 sm:max-h-80 sm:overflow-y-auto">
249
-
{displayResults.map((record, index) => {
250
-
const actor = actors.get(record.did);
251
-
const isRepo = record.collection === "sh.tangled.repo";
252
-
const repo = isRepo
253
-
? (record.value as unknown as ShTangledRepo)
254
-
: null;
255
-
const profile = !isRepo
256
-
? (record.value as unknown as AppBskyActorProfile)
257
-
: null;
258
-
259
-
const repoUrl =
260
-
isRepo && actor?.handle && repo?.name
261
-
? `https://tangled.sh/@${actor.handle}/${repo.name}`
335
+
<div class="space-y-3 sm:space-y-4 sm:max-h-80 sm:overflow-y-auto">
336
+
{displayResults.map((record, index) => {
337
+
const actor = actors.get(record.did);
338
+
const isRepo = record.collection === "sh.tangled.repo";
339
+
const repo = isRepo
340
+
? (record.value as unknown as ShTangledRepo)
262
341
: null;
263
-
264
-
const profileUrl =
265
-
!isRepo && actor?.handle
266
-
? `https://tangled.sh/@${actor.handle}`
342
+
const profile = !isRepo
343
+
? (record.value as unknown as AppBskyActorProfile)
267
344
: null;
268
345
269
-
const clickUrl = repoUrl || profileUrl;
346
+
const repoUrl =
347
+
isRepo && actor?.handle && repo?.name
348
+
? `https://tangled.sh/@${actor.handle}/${repo.name}`
349
+
: null;
270
350
271
-
const ResultContainer = clickUrl ? "a" : "div";
272
-
const containerProps = clickUrl
273
-
? {
274
-
href: clickUrl,
275
-
target: "_blank",
276
-
rel: "noopener noreferrer",
277
-
}
278
-
: {};
351
+
const profileUrl =
352
+
!isRepo && actor?.handle
353
+
? `https://tangled.sh/@${actor.handle}`
354
+
: null;
279
355
280
-
return (
281
-
<ResultContainer
282
-
class="border border-cyan-500/30 bg-black/50 hover:bg-cyan-500/5 transition-all block no-underline overflow-hidden"
283
-
{...containerProps}
284
-
>
285
-
<div class="border-b border-cyan-500/20 px-2 sm:px-3 py-1 bg-cyan-500/5 flex justify-between items-center">
286
-
<div class="text-cyan-400 text-[10px] sm:text-xs">
287
-
[{String(index + 1).padStart(3, "0")}]{" "}
288
-
<span class="hidden sm:inline">
289
-
{isRepo ? "REPO" : "PROFILE"}_ENTRY
290
-
</span>
291
-
<span class="sm:hidden">{isRepo ? "REPO" : "PROF"}</span>
356
+
const clickUrl = repoUrl || profileUrl;
357
+
358
+
const ResultContainer = clickUrl ? "a" : "div";
359
+
360
+
const containerProps = clickUrl
361
+
? {
362
+
href: clickUrl,
363
+
target: "_blank",
364
+
rel: "noopener noreferrer",
365
+
_: `on click
366
+
fetch /track-click {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text`,
367
+
}
368
+
: {};
369
+
370
+
return (
371
+
<ResultContainer
372
+
class="border border-cyan-500/30 bg-black/50 hover:bg-cyan-500/5 transition-all block no-underline overflow-hidden"
373
+
{...containerProps}
374
+
>
375
+
<div class="border-b border-cyan-500/20 px-2 sm:px-3 py-1 bg-cyan-500/5 flex justify-between items-center">
376
+
<div class="text-cyan-400 text-[10px] sm:text-xs">
377
+
[{String(index + 1).padStart(3, "0")}]{" "}
378
+
<span class="hidden sm:inline">
379
+
{isRepo ? "REPO" : "PROFILE"}_ENTRY
380
+
</span>
381
+
<span class="sm:hidden">
382
+
{isRepo ? "REPO" : "PROF"}
383
+
</span>
384
+
</div>
385
+
<div class="text-pink-400 text-[10px] sm:text-xs">
386
+
ID:{" "}
387
+
{isRepo
388
+
? record.uri.split("/").pop()?.slice(-8)
389
+
: record.did.slice(-8)}
390
+
</div>
292
391
</div>
293
-
<div class="text-pink-400 text-[10px] sm:text-xs">
294
-
ID: {isRepo ? record.uri.split('/').pop()?.slice(-8) : record.did.slice(-8)}
295
-
</div>
296
-
</div>
392
+
393
+
<div class="p-2 sm:p-3">
394
+
<div class="flex items-start gap-2 sm:gap-3">
395
+
{(() => {
396
+
// For profiles, use their own avatar
397
+
const avatarProfile = isRepo
398
+
? ownerProfiles.get(record.did)
399
+
: profile;
297
400
298
-
<div class="p-2 sm:p-3">
299
-
<div class="flex items-start gap-2 sm:gap-3">
300
-
{(() => {
301
-
// For profiles, use their own avatar
302
-
const avatarProfile = isRepo
303
-
? ownerProfiles.get(record.did)
304
-
: profile;
401
+
if (avatarProfile?.avatar) {
402
+
return (
403
+
<img
404
+
src={recordBlobToCdnUrl(
405
+
record as IndexedRecord,
406
+
avatarProfile.avatar,
407
+
"avatar"
408
+
)}
409
+
alt={
410
+
avatarProfile.displayName ||
411
+
actor?.handle ||
412
+
"Avatar"
413
+
}
414
+
class="w-7 h-7 sm:w-8 sm:h-8 rounded border border-cyan-500/50 flex-shrink-0"
415
+
/>
416
+
);
417
+
}
305
418
306
-
if (avatarProfile?.avatar) {
307
419
return (
308
-
<img
309
-
src={recordBlobToCdnUrl(
310
-
record as IndexedRecord,
311
-
avatarProfile.avatar,
312
-
"avatar"
313
-
)}
314
-
alt={
315
-
avatarProfile.displayName ||
316
-
actor?.handle ||
317
-
"Avatar"
318
-
}
319
-
class="w-7 h-7 sm:w-8 sm:h-8 rounded border border-cyan-500/50 flex-shrink-0"
320
-
/>
420
+
<div class="w-7 h-7 sm:w-8 sm:h-8 border border-cyan-500/50 bg-cyan-500/10 flex items-center justify-center flex-shrink-0">
421
+
<span class="text-cyan-400 text-[10px] sm:text-xs">
422
+
{(
423
+
profile?.displayName ||
424
+
repo?.name ||
425
+
actor?.handle ||
426
+
"?"
427
+
)
428
+
.charAt(0)
429
+
.toUpperCase()}
430
+
</span>
431
+
</div>
321
432
);
322
-
}
433
+
})()}
323
434
324
-
return (
325
-
<div class="w-7 h-7 sm:w-8 sm:h-8 border border-cyan-500/50 bg-cyan-500/10 flex items-center justify-center flex-shrink-0">
326
-
<span class="text-cyan-400 text-[10px] sm:text-xs">
327
-
{(
328
-
profile?.displayName ||
329
-
repo?.name ||
330
-
actor?.handle ||
331
-
"?"
332
-
)
333
-
.charAt(0)
334
-
.toUpperCase()}
335
-
</span>
435
+
<div class="flex-1 min-w-0">
436
+
<div class="flex flex-wrap items-center gap-2 mb-1">
437
+
<div class="text-green-400 font-mono font-semibold text-sm sm:text-base truncate flex-1">
438
+
{isRepo
439
+
? repo?.name || "UNNAMED_REPO"
440
+
: profile?.displayName ||
441
+
actor?.handle ||
442
+
"UNNAMED_PROFILE"}
443
+
</div>
444
+
{isRepo && starCounts.get(record.uri) && (
445
+
<div class="flex items-center gap-1 px-1.5 sm:px-2 py-0.5 sm:py-1 bg-yellow-500/20 border border-yellow-500/50 rounded text-[10px] sm:text-xs flex-shrink-0">
446
+
<span class="text-yellow-400">★</span>
447
+
<span class="text-yellow-300 font-mono">
448
+
{starCounts.get(record.uri)}
449
+
</span>
450
+
</div>
451
+
)}
336
452
</div>
337
-
);
338
-
})()}
339
453
340
-
<div class="flex-1 min-w-0">
341
-
<div class="flex flex-wrap items-center gap-2 mb-1">
342
-
<div class="text-green-400 font-mono font-semibold text-sm sm:text-base truncate flex-1">
343
-
{isRepo
344
-
? repo?.name || "UNNAMED_REPO"
345
-
: profile?.displayName ||
346
-
actor?.handle ||
347
-
"UNNAMED_PROFILE"}
348
-
</div>
349
-
{isRepo && starCounts.get(record.uri) && (
350
-
<div class="flex items-center gap-1 px-1.5 sm:px-2 py-0.5 sm:py-1 bg-yellow-500/20 border border-yellow-500/50 rounded text-[10px] sm:text-xs flex-shrink-0">
351
-
<span class="text-yellow-400">★</span>
352
-
<span class="text-yellow-300 font-mono">
353
-
{starCounts.get(record.uri)}
354
-
</span>
454
+
{(actor?.handle || profile?.displayName) && (
455
+
<div class="text-pink-400 text-xs sm:text-sm mb-1 sm:mb-2 font-mono truncate">
456
+
{isRepo
457
+
? `OWNER: ${
458
+
actor?.handle ||
459
+
record.did.slice(0, 16) + "..."
460
+
}`
461
+
: `HANDLE: @${actor?.handle || "unknown"}`}
355
462
</div>
356
463
)}
357
-
</div>
358
464
359
-
{(actor?.handle || profile?.displayName) && (
360
-
<div class="text-pink-400 text-xs sm:text-sm mb-1 sm:mb-2 font-mono truncate">
465
+
<div class="text-cyan-300/80 text-xs sm:text-sm font-mono leading-relaxed line-clamp-3">
361
466
{isRepo
362
-
? `OWNER: ${
363
-
actor?.handle ||
364
-
record.did.slice(0, 16) + "..."
365
-
}`
366
-
: `HANDLE: @${actor?.handle || "unknown"}`}
467
+
? repo?.description || "NO_DESCRIPTION_AVAILABLE"
468
+
: profile?.description || "NO_BIO_AVAILABLE"}
367
469
</div>
368
-
)}
369
-
370
-
<div class="text-cyan-300/80 text-xs sm:text-sm font-mono leading-relaxed line-clamp-3">
371
-
{isRepo
372
-
? repo?.description || "NO_DESCRIPTION_AVAILABLE"
373
-
: profile?.description || "NO_BIO_AVAILABLE"}
374
470
</div>
375
471
</div>
376
472
</div>
377
-
</div>
378
-
</ResultContainer>
379
-
);
380
-
})}
473
+
</ResultContainer>
474
+
);
475
+
})}
476
+
</div>
381
477
</div>
382
-
</div>
383
-
)}
384
-
</div>
478
+
)}
479
+
</div>
480
+
</>
385
481
);
386
482
};
387
483
···
389
485
const url = new URL(req.url);
390
486
391
487
if (url.pathname === "/" && req.method === "GET") {
392
-
const html = renderToString(<SearchPage />);
488
+
// Parse cookies to get recent URIs
489
+
const cookieHeader = req.headers.get("cookie") || "";
490
+
const cookies = Object.fromEntries(
491
+
cookieHeader.split("; ").map((c) => {
492
+
const [key, ...value] = c.split("=");
493
+
return [key, value.join("=")];
494
+
})
495
+
);
496
+
497
+
let recentUris: string[] = [];
498
+
if (cookies.recent_uris) {
499
+
try {
500
+
recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris));
501
+
} catch {
502
+
// Invalid cookie data, ignore
503
+
}
504
+
}
505
+
506
+
// Fetch the recent records and their actors
507
+
let recentRecords: IndexedRecord[] = [];
508
+
const actors = new Map<string, Actor>();
509
+
510
+
if (recentUris.length > 0) {
511
+
try {
512
+
const response = await client.getSliceRecords({
513
+
where: { uri: { in: recentUris } },
514
+
limit: 5,
515
+
});
516
+
recentRecords = response.records;
517
+
518
+
// Sort by the order in recentUris to maintain recent order
519
+
recentRecords.sort(
520
+
(a, b) => recentUris.indexOf(a.uri) - recentUris.indexOf(b.uri)
521
+
);
522
+
523
+
// Fetch actors for handles
524
+
const ownerDids = [
525
+
...new Set(recentRecords.map((record) => record.did)),
526
+
];
527
+
if (ownerDids.length > 0) {
528
+
const actorResults = await client.getActors({
529
+
where: { did: { in: ownerDids } },
530
+
limit: 5,
531
+
});
532
+
for (const actor of actorResults.actors) {
533
+
actors.set(actor.did, actor);
534
+
}
535
+
}
536
+
} catch (error) {
537
+
console.error("Error fetching recent records:", error);
538
+
}
539
+
}
540
+
541
+
const html = renderToString(
542
+
<SearchPage recentRecords={recentRecords} actors={actors} />
543
+
);
393
544
return new Response(html, {
394
545
headers: { "content-type": "text/html; charset=utf-8" },
395
546
});
547
+
}
548
+
549
+
if (url.pathname === "/remove-recent" && req.method === "POST") {
550
+
try {
551
+
const { uri } = await req.json();
552
+
553
+
// Get existing recent URIs
554
+
const cookieHeader = req.headers.get("cookie") || "";
555
+
const cookies = Object.fromEntries(
556
+
cookieHeader.split("; ").map((c) => {
557
+
const [key, ...value] = c.split("=");
558
+
return [key, value.join("=")];
559
+
})
560
+
);
561
+
562
+
let recentUris: string[] = [];
563
+
if (cookies.recent_uris) {
564
+
try {
565
+
recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris));
566
+
} catch {
567
+
// Invalid cookie data, ignore
568
+
}
569
+
}
570
+
571
+
// Remove the specified URI
572
+
recentUris = recentUris.filter((u) => u !== uri);
573
+
574
+
// Set cookie for 30 days
575
+
const expires = new Date();
576
+
expires.setDate(expires.getDate() + 30);
577
+
const cookieValue = encodeURIComponent(JSON.stringify(recentUris));
578
+
579
+
return new Response("", {
580
+
status: 200,
581
+
headers: {
582
+
"Set-Cookie": `recent_uris=${cookieValue}; Expires=${expires.toUTCString()}; Path=/; SameSite=Lax`,
583
+
},
584
+
});
585
+
} catch (error) {
586
+
console.error("Error removing recent:", error);
587
+
return new Response("", { status: 200 });
588
+
}
589
+
}
590
+
591
+
if (url.pathname === "/track-click" && req.method === "POST") {
592
+
try {
593
+
const { uri } = await req.json();
594
+
595
+
// Get existing recent URIs
596
+
const cookieHeader = req.headers.get("cookie") || "";
597
+
const cookies = Object.fromEntries(
598
+
cookieHeader.split("; ").map((c) => {
599
+
const [key, ...value] = c.split("=");
600
+
return [key, value.join("=")];
601
+
})
602
+
);
603
+
604
+
let recentUris: string[] = [];
605
+
if (cookies.recent_uris) {
606
+
try {
607
+
recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris));
608
+
} catch {
609
+
// Invalid cookie data, ignore
610
+
}
611
+
}
612
+
613
+
// Remove if exists (to move to front)
614
+
recentUris = recentUris.filter((u) => u !== uri);
615
+
// Add to front
616
+
recentUris.unshift(uri);
617
+
// Keep only last 5
618
+
recentUris = recentUris.slice(0, 5);
619
+
620
+
// Set cookie for 30 days
621
+
const expires = new Date();
622
+
expires.setDate(expires.getDate() + 30);
623
+
const cookieValue = encodeURIComponent(JSON.stringify(recentUris));
624
+
625
+
return new Response("", {
626
+
status: 200,
627
+
headers: {
628
+
"Set-Cookie": `recent_uris=${cookieValue}; Expires=${expires.toUTCString()}; Path=/; SameSite=Lax`,
629
+
},
630
+
});
631
+
} catch (error) {
632
+
console.error("Error tracking click:", error);
633
+
return new Response("", { status: 200 });
634
+
}
396
635
}
397
636
398
637
if (url.pathname === "/search" && req.method === "POST") {