Skip to content

Commit 3a17abf

Browse files
committed
make look better :)
1 parent 49d7cb1 commit 3a17abf

4 files changed

Lines changed: 239 additions & 53 deletions

File tree

src/lib/components/ControlModules/impl/ShockerMenu.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
let pauseLoading = $state(false);
2121
2222
function viewLogs() {
23-
goto(resolve(`/shockers/logs/${shocker.id}`));
23+
goto(resolve(`/shockers/logs?shockerId=${shocker.id}`));
2424
}
2525
2626
function editShocker() {

src/lib/components/Table/DataTableTemplate.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
} from '@tanstack/table-core';
1212
import { FlexRender, createSvelteTable } from '$lib/components/ui/data-table';
1313
import * as Table from '$lib/components/ui/table';
14+
import { cn } from '$lib/utils';
1415
1516
interface Props {
1617
data: TData[];
@@ -19,6 +20,7 @@
1920
filters?: ColumnFiltersState;
2021
pagination?: PaginationState;
2122
onRowClick?: (row: TData) => void;
23+
class?: string;
2224
}
2325
2426
let {
@@ -28,6 +30,7 @@
2830
filters = $bindable(),
2931
pagination = $bindable(),
3032
onRowClick,
33+
class: className,
3134
}: Props = $props();
3235
3336
const table = createSvelteTable({
@@ -78,7 +81,7 @@
7881
});
7982
</script>
8083

81-
<div class="overflow-y-auto rounded-md border">
84+
<div class={cn('overflow-y-auto rounded-md border', className)}>
8285
<Table.Root>
8386
<Table.Header>
8487
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}

