+9
-9
client/src/app.html
+9
-9
client/src/app.html
···
1
1
<!doctype html>
2
2
<html lang="en">
3
-
<head>
4
-
<meta charset="utf-8" />
5
-
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
6
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
-
%sveltekit.head%
8
-
</head>
9
-
<body data-sveltekit-preload-data="hover">
10
-
<div style="display: contents">%sveltekit.body%</div>
11
-
</body>
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
+
%sveltekit.head%
8
+
</head>
9
+
<body class="bg-white dark:bg-gray-900" data-sveltekit-preload-data="hover">
10
+
<div style="display: contents">%sveltekit.body%</div>
11
+
</body>
12
12
</html>
+2
-2
client/src/lib/components/BskyToggle.svelte
+2
-2
client/src/lib/components/BskyToggle.svelte
···
11
11
<!-- svelte-ignore a11y_no_static_element_interactions -->
12
12
<button
13
13
onclick={onBskyToggle}
14
-
class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300"
14
+
class="wsbadge !mt-0 !font-normal bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 border-blue-300 dark:border-blue-700"
15
15
>
16
16
<input checked={dontShowBsky} type="checkbox" />
17
-
<span class="ml-0.5"> hide app.bsky.* </span>
17
+
<span class="ml-0.5 text-black dark:text-gray-200"> hide app.bsky.* </span>
18
18
</button>
+8
-5
client/src/lib/components/EventCard.svelte
+8
-5
client/src/lib/components/EventCard.svelte
···
104
104
</script>
105
105
106
106
<div
107
-
class="group flex flex-col gap-2 p-1.5 md:p-3 min-h-64 bg-white border border-gray-200 rounded-lg hover:shadow-lg md:hover:-translate-y-1 transition-all duration-200 transform"
107
+
class="group flex flex-col gap-2 p-1.5 md:p-3 min-h-64 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-gray-950 rounded-lg hover:shadow-lg md:hover:-translate-y-1 transition-all duration-200 transform"
108
108
class:has-activity={isAnimating}
109
109
style="--border-thickness: {borderThickness}px"
110
110
>
111
111
<div class="flex items-start gap-2">
112
112
<div
113
-
class="text-sm font-bold text-blue-600 bg-blue-100 px-3 py-1 rounded-full"
113
+
class="text-sm font-bold text-blue-600 bg-blue-100 dark:bg-indigo-950 px-3 py-1 rounded-full"
114
114
>
115
115
#{index + 1}
116
116
</div>
117
117
<div
118
118
title={event.nsid}
119
-
class="font-mono text-sm text-gray-700 mt-0.5 leading-relaxed rounded-full text-nowrap text-ellipsis overflow-hidden group-hover:overflow-visible group-hover:bg-gray-50 border-gray-100 group-hover:border transition-all px-1"
119
+
class="font-mono text-sm text-gray-700 dark:text-gray-300 mt-0.5 leading-relaxed rounded-full text-nowrap text-ellipsis overflow-hidden group-hover:overflow-visible group-hover:bg-gray-50 dark:group-hover:bg-gray-700 border-gray-100 dark:border-gray-900 group-hover:border transition-all px-1"
120
120
>
121
121
{event.nsid}
122
122
</div>
···
136
136
</div>
137
137
</div>
138
138
139
-
<style>
139
+
<style lang="postcss">
140
140
.has-activity {
141
141
position: relative;
142
142
transition: all 0.2s ease-out;
143
143
}
144
144
145
145
.has-activity::before {
146
+
@reference "../../app.css";
147
+
@apply border-blue-500 dark:border-blue-800;
146
148
content: "";
147
149
position: absolute;
148
150
top: calc(-1 * var(--border-thickness));
149
151
left: calc(-1 * var(--border-thickness));
150
152
right: calc(-1 * var(--border-thickness));
151
153
bottom: calc(-1 * var(--border-thickness));
152
-
border: var(--border-thickness) solid rgba(59, 130, 246, 0.8);
154
+
border-width: var(--border-thickness);
155
+
border-style: solid;
153
156
border-radius: calc(0.5rem + var(--border-thickness));
154
157
pointer-events: none;
155
158
transition: all 0.3s ease-out;
+5
-3
client/src/lib/components/FilterControls.svelte
+5
-3
client/src/lib/components/FilterControls.svelte
···
8
8
</script>
9
9
10
10
<div
11
-
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300"
11
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 border-blue-300 dark:border-blue-700"
12
12
>
13
-
<label for="filter-regex" class="text-blue-800 mr-1"> filter: </label>
13
+
<label for="filter-regex" class="text-blue-800 dark:text-gray-200 mr-1">
14
+
filter:
15
+
</label>
14
16
<input
15
17
id="filter-regex"
16
18
value={filterRegex}
17
19
oninput={(e) => onFilterChange((e.target as HTMLInputElement).value)}
18
20
type="text"
19
21
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"
22
+
class="bg-blue-50 dark:bg-blue-950 text-blue-900 dark:text-gray-400 placeholder-blue-400 dark:placeholder-blue-700 border border-blue-200 dark:border-blue-700 rounded-full px-1 outline-none focus:border-blue-400 min-w-0 w-24"
21
23
/>
22
24
</div>
+6
-4
client/src/lib/components/RefreshControl.svelte
+6
-4
client/src/lib/components/RefreshControl.svelte
···
8
8
</script>
9
9
10
10
<div
11
-
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-green-100 hover:bg-green-200 border-green-300"
11
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-lime-100 dark:bg-lime-900 dark:hover:bg-lime-800 hover:bg-lime-200 border-lime-300 dark:border-lime-700"
12
12
>
13
-
<label for="refresh-rate" class="text-green-800 mr-1">refresh:</label>
13
+
<label for="refresh-rate" class="text-lime-800 dark:text-lime-200 mr-1"
14
+
>refresh:</label
15
+
>
14
16
<input
15
17
id="refresh-rate"
16
18
value={refreshRate}
···
24
26
pattern="[0-9]*"
25
27
min="0"
26
28
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"
29
+
class="bg-green-50 dark:bg-green-900 text-lime-900 dark:text-lime-200 placeholder-lime-600 dark:placeholder-lime-400 border border-lime-200 dark:border-lime-700 rounded-full px-1 outline-none focus:border-lime-400 min-w-0 w-20"
28
30
/>
29
-
<span class="text-green-700">s</span>
31
+
<span class="text-lime-800 dark:text-lime-200">s</span>
30
32
</div>
+31
client/src/lib/components/ShowControls.svelte
+31
client/src/lib/components/ShowControls.svelte
···
1
+
<script lang="ts">
2
+
import type { ShowOption } from "$lib/types";
3
+
4
+
interface Props {
5
+
show: ShowOption;
6
+
onShowChange: (value: ShowOption) => void;
7
+
}
8
+
9
+
let { show, onShowChange }: Props = $props();
10
+
11
+
const showOptions: ShowOption[] = ["server init", "stream start"];
12
+
</script>
13
+
14
+
<div
15
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-pink-100 dark:bg-pink-800 hover:bg-pink-200 dark:hover:bg-pink-700 border-pink-300 dark:border-pink-700"
16
+
>
17
+
<label for="show" class="text-pink-800 dark:text-pink-100 mr-1">
18
+
show since:
19
+
</label>
20
+
<select
21
+
id="show"
22
+
value={show}
23
+
onchange={(e) =>
24
+
onShowChange((e.target as HTMLSelectElement).value as ShowOption)}
25
+
class="bg-pink-50 dark:bg-pink-900 text-pink-900 dark:text-pink-100 border border-pink-200 dark:border-pink-700 rounded-full px-1 outline-none focus:border-pink-400 min-w-0"
26
+
>
27
+
{#each showOptions as option}
28
+
<option value={option}>{option}</option>
29
+
{/each}
30
+
</select>
31
+
</div>
+5
-3
client/src/lib/components/SortControls.svelte
+5
-3
client/src/lib/components/SortControls.svelte
···
17
17
</script>
18
18
19
19
<div
20
-
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-purple-100 hover:bg-purple-200 border-purple-300"
20
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-purple-100 dark:bg-purple-800 hover:bg-purple-200 dark:hover:bg-purple-700 border-purple-300 dark:border-purple-700"
21
21
>
22
-
<label for="sort-by" class="text-purple-800 mr-1"> sort by: </label>
22
+
<label for="sort-by" class="text-purple-800 dark:text-purple-300 mr-1">
23
+
sort by:
24
+
</label>
23
25
<select
24
26
id="sort-by"
25
27
value={sortBy}
26
28
onchange={(e) =>
27
29
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"
30
+
class="bg-purple-50 dark:bg-purple-900 text-purple-900 dark:text-purple-300 border border-purple-200 dark:border-purple-700 rounded-full px-1 outline-none focus:border-purple-400 min-w-0"
29
31
>
30
32
{#each sortOptions as option}
31
33
<option value={option.value}>{option.label}</option>
+16
-16
client/src/lib/components/StatsCard.svelte
+16
-16
client/src/lib/components/StatsCard.svelte
···
3
3
4
4
const colorClasses = {
5
5
green: {
6
-
bg: "from-green-50 to-green-100",
7
-
border: "border-green-200",
8
-
titleText: "text-green-700",
9
-
valueText: "text-green-900",
6
+
bg: "from-green-50 to-green-100 dark:from-green-900 dark:to-green-800",
7
+
border: "border-green-200 dark:border-green-800",
8
+
titleText: "text-green-700 dark:text-green-400",
9
+
valueText: "text-green-900 dark:text-green-200",
10
10
},
11
11
red: {
12
-
bg: "from-red-50 to-red-100",
13
-
border: "border-red-200",
14
-
titleText: "text-red-700",
15
-
valueText: "text-red-900",
12
+
bg: "from-red-50 to-red-100 dark:from-red-900 dark:to-red-800",
13
+
border: "border-red-200 dark:border-red-800",
14
+
titleText: "text-red-700 dark:text-red-400",
15
+
valueText: "text-red-900 dark:text-red-200",
16
16
},
17
17
turqoise: {
18
-
bg: "from-teal-50 to-teal-100",
19
-
border: "border-teal-200",
20
-
titleText: "text-teal-700",
21
-
valueText: "text-teal-900",
18
+
bg: "from-teal-50 to-teal-100 dark:from-teal-900 dark:to-teal-800",
19
+
border: "border-teal-200 dark:border-teal-800",
20
+
titleText: "text-teal-700 dark:text-teal-400",
21
+
valueText: "text-teal-900 dark:text-teal-200",
22
22
},
23
23
orange: {
24
-
bg: "from-orange-50 to-orange-100",
25
-
border: "border-orange-200",
26
-
titleText: "text-orange-700",
27
-
valueText: "text-orange-900",
24
+
bg: "from-orange-50 to-orange-100 dark:from-orange-900 dark:to-orange-800",
25
+
border: "border-orange-200 dark:border-orange-800",
26
+
titleText: "text-orange-700 dark:text-orange-400",
27
+
valueText: "text-orange-900 dark:text-orange-200",
28
28
},
29
29
};
30
30
+9
-5
client/src/lib/components/StatusBadge.svelte
+9
-5
client/src/lib/components/StatusBadge.svelte
···
8
8
const statusConfig = {
9
9
connected: {
10
10
text: "stream live",
11
-
classes: "bg-green-100 text-green-800 border-green-200",
11
+
classes:
12
+
"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800",
12
13
},
13
14
connecting: {
14
15
text: "stream connecting",
15
-
classes: "bg-yellow-100 text-yellow-800 border-yellow-200",
16
+
classes:
17
+
"bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800",
16
18
},
17
19
error: {
18
20
text: "stream errored",
19
-
classes: "bg-red-100 text-red-800 border-red-200",
21
+
classes:
22
+
"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800",
20
23
},
21
24
disconnected: {
22
25
text: "stream offline",
23
-
classes: "bg-gray-100 text-gray-800 border-gray-200",
26
+
classes:
27
+
"bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-gray-200 dark:border-gray-800",
24
28
},
25
29
};
26
30
···
31
35
<!-- connecting spinner -->
32
36
{#if status === "connecting"}
33
37
<div
34
-
class="animate-spin rounded-full h-4 w-4 border-b-2 border-yellow-800"
38
+
class="animate-spin rounded-full h-4 w-4 border-b-2 border-yellow-800 dark:border-yellow-200"
35
39
></div>
36
40
{/if}
37
41
<!-- status text -->
+1
client/src/lib/types.ts
+1
client/src/lib/types.ts
+79
-26
client/src/routes/+page.svelte
+79
-26
client/src/routes/+page.svelte
···
4
4
EventRecord,
5
5
Events,
6
6
NsidCount,
7
+
ShowOption,
7
8
Since,
8
9
SortOption,
9
10
} from "$lib/types";
10
11
import { onMount, onDestroy } from "svelte";
11
-
import { writable } from "svelte/store";
12
+
import { get, writable } from "svelte/store";
12
13
import { PUBLIC_API_URL } from "$env/static/public";
13
14
import { fetchEvents, fetchTrackingSince } from "$lib/api";
14
15
import { createRegexFilter } from "$lib/filter";
···
20
21
import BskyToggle from "$lib/components/BskyToggle.svelte";
21
22
import RefreshControl from "$lib/components/RefreshControl.svelte";
22
23
import { formatTimestamp } from "$lib/format";
24
+
import ShowControls from "$lib/components/ShowControls.svelte";
23
25
24
26
type Props = {
25
27
data: { events: Events; trackingSince: Since };
···
29
31
30
32
const events = writable(
31
33
new Map<string, EventRecord>(Object.entries(data.events.events)),
34
+
);
35
+
const eventsStart = new Map<string, EventRecord>(
36
+
Object.entries(data.events.events),
32
37
);
33
38
const pendingUpdates = new Map<string, EventRecord>();
34
-
let eventsList: NsidCount[] = $state([]);
39
+
35
40
let updateTimer: NodeJS.Timeout | null = null;
36
-
events.subscribe((value) => {
37
-
eventsList = value
38
-
.entries()
39
-
.map(([nsid, event]) => ({
40
-
nsid,
41
-
...event,
42
-
}))
43
-
.toArray();
44
-
});
45
41
let per_second = $state(data.events.per_second);
46
42
let tracking_since = $state(data.trackingSince.since);
47
43
44
+
const diffEvents = (
45
+
oldEvents: Map<string, EventRecord>,
46
+
newEvents: Map<string, EventRecord>,
47
+
): NsidCount[] => {
48
+
const nsidCounts: NsidCount[] = [];
49
+
for (const [nsid, event] of newEvents.entries()) {
50
+
const oldEvent = oldEvents.get(nsid);
51
+
if (oldEvent) {
52
+
const counts = {
53
+
nsid,
54
+
count: event.count - oldEvent.count,
55
+
deleted_count: event.deleted_count - oldEvent.deleted_count,
56
+
last_seen: event.last_seen,
57
+
};
58
+
if (counts.count > 0 || counts.deleted_count > 0)
59
+
nsidCounts.push(counts);
60
+
} else {
61
+
nsidCounts.push({
62
+
nsid,
63
+
...event,
64
+
});
65
+
}
66
+
}
67
+
return nsidCounts;
68
+
};
48
69
const applyEvents = (newEvents: Record<string, EventRecord>) => {
49
70
events.update((map) => {
50
71
for (const [nsid, event] of Object.entries(newEvents)) {
···
54
75
});
55
76
};
56
77
78
+
let error: string | null = $state(null);
79
+
let filterRegex = $state("");
80
+
let dontShowBsky = $state(false);
81
+
let sortBy: SortOption = $state("total");
82
+
let refreshRate = $state("");
83
+
let changedByUser = $state(false);
84
+
let show: ShowOption = $state("server init");
85
+
let eventsList: NsidCount[] = $state([]);
86
+
let updateEventsList = $derived((value: Map<string, EventRecord>) => {
87
+
switch (show) {
88
+
case "server init":
89
+
eventsList = value
90
+
.entries()
91
+
.map(([nsid, event]) => ({
92
+
nsid,
93
+
...event,
94
+
}))
95
+
.toArray();
96
+
break;
97
+
case "stream start":
98
+
eventsList = diffEvents(eventsStart, value);
99
+
break;
100
+
}
101
+
});
102
+
events.subscribe((value) => updateEventsList(value));
57
103
let all: EventRecord = $derived(
58
104
eventsList.reduce(
59
105
(acc, event) => {
···
73
119
},
74
120
),
75
121
);
76
-
let error: string | null = $state(null);
77
-
let filterRegex = $state("");
78
-
let dontShowBsky = $state(false);
79
-
let sortBy: SortOption = $state("total");
80
-
let refreshRate = $state("");
81
-
let changedByUser = $state(false);
82
122
83
123
let websocket: WebSocket | null = null;
84
124
let isStreamOpen = $state(false);
···
227
267
/>
228
268
</svelte:head>
229
269
230
-
<header class="border-gray-300 border-b mb-4 pb-2">
270
+
<header
271
+
class="bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-950 border-b mb-4 pb-2"
272
+
>
231
273
<div
232
274
class="px-2 md:ml-[19vw] mx-auto flex flex-wrap items-center text-center"
233
275
>
234
-
<h1 class="text-4xl font-bold mr-4 text-gray-900">lexicon tracker</h1>
235
-
<p class="text-lg mt-1 text-gray-600">
276
+
<h1 class="text-4xl font-bold mr-4 text-gray-900 dark:text-gray-200">
277
+
lexicon tracker
278
+
</h1>
279
+
<p class="text-lg mt-1 text-gray-600 dark:text-gray-300">
236
280
tracks lexicons seen on the jetstream {tracking_since === 0
237
281
? ""
238
282
: `(since: ${formatTimestamp(tracking_since)})`}
239
283
</p>
240
284
</div>
241
285
</header>
242
-
<div class="md:max-w-[61vw] mx-auto p-2">
286
+
<div class="bg-white dark:bg-gray-900 md:max-w-[61vw] mx-auto p-2">
243
287
<div class="min-w-fit grid grid-cols-2 xl:grid-cols-4 gap-2 2xl:gap-6 mb-8">
244
288
<StatsCard
245
289
title="total creation"
···
265
309
266
310
{#if error}
267
311
<div
268
-
class="bg-red-100 border border-red-300 text-red-700 px-4 py-3 rounded-lg mb-6"
312
+
class="bg-red-100 dark:bg-red-900 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded-lg mb-6"
269
313
>
270
314
<p>Error: {error}</p>
271
315
</div>
···
274
318
{#if eventsList.length > 0}
275
319
<div class="mb-8">
276
320
<div class="flex flex-wrap items-center gap-3 mb-3">
277
-
<h2 class="text-2xl font-bold text-gray-900">seen lexicons</h2>
321
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-200">
322
+
seen lexicons
323
+
</h2>
278
324
<StatusBadge status={websocketStatus} />
279
325
</div>
280
326
<div class="flex flex-wrap items-center gap-1.5 mb-6">
···
296
342
refreshRate = "";
297
343
}}
298
344
/>
345
+
<ShowControls
346
+
{show}
347
+
onShowChange={(value: ShowOption) => {
348
+
show = value;
349
+
updateEventsList(get(events));
350
+
}}
351
+
/>
299
352
<RefreshControl
300
353
{refreshRate}
301
354
onRefreshChange={(value) => {
···
320
373
{/if}
321
374
</div>
322
375
323
-
<footer class="py-2 border-t border-gray-200 text-center">
324
-
<p class="text-gray-600 text-sm">
376
+
<footer class="py-2 border-t border-gray-200 dark:border-gray-800 text-center">
377
+
<p class="text-gray-600 dark:text-gray-200 text-sm">
325
378
source code <a
326
379
href="https://tangled.sh/@poor.dog/nsid-tracker"
327
380
target="_blank"
328
381
rel="noopener noreferrer"
329
-
class="text-blue-600 hover:text-blue-800 underline"
382
+
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-600 underline"
330
383
>@poor.dog/nsid-tracker</a
331
384
>
332
385
</p>
+2
-2
server/src/main.rs
+2
-2
server/src/main.rs
···
71
71
.expect("cant install rustls crypto provider");
72
72
73
73
let urls = [
74
+
"wss://jetstream1.us-west.bsky.network/subscribe",
75
+
"wss://jetstream2.us-west.bsky.network/subscribe",
74
76
"wss://jetstream2.fr.hose.cam/subscribe",
75
77
"wss://jetstream.fire.hose.cam/subscribe",
76
-
"wss://jetstream1.us-west.bsky.network/subscribe",
77
-
"wss://jetstream2.us-west.bsky.network/subscribe",
78
78
];
79
79
let mut jetstream = match JetstreamClient::new(urls) {
80
80
Ok(client) => client,