view who was fronting when a record was made
1<script lang="ts">
2 import { expect } from "@/lib/result";
3 import { getFronter, getMemberPublicUri } from "@/lib/utils";
4 import { isResourceUri } from "@atcute/lexicons";
5 import type { ResourceUri } from "@atcute/lexicons/syntax";
6 import FronterList from "@/components/FronterList.svelte";
7
8 let recordAtUri = $state("");
9 let queryResult = $state<{
10 handle: string;
11 fronters: { name: string; uri?: string }[];
12 } | null>(null);
13 let queryError = $state("");
14 let isQuerying = $state(false);
15 let fronters = $state<string[]>([]);
16 let pkSystemId = $state<string>("");
17 let spToken = $state("");
18 let isFromCurrentTab = $state(false);
19
20 const makeOutput = (record: any) => {
21 const fronters = record.members.map((f: any) => ({
22 name: f.name,
23 uri: f.uri ? getMemberPublicUri(f.uri) : undefined,
24 }));
25 return {
26 handle: record.handle ?? `handle.invalid (${record.did})`,
27 fronters,
28 };
29 };
30
31 const queryRecord = async (recordUri: ResourceUri) => {
32 if (!recordAtUri.trim()) return;
33
34 isQuerying = true;
35 queryResult = null;
36
37 try {
38 if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI";
39 const result = expect(await getFronter(recordUri));
40 queryResult = makeOutput(result);
41 } catch (error) {
42 queryResult = null;
43 queryError = `ERROR: ${error}`;
44 } finally {
45 isQuerying = false;
46 }
47 };
48
49 const updateFronters = (newFronters: string[]) => {
50 fronters = newFronters;
51 storage.setItem("sync:fronters", newFronters);
52 };
53
54 const updatePkSystem = (event: any) => {
55 pkSystemId = (event.target as HTMLInputElement).value;
56 storage.setItem("sync:pk-system", pkSystemId);
57 };
58
59 const updateSpToken = (event: any) => {
60 spToken = (event.target as HTMLInputElement).value;
61 storage.setItem("sync:sp_token", spToken);
62 };
63
64 const handleKeyPress = (event: KeyboardEvent) => {
65 if (event.key === "Enter") {
66 queryRecord(recordAtUri as ResourceUri);
67 }
68 };
69
70 const clearResult = () => {
71 queryResult = null;
72 queryError = "";
73 recordAtUri = "";
74 isFromCurrentTab = false;
75 };
76
77 onMount(async () => {
78 const frontersArray = await storage.getItem<string[]>("sync:fronters");
79 if (frontersArray && Array.isArray(frontersArray)) {
80 fronters = frontersArray;
81 }
82
83 const pkSystem = await storage.getItem<string>("sync:pk-system");
84 if (pkSystem) {
85 pkSystemId = pkSystem;
86 }
87
88 const token = await storage.getItem<string>("sync:sp_token");
89 if (token) {
90 spToken = token;
91 }
92
93 const tabs = await browser.tabs.query({
94 active: true,
95 currentWindow: true,
96 });
97 const tabFronter = await storage.getItem<any>(
98 `local:tab-${tabs[0].id!}-fronter`,
99 );
100 if (tabFronter) {
101 queryResult = makeOutput(tabFronter);
102 recordAtUri = tabFronter.recordUri;
103 isFromCurrentTab = true;
104 }
105 });
106</script>
107
108<main>
109 <div class="container">
110 <div class="content">
111 <section class="query-panel">
112 <div class="panel-header">
113 <span class="panel-title">RECORD QUERY</span>
114 <div class="panel-accent"></div>
115 </div>
116
117 <div class="input-container">
118 <div class="input-wrapper">
119 <input
120 type="text"
121 placeholder="input_at_uri (at://repo/collection/rkey)"
122 bind:value={recordAtUri}
123 onkeypress={handleKeyPress}
124 class="record-input"
125 disabled={isQuerying}
126 />
127 <button
128 onclick={() =>
129 queryRecord(recordAtUri as ResourceUri)}
130 class="exec-button"
131 disabled={isQuerying || !recordAtUri.trim()}
132 >
133 <span class="button-text">EXEC</span>
134 <div class="button-accent"></div>
135 </button>
136 </div>
137 </div>
138
139 <div class="output-container">
140 <div class="output-header">
141 <div class="output-header-left">
142 <span>OUTPUT</span>
143 {#if isFromCurrentTab}
144 <div class="tab-indicator">
145 <span class="tab-indicator-text"
146 >FROM_CURRENT_TAB</span
147 >
148 <div class="tab-indicator-accent"></div>
149 </div>
150 {/if}
151 </div>
152 <div class="clear-button-container">
153 {#if (queryResult || queryError) && !isQuerying}
154 <button
155 class="clear-button"
156 onclick={clearResult}
157 >
158 <span>CLEAR</span>
159 </button>
160 {/if}
161 </div>
162 </div>
163 <div class="output-display" class:querying={isQuerying}>
164 <div class="output-content">
165 {#if isQuerying}
166 <div class="loading-indicator">
167 <span class="loading-text"
168 >PROCESSING REQUEST</span
169 >
170 <div class="loading-bar"></div>
171 </div>
172 {:else if queryError}
173 <div class="result-text error">
174 {queryError}
175 </div>
176 {:else if queryResult}
177 <div class="result-text">
178 <div>HANDLE: {queryResult.handle}</div>
179 <div>
180 FRONTER(S):
181 {#each queryResult.fronters as fronter, i}
182 {#if fronter.uri}
183 <a
184 href={fronter.uri}
185 class="fronter-link"
186 >{fronter.name}</a
187 >
188 {:else}
189 {fronter.name +
190 (i <
191 queryResult.fronters
192 .length -
193 1
194 ? ", "
195 : "")}
196 {/if}
197 {/each}
198 </div>
199 </div>
200 {:else}
201 <div class="placeholder-text">
202 AWAITING INPUT
203 </div>
204 {/if}
205 </div>
206 </div>
207 </div>
208 </section>
209
210 <section class="config-panel">
211 <div class="panel-header">
212 <span class="panel-title">CONFIGURATION</span>
213 <div class="panel-accent"></div>
214 </div>
215 <div class="config-card">
216 <div class="config-row">
217 <span class="config-label">SP TOKEN</span>
218 <input
219 type="password"
220 placeholder="enter_simply_plural_token"
221 oninput={updateSpToken}
222 bind:value={spToken}
223 class="config-input"
224 class:has-value={spToken}
225 />
226 </div>
227 <div class="config-note">
228 <span class="note-text">
229 when set, pulls fronters from Simply Plural (token
230 only requires read permissions)
231 </span>
232 </div>
233 </div>
234 <div class="config-card">
235 <div class="config-row">
236 <span class="config-label">PK SYSTEM</span>
237 <input
238 type="password"
239 placeholder="enter_pk_system_id"
240 oninput={updatePkSystem}
241 bind:value={pkSystemId}
242 class="config-input"
243 class:has-value={pkSystemId}
244 />
245 </div>
246 <div class="config-note">
247 <span class="note-text">
248 when set, pulls fronters from PluralKit (fronters
249 must be public)
250 </span>
251 </div>
252 </div>
253 <FronterList
254 bind:fronters
255 onUpdate={updateFronters}
256 label="FRONTERS"
257 placeholder="enter_fronter_names"
258 note="just names, overrides SP & PK fronters"
259 />
260 </section>
261 </div>
262
263 <footer class="footer">
264 <span class="title">AT_FRONTER</span>
265 <span class="footer-separator">•</span>
266 <span class="footer-source">SOURCE ON </span>
267 <a
268 href="https://tangled.sh/did:plc:dfl62fgb7wtjj3fcbb72naae/at-fronter"
269 class="footer-link">TANGLED</a
270 >
271 </footer>
272 </div>
273</main>
274
275<style>
276 main {
277 width: 480px;
278 height: 600px;
279 background: #000000;
280 color: #ffffff;
281 font-family:
282 "JetBrains Mono", "SF Mono", "Monaco", "Cascadia Code",
283 "Roboto Mono", monospace;
284 font-size: 13px;
285 position: relative;
286 overflow: hidden;
287 border: 1px solid #2a2a2a;
288 }
289
290 .container {
291 height: 100%;
292 display: flex;
293 flex-direction: column;
294 background: linear-gradient(180deg, #000000 0%, #0a0a0a 100%);
295 }
296
297 .title {
298 font-size: 10px;
299 font-weight: 700;
300 letter-spacing: 2px;
301 color: #999999;
302 line-height: 1;
303 vertical-align: baseline;
304 }
305
306 .content {
307 flex: 1;
308 display: flex;
309 flex-direction: column;
310 gap: 20px;
311 padding: 18px 16px;
312 overflow-y: auto;
313 }
314
315 .query-panel {
316 display: flex;
317 flex-direction: column;
318 gap: 16px;
319 }
320
321 .config-panel {
322 display: flex;
323 flex-direction: column;
324 gap: 12px;
325 }
326
327 .config-card {
328 background: #0d0d0d;
329 border: 1px solid #2a2a2a;
330 border-left: 3px solid #444444;
331 padding: 10px;
332 display: flex;
333 flex-direction: column;
334 gap: 6px;
335 transition: border-left-color 0.2s ease;
336 }
337
338 .config-card:hover {
339 border-left-color: #555555;
340 }
341
342 .config-note {
343 padding: 0;
344 background: transparent;
345 border: none;
346 margin: 0;
347 }
348
349 .note-text {
350 font-size: 11px;
351 color: #bbbbbb;
352 line-height: 1.3;
353 font-weight: 500;
354 letter-spacing: 0.5px;
355 }
356
357 .panel-header {
358 display: flex;
359 align-items: center;
360 gap: 12px;
361 margin-bottom: 8px;
362 }
363
364 .panel-title {
365 font-size: 12px;
366 font-weight: 700;
367 letter-spacing: 2px;
368 color: #e0e0e0;
369 }
370
371 .panel-accent {
372 flex: 1;
373 height: 1px;
374 background: linear-gradient(90deg, #555555, transparent);
375 }
376
377 .input-container {
378 margin-bottom: 8px;
379 }
380
381 .input-wrapper {
382 display: flex;
383 background: #181818;
384 border: 1px solid #333333;
385 transition: border-color 0.2s ease;
386 }
387
388 .input-wrapper:focus-within {
389 border-color: #666666;
390 }
391
392 .record-input {
393 flex: 1;
394 padding: 12px 14px;
395 background: transparent;
396 border: none;
397 outline: none;
398 color: #ffffff;
399 font-family: inherit;
400 font-size: 13px;
401 font-weight: 500;
402 }
403
404 .record-input::placeholder {
405 color: #777777;
406 font-size: 12px;
407 }
408
409 .record-input:disabled {
410 color: #666666;
411 }
412
413 .exec-button {
414 position: relative;
415 padding: 8px 10px;
416 background: #2a2a2a;
417 border: none;
418 border-left: 1px solid #444444;
419 color: #ffffff;
420 font-family: inherit;
421 font-size: 12px;
422 font-weight: 700;
423 letter-spacing: 1.5px;
424 cursor: pointer;
425 transition: all 0.15s ease;
426 overflow: hidden;
427 }
428
429 .exec-button:hover:not(:disabled) {
430 background: #3a3a3a;
431 }
432
433 .exec-button:active:not(:disabled) {
434 background: #444444;
435 }
436
437 .exec-button:disabled {
438 color: #555555;
439 cursor: not-allowed;
440 }
441
442 .button-text {
443 position: relative;
444 z-index: 1;
445 }
446
447 .button-accent {
448 position: absolute;
449 bottom: 0;
450 left: 0;
451 width: 100%;
452 height: 2px;
453 background: #00ff41;
454 transform: scaleX(0);
455 transition: transform 0.2s ease;
456 }
457
458 .exec-button:hover:not(:disabled) .button-accent {
459 transform: scaleX(1);
460 }
461
462 .output-container {
463 display: flex;
464 flex-direction: column;
465 gap: 8px;
466 }
467
468 .output-header {
469 display: flex;
470 align-items: center;
471 justify-content: space-between;
472 font-size: 11px;
473 color: #aaaaaa;
474 font-weight: 600;
475 letter-spacing: 1px;
476 height: 32px;
477 min-height: 32px;
478 }
479
480 .output-header-left {
481 display: flex;
482 align-items: center;
483 gap: 12px;
484 }
485
486 .tab-indicator {
487 display: flex;
488 align-items: center;
489 gap: 6px;
490 padding: 4px 8px;
491 background: #1a1a1a;
492 border: 1px solid #333333;
493 position: relative;
494 overflow: hidden;
495 }
496
497 .tab-indicator-text {
498 font-size: 9px;
499 color: #00ff41;
500 font-weight: 700;
501 letter-spacing: 1px;
502 position: relative;
503 z-index: 1;
504 }
505
506 .tab-indicator-accent {
507 position: absolute;
508 left: 0;
509 bottom: 0;
510 width: 100%;
511 height: 1px;
512 background: #00ff41;
513 animation: pulse 2s ease-in-out infinite;
514 }
515
516 .clear-button-container {
517 width: 60px;
518 display: flex;
519 justify-content: flex-end;
520 }
521
522 .clear-button {
523 background: none;
524 border: 1px solid #444444;
525 color: #aaaaaa;
526 font-family: inherit;
527 font-size: 10px;
528 font-weight: 700;
529 letter-spacing: 1px;
530 padding: 6px 10px;
531 cursor: pointer;
532 transition: all 0.15s ease;
533 }
534
535 .clear-button:hover {
536 border-color: #666666;
537 color: #ffffff;
538 background: #222222;
539 }
540
541 .output-display {
542 background: #111111;
543 border: 1px solid #333333;
544 border-left: 3px solid #555555;
545 min-height: 120px;
546 position: relative;
547 transition: border-left-color 0.2s ease;
548 }
549
550 .output-display.querying {
551 border-left-color: #00ff41;
552 }
553
554 .output-content {
555 padding: 14px;
556 height: 100%;
557 display: flex;
558 align-items: center;
559 }
560
561 .loading-indicator {
562 width: 100%;
563 display: flex;
564 flex-direction: column;
565 gap: 12px;
566 }
567
568 .loading-bar {
569 width: 100%;
570 height: 2px;
571 background: #333333;
572 overflow: hidden;
573 position: relative;
574 }
575
576 .loading-bar::after {
577 content: "";
578 position: absolute;
579 left: -100%;
580 width: 100%;
581 height: 100%;
582 background: linear-gradient(90deg, transparent, #00ff41, transparent);
583 animation: loading 1.5s ease-in-out infinite;
584 }
585
586 .loading-text {
587 font-size: 12px;
588 color: #00ff41;
589 letter-spacing: 1.5px;
590 font-weight: 700;
591 }
592
593 .result-text {
594 color: #ffffff;
595 font-size: 14px;
596 font-weight: 600;
597 word-break: break-all;
598 line-height: 1.5;
599 }
600
601 .result-text.error {
602 color: #ff4444;
603 }
604
605 .fronter-link {
606 color: #00ff41;
607 text-decoration: none;
608 font-weight: 700;
609 transition: all 0.2s ease;
610 position: relative;
611 border-bottom: 1px solid transparent;
612 }
613
614 .fronter-link:hover {
615 color: #33ff66;
616 border-bottom-color: #00ff41;
617 }
618
619 .fronter-link:active {
620 color: #ffffff;
621 }
622
623 .placeholder-text {
624 color: #888888;
625 font-size: 12px;
626 letter-spacing: 1px;
627 font-style: italic;
628 font-weight: 500;
629 }
630
631 .config-row {
632 display: flex;
633 align-items: center;
634 gap: 12px;
635 margin-bottom: 0;
636 }
637
638 .config-label {
639 font-size: 12px;
640 color: #cccccc;
641 letter-spacing: 1px;
642 font-weight: 700;
643 white-space: nowrap;
644 min-width: 90px;
645 }
646
647 .config-input {
648 flex: 1;
649 padding: 10px 12px;
650 background: #181818;
651 border: 1px solid #333333;
652 color: #ffffff;
653 font-family: inherit;
654 font-size: 12px;
655 font-weight: 500;
656 transition: all 0.2s ease;
657 position: relative;
658 }
659
660 .config-input:focus {
661 outline: none;
662 border-color: #666666;
663 }
664
665 .config-input.has-value {
666 border-bottom-color: #00ff41;
667 }
668
669 .config-input::placeholder {
670 color: #777777;
671 font-size: 12px;
672 }
673
674 .footer {
675 display: flex;
676 align-items: baseline;
677 justify-content: center;
678 gap: 8px;
679 padding: 12px 16px;
680 background: #000000;
681 border-top: 1px solid #222222;
682 font-size: 9px;
683 color: #666666;
684 font-weight: 500;
685 letter-spacing: 0.5px;
686 line-height: 1;
687 position: relative;
688 }
689
690 .footer::before {
691 content: "";
692 position: absolute;
693 top: 0;
694 left: 0;
695 width: 100%;
696 height: 1px;
697 background: linear-gradient(90deg, transparent, #333333, transparent);
698 }
699
700 .footer-separator {
701 color: #444444;
702 font-weight: 400;
703 line-height: 1;
704 vertical-align: baseline;
705 }
706
707 .footer-source {
708 color: #777777;
709 line-height: 1;
710 vertical-align: baseline;
711 }
712
713 .footer-link {
714 color: #999999;
715 text-decoration: none;
716 font-weight: 700;
717 transition: color 0.2s ease;
718 line-height: 1;
719 vertical-align: baseline;
720 }
721
722 .footer-link:hover {
723 color: #cccccc;
724 }
725
726 /* Animations */
727 @keyframes pulse {
728 0%,
729 100% {
730 opacity: 1;
731 }
732 50% {
733 opacity: 0.3;
734 }
735 }
736
737 @keyframes loading {
738 0% {
739 left: -100%;
740 }
741 100% {
742 left: 100%;
743 }
744 }
745</style>