tracks lexicons and how many times they appeared on the jetstream

feat(client): implement sorting and setting refresh rate

ptr.pet e8dfb4a1 b4c008ad

verified
+226 -41
+25
client/src/lib/components/BskyToggle.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + dontShowBsky: boolean; 4 + onBskyToggle: () => void; 5 + } 6 + 7 + let { dontShowBsky, onBskyToggle }: Props = $props(); 8 + </script> 9 + 10 + <!-- svelte-ignore a11y_click_events_have_key_events --> 11 + <!-- svelte-ignore a11y_no_static_element_interactions --> 12 + <button 13 + onclick={onBskyToggle} 14 + class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300" 15 + > 16 + <input checked={dontShowBsky} type="checkbox" /> 17 + <span class="ml-0.5"> hide app.bsky.* </span> 18 + </button> 19 + 20 + <style lang="postcss"> 21 + @reference "../../app.css"; 22 + .wsbadge { 23 + @apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border; 24 + } 25 + </style>
+13 -28
client/src/lib/components/FilterControls.svelte
··· 1 1 <script lang="ts"> 2 2 interface Props { 3 3 filterRegex: string; 4 - dontShowBsky: boolean; 5 4 onFilterChange: (value: string) => void; 6 - onBskyToggle: () => void; 7 5 } 8 6 9 - let { filterRegex, dontShowBsky, onFilterChange, onBskyToggle }: Props = 10 - $props(); 7 + let { filterRegex, onFilterChange }: Props = $props(); 11 8 </script> 12 9 13 - <div class="flex flex-wrap items-center gap-3 mb-6"> 14 - <div 15 - class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300" 16 - > 17 - <label for="filter-regex" class="text-blue-800 mr-1"> filter: </label> 18 - <input 19 - id="filter-regex" 20 - value={filterRegex} 21 - oninput={(e) => 22 - onFilterChange((e.target as HTMLInputElement).value)} 23 - type="text" 24 - placeholder="regex..." 25 - class="bg-blue-50 text-blue-900 placeholder-blue-400 border border-blue-200 rounded-full px-1 outline-none focus:bg-white focus:border-blue-400 min-w-0 w-24" 26 - /> 27 - </div> 28 - <!-- svelte-ignore a11y_click_events_have_key_events --> 29 - <!-- svelte-ignore a11y_no_static_element_interactions --> 30 - <button 31 - onclick={onBskyToggle} 32 - class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300" 33 - > 34 - <input checked={dontShowBsky} type="checkbox" /> 35 - <span class="ml-0.5"> hide app.bsky.* </span> 36 - </button> 10 + <div 11 + class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300" 12 + > 13 + <label for="filter-regex" class="text-blue-800 mr-1"> filter: </label> 14 + <input 15 + id="filter-regex" 16 + value={filterRegex} 17 + oninput={(e) => onFilterChange((e.target as HTMLInputElement).value)} 18 + type="text" 19 + placeholder="regex..." 20 + class="bg-blue-50 text-blue-900 placeholder-blue-400 border border-blue-200 rounded-full px-1 outline-none focus:bg-white focus:border-blue-400 min-w-0 w-24" 21 + /> 37 22 </div> 38 23 39 24 <style lang="postcss">
+37
client/src/lib/components/RefreshControl.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + refreshRate: string; 4 + onRefreshChange: (value: string) => void; 5 + } 6 + 7 + let { refreshRate, onRefreshChange }: Props = $props(); 8 + </script> 9 + 10 + <div 11 + class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-green-100 hover:bg-green-200 border-green-300" 12 + > 13 + <label for="refresh-rate" class="text-green-800 mr-1">refresh:</label> 14 + <input 15 + id="refresh-rate" 16 + value={refreshRate} 17 + oninput={(e) => { 18 + const el = e.target as HTMLInputElement; 19 + if (!el.validity.valid) el.value = el.value.replace(/\D+/g, ""); 20 + onRefreshChange(el.value); 21 + }} 22 + type="text" 23 + inputmode="numeric" 24 + pattern="[0-9]*" 25 + min="0" 26 + placeholder="real-time" 27 + class="bg-green-50 text-green-900 placeholder-green-400 border border-green-200 rounded-full px-1 outline-none focus:bg-white focus:border-green-400 min-w-0 w-20" 28 + /> 29 + <span class="text-green-700">s</span> 30 + </div> 31 + 32 + <style lang="postcss"> 33 + @reference "../../app.css"; 34 + .wsbadge { 35 + @apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border; 36 + } 37 + </style>
+41
client/src/lib/components/SortControls.svelte
··· 1 + <script lang="ts"> 2 + import type { SortOption } from "$lib/types"; 3 + 4 + interface Props { 5 + sortBy: SortOption; 6 + onSortChange: (value: SortOption) => void; 7 + } 8 + 9 + let { sortBy, onSortChange }: Props = $props(); 10 + 11 + const sortOptions = [ 12 + { value: "total" as const, label: "total count" }, 13 + { value: "created" as const, label: "created count" }, 14 + { value: "deleted" as const, label: "deleted count" }, 15 + { value: "date" as const, label: "newest first" }, 16 + ]; 17 + </script> 18 + 19 + <div 20 + class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-purple-100 hover:bg-purple-200 border-purple-300" 21 + > 22 + <label for="sort-by" class="text-purple-800 mr-1"> sort by: </label> 23 + <select 24 + id="sort-by" 25 + value={sortBy} 26 + onchange={(e) => 27 + onSortChange((e.target as HTMLSelectElement).value as SortOption)} 28 + class="bg-purple-50 text-purple-900 border border-purple-200 rounded-full px-1 outline-none focus:bg-white focus:border-purple-400 min-w-0" 29 + > 30 + {#each sortOptions as option} 31 + <option value={option.value}>{option.label}</option> 32 + {/each} 33 + </select> 34 + </div> 35 + 36 + <style lang="postcss"> 37 + @reference "../../app.css"; 38 + .wsbadge { 39 + @apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border; 40 + } 41 + </style>
+2
client/src/lib/types.ts
··· 13 13 count: number; 14 14 deleted_count: number; 15 15 }; 16 + 17 + export type SortOption = "total" | "created" | "deleted" | "date";
+108 -13
client/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from "$app/environment"; 3 - import type { EventRecord, NsidCount } from "$lib/types"; 3 + import type { EventRecord, NsidCount, SortOption } from "$lib/types"; 4 4 import { onMount, onDestroy } from "svelte"; 5 5 import { writable } from "svelte/store"; 6 6 import { PUBLIC_API_URL } from "$env/static/public"; ··· 10 10 import StatusBadge from "$lib/components/StatusBadge.svelte"; 11 11 import EventCard from "$lib/components/EventCard.svelte"; 12 12 import FilterControls from "$lib/components/FilterControls.svelte"; 13 + import SortControls from "$lib/components/SortControls.svelte"; 14 + import BskyToggle from "$lib/components/BskyToggle.svelte"; 15 + import RefreshControl from "$lib/components/RefreshControl.svelte"; 13 16 14 17 const events = writable(new Map<string, EventRecord>()); 18 + const pendingUpdates = new Map<string, EventRecord>(); 15 19 let eventsList: NsidCount[] = $state([]); 20 + let updateTimer: NodeJS.Timeout | null = null; 16 21 events.subscribe((value) => { 17 22 eventsList = value 18 23 .entries() ··· 21 26 ...event, 22 27 })) 23 28 .toArray(); 24 - eventsList.sort((a, b) => b.count - a.count); 25 29 }); 26 30 let per_second = $state(0); 27 31 ··· 47 51 let error: string | null = $state(null); 48 52 let filterRegex = $state(""); 49 53 let dontShowBsky = $state(false); 54 + let sortBy: SortOption = $state("total"); 55 + let refreshRate = $state(""); 56 + let previousRefreshRate = ""; 50 57 51 58 let websocket: WebSocket | null = null; 52 59 let isStreamOpen = $state(false); ··· 71 78 if (jsonData.per_second > 0) { 72 79 per_second = jsonData.per_second; 73 80 } 74 - events.update((map) => { 81 + 82 + // Store updates in pending map if refresh rate is set 83 + if (refreshRate) { 75 84 for (const [nsid, event] of Object.entries(jsonData.events)) { 76 - map.set(nsid, event as EventRecord); 85 + pendingUpdates.set(nsid, event as EventRecord); 77 86 } 78 - return map; 79 - }); 87 + } else { 88 + // Apply updates immediately if no refresh rate 89 + events.update((map) => { 90 + for (const [nsid, event] of Object.entries( 91 + jsonData.events, 92 + )) { 93 + map.set(nsid, event as EventRecord); 94 + } 95 + return map; 96 + }); 97 + } 80 98 }; 81 99 websocket.onerror = (error) => { 82 100 console.error("ws error:", error); ··· 109 127 } 110 128 }; 111 129 130 + // Set refresh rate when sort mode changes 131 + $effect(() => { 132 + if (sortBy === "date" && !refreshRate) { 133 + // Only set to 2 if currently empty (real-time) 134 + previousRefreshRate = ""; 135 + refreshRate = "2"; 136 + } else if (refreshRate === "2" && sortBy !== "date") { 137 + // Only restore to empty if we auto-set it and switching away from date 138 + refreshRate = previousRefreshRate; 139 + previousRefreshRate = ""; 140 + } 141 + }); 142 + 143 + // Update the refresh timer when refresh rate changes 144 + $effect(() => { 145 + if (updateTimer) { 146 + clearInterval(updateTimer); 147 + updateTimer = null; 148 + } 149 + 150 + if (refreshRate) { 151 + const rate = parseInt(refreshRate, 10) * 1000; // Convert to milliseconds 152 + if (!isNaN(rate) && rate > 0) { 153 + updateTimer = setInterval(() => { 154 + if (pendingUpdates.size > 0) { 155 + events.update((map) => { 156 + for (const [nsid, event] of pendingUpdates) { 157 + map.set(nsid, event); 158 + } 159 + pendingUpdates.clear(); 160 + return map; 161 + }); 162 + } 163 + }, rate); 164 + } 165 + } 166 + }); 167 + 112 168 onMount(() => { 113 169 loadData(); 114 170 connectToStream(); 115 171 }); 116 172 117 173 onDestroy(() => { 174 + // Clear refresh timer 175 + if (updateTimer) { 176 + clearInterval(updateTimer); 177 + updateTimer = null; 178 + } 118 179 // Close WebSocket connection 119 180 if (websocket) { 120 181 websocket.close(); 121 182 } 122 183 }); 184 + 185 + const sortEvents = (events: NsidCount[], sortBy: SortOption) => { 186 + const sorted = [...events]; 187 + switch (sortBy) { 188 + case "total": 189 + sorted.sort( 190 + (a, b) => 191 + b.count + b.deleted_count - (a.count + a.deleted_count), 192 + ); 193 + break; 194 + case "created": 195 + sorted.sort((a, b) => b.count - a.count); 196 + break; 197 + case "deleted": 198 + sorted.sort((a, b) => b.deleted_count - a.deleted_count); 199 + break; 200 + case "date": 201 + sorted.sort((a, b) => b.last_seen - a.last_seen); 202 + break; 203 + } 204 + return sorted; 205 + }; 123 206 124 207 const filterEvents = (events: NsidCount[]) => { 125 208 let filtered = events; ··· 199 282 <h2 class="text-2xl font-bold text-gray-900">seen lexicons</h2> 200 283 <StatusBadge status={websocketStatus} /> 201 284 </div> 202 - <FilterControls 203 - {filterRegex} 204 - {dontShowBsky} 205 - onFilterChange={(value) => (filterRegex = value)} 206 - onBskyToggle={() => (dontShowBsky = !dontShowBsky)} 207 - /> 285 + <div class="flex flex-wrap items-center gap-1.5 mb-6"> 286 + <FilterControls 287 + {filterRegex} 288 + onFilterChange={(value) => (filterRegex = value)} 289 + /> 290 + <SortControls 291 + {sortBy} 292 + onSortChange={(value: SortOption) => (sortBy = value)} 293 + /> 294 + <BskyToggle 295 + {dontShowBsky} 296 + onBskyToggle={() => (dontShowBsky = !dontShowBsky)} 297 + /> 298 + <RefreshControl 299 + {refreshRate} 300 + onRefreshChange={(value) => (refreshRate = value)} 301 + /> 302 + </div> 208 303 <div 209 304 class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4" 210 305 > 211 - {#each filterEvents(eventsList) as event, index (event.nsid)} 306 + {#each sortEvents(filterEvents(eventsList), sortBy) as event, index (event.nsid)} 212 307 <EventCard {event} {index} /> 213 308 {/each} 214 309 </div>