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.
85114 .then ((res ) => {
86115 logs = res .items ;
87116 page = res .page ;
88- pageSize = res .pageSize ;
89117 total = res .totalCount ;
90118 })
91119 .catch (handleApiError )
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