src/lib/utils/urlFilters.svelte.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { goto } from '$app/navigation';
2+
import { page } from '$app/state';
3+
import { untrack } from 'svelte';
4+
5+
type ParamTypeMap = {
6+
number: number;
7+
string: string;
8+
};
9+
10+
export type ParamDef = {
11+
[K in keyof ParamTypeMap]: {
12+
type: K;
13+
default?: ParamTypeMap[K] | null;
14+
};
15+
}[keyof ParamTypeMap];
16+
17+
// Resolved value type for a single param definition:
18+
// - `{ type: 'number' }` → number | undefined
19+
// - `{ type: 'number', default: null }` → number | null | undefined
20+
// - `{ type: 'string' }` → string | undefined
21+
type FilterValueFor<D extends ParamDef> = null extends D['default']
22+
? ParamTypeMap[D['type']] | null | undefined
23+
: ParamTypeMap[D['type']] | undefined;
24+
25+
export type FilterState<T extends Record<string, ParamDef>> = {
26+
-readonly [K in keyof T]: FilterValueFor<T[K]>;
27+
};
28+
29+
// Internal broadest value type used in records that mix all param kinds
30+
type FilterValue = string | number | null | undefined;
31+
32+
// Sentinel used to represent null in URL params and localStorage
33+
const NULL_SENTINEL = '__null__';
34+
35+
function parseValue(
36+
type: ParamDef['type'],
37+
raw: string | null,
38+
defaultValue?: FilterValue
39+
): FilterValue {
40+
if (raw === null || raw === '') return defaultValue;
41+
if (raw === NULL_SENTINEL) return null;
42+
43+
if (type === 'number') {
44+
const n = Number(raw);
45+
return isNaN(n) ? defaultValue : n;
46+
}
47+
48+
return raw;
49+
}
50+
51+
/**
52+
* Creates a reactive filter state object that syncs bidirectionally with URL query parameters.
53+
*
54+
* Must be called at component initialisation time (top-level in a <script> block, or inside
55+
* a function that is called during component init — same rules as Svelte runes).
56+
*
57+
* On mount the state is seeded from the current URL, falling back to any declared default.
58+
* Whenever a filter value changes, the URL is updated with replaceState so the address bar
59+
* always reflects the current filter state and users can copy/share the link.
60+
*
61+
* @example
62+
* const filters = createUrlFilters({
63+
* companyId: { type: 'number' },
64+
* year: { type: 'number', default: new Date().getFullYear() },
65+
* search: { type: 'string' },
66+
* categoryId: { type: 'number' },
67+
* } as const satisfies Record<string, ParamDef>);
68+
*
69+
* // Bind directly to components:
70+
* <ComboBox bind:selectedId={filters.companyId} ... />
71+
*
72+
* // Read in derived/effect just like any $state property:
73+
* $effect(() => { if (filters.companyId) loadData(filters.companyId); });
74+
*/
75+
export function createUrlFilters<T extends Record<string, ParamDef>>(
76+
defs: T,
77+
options?: { storageKey?: string }
78+
): FilterState<T> {
79+
// Load saved filters from localStorage if a storageKey is provided
80+
const storageKey = options?.storageKey;
81+
let saved: Record<string, FilterValue> = {};
82+
if (storageKey) {
83+
try {
84+
const raw = localStorage.getItem(storageKey);
85+
if (raw) saved = JSON.parse(raw);
86+
} catch {
87+
// ignore corrupt data
88+
}
89+
}
90+
91+
// Seed initial values from URL, then localStorage, then declared defaults
92+
const init: Record<string, FilterValue> = {};
93+
for (const [key, def] of Object.entries(defs)) {
94+
const fromUrl = page.url.searchParams.get(key);
95+
if (fromUrl !== null && fromUrl !== '') {
96+
init[key] = parseValue(def.type, fromUrl, def.default);
97+
} else if (key in saved) {
98+
init[key] = saved[key];
99+
} else {
100+
init[key] = def.default;
101+
}
102+
}
103+
104+
const state = $state(init) as FilterState<T>;
105+
const keys = Object.keys(defs);
106+
107+
// Whenever any filter value changes, update the URL with replaceState so the
108+
// address bar stays in sync without adding a history entry.
109+
// page.url is read inside untrack() so URL changes caused by our own goto
110+
// call do not re-trigger this effect (avoids an infinite loop).
111+
$effect(() => {
112+
// Snapshot all tracked state values before entering untrack
113+
const snapshot: Record<string, FilterValue> = {};
114+
for (const key of keys) {
115+
snapshot[key] = (state as Record<string, FilterValue>)[key];
116+
}
117+
118+
untrack(() => {
119+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
120+
const params = new URLSearchParams(page.url.searchParams);
121+
for (const key of keys) {
122+
const value = snapshot[key];
123+
if (value === null) {
124+
params.set(key, NULL_SENTINEL);
125+
} else if (value !== undefined) {
126+
params.set(key, String(value));
127+
} else {
128+
params.delete(key);
129+
}
130+
}
131+
// eslint-disable-next-line svelte/no-navigation-without-resolve
132+
goto(`?${params}`, { replaceState: true, keepFocus: true, noScroll: true });
133+
134+
// Persist to localStorage so filters survive navigation
135+
if (storageKey) {
136+
try {
137+
localStorage.setItem(storageKey, JSON.stringify(snapshot));
138+
} catch {
139+
// storage full or unavailable — silently ignore
140+
}
141+
}
142+
});
143+
});
144+
145+
return state;
146+
}

src/routes/(app)/shockers/logs/+page.svelte

Lines changed: 88 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,81 @@
1313
} from '$lib/components/Table/ColumnUtils';
1414
import DataTable from '$lib/components/Table/DataTableTemplate.svelte';
1515
import PaginationFooter from '$lib/components/Table/PaginationFooter.svelte';
16+
import { Badge } from '$lib/components/ui/badge';
1617
import Button from '$lib/components/ui/button/button.svelte';
1718
import * as Card from '$lib/components/ui/card';
18-
import * as Select from '$lib/components/ui/select';
19+
import MultiSelectCombobox from '$lib/components/ui/multi-select-combobox/multi-select-combobox.svelte';
1920
import { registerBreadcrumbs } from '$lib/state/breadcrumbs-state.svelte';
2021
import { addShockEventListener, removeShockEventListener } from '$lib/signalr/handlers/Log';
2122
import { ControlType } from '$lib/signalr/models/ControlType';
2223
import { ownHubs, refreshOwnHubs } from '$lib/state/hubs-state.svelte';
23-
24+
import { createUrlFilters } from '$lib/utils/urlFilters.svelte';
2425
2526
registerBreadcrumbs(() => [
2627
{ label: 'Shockers', href: '/shockers/own' },
2728
{ label: 'Shocker Logs' },
2829
]);
29-
30+
3031
const DEFAULT_SORT_ID = 'createdOn';
3132
3233
let logs = $state<LogEntryWithHub[]>([]);
3334
let sorting = $state<SortingState>([{ id: DEFAULT_SORT_ID, desc: true }]);
3435
3536
let isFetching = $state(false);
3637
let requestedPage = $state(1);
37-
let pageSize = $state(100);
3838
let page = $state(1);
3939
let total = $state(0);
4040
41-
// Empty = no filter (logs for all of the caller's shockers).
42-
let selectedShockerIds = $state<string[]>([]);
41+
// Row metrics — rows are single-line (whitespace-nowrap), so heights are
42+
// stable and we can size pages off them without measuring each row.
43+
const HEADER_HEIGHT = 40; // Table.Head (h-10)
44+
const ROW_HEIGHT = 37; // td p-2 (16) + text-sm line (20) + border-b (1)
45+
const MIN_PAGE_SIZE = 10;
46+
const DEFAULT_PAGE_SIZE = 25; // used before the viewport has been measured
47+
48+
// Height available to the table, measured from the DOM (see markup binding).
49+
let tableViewportHeight = $state(0);
50+
51+
// Fit as many rows as the viewport allows (clamped to a sane minimum) so the
52+
// table fills the screen and we request exactly the page size we can show.
53+
const pageSize = $derived.by(() => {
54+
if (tableViewportHeight <= 0) return DEFAULT_PAGE_SIZE;
55+
const rows = Math.floor((tableViewportHeight - HEADER_HEIGHT) / ROW_HEIGHT);
56+
return Math.max(MIN_PAGE_SIZE, rows);
57+
});
58+
59+
// Shocker filter synced to the URL (comma-separated list under ?shockerId=)
60+
// so it can be bookmarked / shared. Empty = no filter (all of the caller's
61+
// shockers).
62+
const filters = createUrlFilters({
63+
shockerId: { type: 'string' },
64+
} as const);
65+
66+
const shockerOptions = $derived(
67+
ownHubs
68+
.values()
69+
.flatMap((hub) => hub.shockers)
70+
.map((shocker) => ({ value: shocker.id, label: shocker.name }))
71+
.toArray()
72+
);
4373
44-
const filterLabel = $derived.by(() => {
45-
if (selectedShockerIds.length === 0) return 'All shockers';
46-
if (selectedShockerIds.length === 1) {
47-
for (const hub of ownHubs.values()) {
48-
const shocker = hub.shockers.find((s) => s.id === selectedShockerIds[0]);
49-
if (shocker) return shocker.name;
50-
}
51-
return '1 shocker';
74+
// Local selection bound to the combobox, which mutates the array in place, so
75+
// it needs a real $state target (not a derived getter/setter). Seeded from the
76+
// URL-synced filter.
77+
let selectedShockerIds = $state<string[]>(
78+
filters.shockerId ? filters.shockerId.split(',').filter(Boolean) : []
79+
);
80+
81+
// Push selection changes back into the URL-synced filter and reset to the
82+
// first page so we never land on an out-of-range page for the narrowed set.
83+
$effect(() => {
84+
const joined = selectedShockerIds.length > 0 ? selectedShockerIds.join(',') : undefined;
85+
if (joined !== filters.shockerId) {
86+
filters.shockerId = joined;
87+
requestedPage = 1;
5288
}
53-
return `${selectedShockerIds.length} shockers`;
5489
});
5590
56-
// Reset to the first page whenever the filter changes so we never land on an
57-
// out-of-range page for the narrowed result set.
58-
function onFilterChange() {
59-
requestedPage = 1;
60-
}
61-
6291
const sortQuery = $derived(sorting.length > 0 ? sorting[0] : undefined);
6392
// Live updates only make sense on page 1 with the default newest-first sort,
6493
// otherwise prepending breaks the user's chosen ordering / page slice.
@@ -85,7 +114,6 @@
85114
.then((res) => {
86115
logs = res.items;
87116
page = res.page;
88-
pageSize = res.pageSize;
89117
total = res.totalCount;
90118
})
91119
.catch(handleApiError)
@@ -155,45 +183,54 @@
155183
<Card.Header class="w-full">
156184
<Card.Title class="flex items-center justify-between space-x-2 text-3xl">
157185
<h1>Shocker Logs</h1>
186+
<Badge
187+
variant={liveUpdatesActive ? 'default' : 'secondary'}
188+
class="gap-1.5"
189+
title={liveUpdatesActive
190+
? 'New logs appear in real time'
191+
: 'Live updates pause when sorting or viewing a page other than the first'}
192+
>
193+
<span class="relative flex size-2">
194+
{#if liveUpdatesActive}
195+
<span
196+
class="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"
197+
></span>
198+
{/if}
199+
<span
200+
class="relative inline-flex size-2 rounded-full {liveUpdatesActive
201+
? 'bg-green-500'
202+
: 'bg-muted-foreground'}"
203+
></span>
204+
</span>
205+
{liveUpdatesActive ? 'Live' : 'Paused'}
206+
</Badge>
158207
</Card.Title>
159208
<Card.Description>These are the logs for all shockers.</Card.Description>
160209
</Card.Header>
161-
<div class="grid w-full gap-6 p-6">
162-
<div class="flex items-center gap-2">
163-
<Select.Root type="multiple" bind:value={selectedShockerIds} onValueChange={onFilterChange}>
164-
<Select.Trigger class="w-64">{filterLabel}</Select.Trigger>
165-
<Select.Content>
166-
{#each [...ownHubs.values()] as hub (hub.id)}
167-
{#if hub.shockers.length > 0}
168-
<Select.Group>
169-
<Select.GroupHeading>{hub.name}</Select.GroupHeading>
170-
{#each hub.shockers as shocker (shocker.id)}
171-
<Select.Item value={shocker.id} label={shocker.name}>{shocker.name}</Select.Item>
172-
{/each}
173-
</Select.Group>
174-
{/if}
175-
{/each}
176-
</Select.Content>
177-
</Select.Root>
210+
<div class="flex min-h-0 w-full flex-1 flex-col gap-6 p-6">
211+
<div class="flex items-end gap-2">
212+
<div class="w-64">
213+
<MultiSelectCombobox
214+
bind:selected={selectedShockerIds}
215+
options={shockerOptions}
216+
label="Filter by shocker"
217+
placeholder="Search shockers..."
218+
selectText="All shockers"
219+
noMatchText="No matching shockers"
220+
/>
221+
</div>
178222
{#if selectedShockerIds.length > 0}
179-
<Button
180-
variant="ghost"
181-
size="sm"
182-
onclick={() => {
183-
selectedShockerIds = [];
184-
onFilterChange();
185-
}}
186-
>
187-
Clear
188-
</Button>
223+
<Button variant="ghost" size="sm" onclick={() => (selectedShockerIds = [])}>Clear</Button>
189224
{/if}
190225
</div>
191-
<DataTable data={logs} {columns} bind:sorting />
226+
<div class="min-h-0 flex-1" bind:clientHeight={tableViewportHeight}>
227+
<DataTable data={logs} {columns} bind:sorting class="h-full" />
228+
</div>
192229
<PaginationFooter
193230
count={total}
194231
perPage={pageSize}
195232
bind:page={() => page, (p) => (requestedPage = p)}
196233
disabled={isFetching}
197234
/>
198235
</div>
199-
</Container>
236+
</Container>

0 commit comments

Comments
 (0)