@luxonis/depthai-viewer-common 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,402 @@
1
+ import type { Pipeline } from '@luxonis/depthai-pipeline-lib';
2
+ import { type PublicChannelData, VisualizerConnection } from '@luxonis/visualizer-protobuf';
3
+ // TODO: Outdated import. Replace after updating visualizer-protobuf
4
+ import type { VisualizerConnectionArgs } from '@luxonis/visualizer-protobuf/dist/lib/src/connection/connection.js';
5
+ import { DAI_SERVICE_NAME } from '../constants/connection.js';
6
+ import type { Topic } from '../hooks/connection.js';
7
+ import type { DecoderWorkerInput, DecoderWorkerOutput } from './decoder.worker.js';
8
+ import _ from 'lodash';
9
+ import { StreamTypes } from '../constants/streams.js';
10
+
11
+ export type DAIConnectionInitArgs = {
12
+ type: 'ws' | 'webrtc';
13
+ connectionUrl: string;
14
+ clientId: string;
15
+ token: string;
16
+ automaticTokenRefresh?: (token: string) => void;
17
+ } & {
18
+ enabledStreams: string[];
19
+ };
20
+
21
+ type DaiConnectionEventMap = {
22
+ open: () => void;
23
+ error: (error: MessageEvent<unknown> | ErrorEvent) => void;
24
+ close: () => void;
25
+ pipeline: (pipeline: Pipeline | null) => void;
26
+ topics: (topics: Topic[]) => void;
27
+ disconnect: (error: ErrorEvent) => void;
28
+ };
29
+
30
+ export type DAIService = keyof typeof DAI_SERVICE_NAME;
31
+
32
+ export class DAIConnection {
33
+ alive = true;
34
+ connection?: VisualizerConnection;
35
+
36
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
37
+ protected listeners: Partial<Record<keyof DaiConnectionEventMap, any[]>> = {};
38
+ // biome-ignore lint/suspicious/noExplicitAny: Typed only on a usage level
39
+ protected serviceListeners: Record<string, (data: any) => void> = {};
40
+
41
+ protected enabledTopics: string[] = [];
42
+ protected previouslyEnabledTopics: string[] = [];
43
+
44
+ protected decoderWorker!: Worker;
45
+ protected allowedServices: Set<DAIService> = new Set();
46
+ protected activeServices: Set<DAIService> = new Set([]);
47
+ protected serviceInterval: NodeJS.Timeout | null = null;
48
+
49
+ protected topicsTouched = false;
50
+ protected channels: PublicChannelData[] = [];
51
+ protected groups: Record<string, string> = {};
52
+ protected topics: Topic[] = [];
53
+ protected pipeline: Pipeline | null = null;
54
+
55
+ protected recreateDecoderWorker() {
56
+ this.decoderWorker?.terminate();
57
+ this.decoderWorker = new Worker(new URL('./decoder.worker.js', import.meta.url), {
58
+ type: 'module',
59
+ /* @vite-ignore */
60
+ basePath: window.__basePath,
61
+ } as WorkerOptions);
62
+
63
+ this.decoderWorker.addEventListener('error', (event) =>
64
+ console.error('Decoder Worker Error:', event.message, event),
65
+ );
66
+ this.decoderWorker.addEventListener('messageerror', (event) =>
67
+ console.error('Decoder Worker Error (message):', event.data, event),
68
+ );
69
+
70
+ this.decoderWorker.addEventListener(
71
+ 'message',
72
+ ({ data: { kind, data, name } }: MessageEvent<DecoderWorkerOutput>) => {
73
+ if (kind === 'pipeline') {
74
+ if (JSON.stringify(this.pipeline) !== JSON.stringify(data)) {
75
+ this.pipeline = data;
76
+ for (const listener of this.listeners.pipeline ?? []) {
77
+ listener(data);
78
+ }
79
+ }
80
+ } else if (name) {
81
+ this.serviceListeners[name]?.(data);
82
+ } else {
83
+ this.serviceListeners[data.name]?.(data);
84
+ }
85
+ },
86
+ );
87
+ }
88
+
89
+ protected reportTopics(type?: 'ws' | 'webrtc') {
90
+ if (!this.groups || !this.channels || !this.alive) {
91
+ return;
92
+ }
93
+
94
+ const topicList: (Omit<Topic, 'annotations' | 'extraAnnotations'> & {
95
+ group: string | null;
96
+ })[] = [];
97
+ const annotationGroups: Record<string, string[]> = {};
98
+ const annotations: string[] = [];
99
+
100
+ for (const channel of this.channels) {
101
+ const group = this.groups[channel.topic];
102
+
103
+ if (channel.kind === 'annotations') {
104
+ annotations.push(channel.topic);
105
+ if (group) {
106
+ annotationGroups[group] ??= [];
107
+ annotationGroups[group].push(channel.topic);
108
+ }
109
+
110
+ continue;
111
+ }
112
+
113
+ const shouldOpenDefaultStream =
114
+ (this.enabledTopics.length === 0 ||
115
+ (this.enabledTopics.length !== 0 &&
116
+ this.enabledTopics.includes(StreamTypes.NeuralNetwork))) &&
117
+ !this.topicsTouched;
118
+
119
+ if (shouldOpenDefaultStream) {
120
+ // If the connection is over WebSockets we want to open RAW stream as default stream
121
+ if (
122
+ type === 'ws' &&
123
+ _.isEqual(channel.topic.toLowerCase(), StreamTypes.Raw.toLowerCase())
124
+ ) {
125
+ this.topicsTouched = true;
126
+ this.enabledTopics.push(channel.topic);
127
+
128
+ if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
129
+ this.enabledTopics.push(StreamTypes.NeuralNetwork);
130
+ }
131
+ // If the connection is over WebRTC we want to open ENCODED stream as default stream
132
+ } else if (
133
+ type === 'webrtc' &&
134
+ _.isEqual(channel.topic.toLowerCase(), StreamTypes.Encoded.toLowerCase())
135
+ ) {
136
+ this.topicsTouched = true;
137
+ this.enabledTopics.push(channel.topic);
138
+
139
+ if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
140
+ this.enabledTopics.push(StreamTypes.NeuralNetwork);
141
+ }
142
+ }
143
+ }
144
+
145
+ topicList.push({
146
+ name: channel.topic,
147
+ kind: channel.kind,
148
+ enabled: this.enabledTopics.includes(channel.topic),
149
+ group: this.groups[channel.topic] ?? null,
150
+ });
151
+ }
152
+
153
+ const fullAnnotationGroups = Object.fromEntries(
154
+ Object.entries(annotationGroups).map(([group, topics]) => [
155
+ group,
156
+ {
157
+ topics,
158
+ extra: annotations.filter((annotation) => !topics.includes(annotation)),
159
+ },
160
+ ]),
161
+ );
162
+
163
+ const updatedTopics = topicList.map<Topic>((topic) => ({
164
+ ...topic,
165
+ annotations: fullAnnotationGroups[topic.group ?? '']?.topics ?? annotations,
166
+ extraAnnotations: fullAnnotationGroups[topic.group ?? '']?.extra ?? [],
167
+ }));
168
+
169
+ if (JSON.stringify(this.topics) !== JSON.stringify(updatedTopics)) {
170
+ const [oldTopics, newTopics] = updatedTopics.reduce<[Set<string>, Set<string>]>(
171
+ (sets, topic) => {
172
+ const set = sets[Number(topic.enabled)];
173
+ for (const name of [topic.name, ...topic.annotations, ...topic.extraAnnotations]) {
174
+ set.add(name);
175
+ }
176
+ return sets;
177
+ },
178
+ [new Set(), new Set()],
179
+ );
180
+
181
+ for (const annotation of newTopics) {
182
+ oldTopics.delete(annotation);
183
+ }
184
+
185
+ // TODO: Rewrite lib to control enabled annotations
186
+ // This setup subscribes to all annotations regardless of the enabled state
187
+ if (oldTopics.size !== 0) {
188
+ this.connection?.unsubscribe({ topics: [...oldTopics] });
189
+ }
190
+
191
+ if (newTopics.size !== 0) {
192
+ this.connection?.subscribe({ topics: [...newTopics] });
193
+ }
194
+
195
+ this.topics = updatedTopics;
196
+ for (const listener of this.listeners.topics ?? []) {
197
+ listener(updatedTopics);
198
+ }
199
+ }
200
+ }
201
+
202
+ protected pollServices() {
203
+ if (this.serviceInterval) {
204
+ clearInterval(this.serviceInterval);
205
+ }
206
+ this.serviceInterval = setInterval(() => {
207
+ for (const service of this.activeServices) {
208
+ this.fetchService(service);
209
+ }
210
+ }, 3_000);
211
+ }
212
+
213
+ init({ enabledStreams, ...args }: DAIConnectionInitArgs) {
214
+ if (enabledStreams && enabledStreams.length > 0) {
215
+ console.debug('Initializing connection with predefined streams', enabledStreams);
216
+ this.enabledTopics = enabledStreams;
217
+ }
218
+
219
+ this.recreateDecoderWorker();
220
+ this.setupConnection(args);
221
+ }
222
+
223
+ setupConnection({
224
+ type,
225
+ connectionUrl,
226
+ clientId,
227
+ token,
228
+ automaticTokenRefresh,
229
+ }: Pick<
230
+ DAIConnectionInitArgs,
231
+ 'type' | 'clientId' | 'connectionUrl' | 'token' | 'automaticTokenRefresh'
232
+ >) {
233
+ // TODO: Enable Luxonis ICE Servers
234
+ this.connection = new VisualizerConnection({
235
+ type: type as never,
236
+ data: (type === 'ws'
237
+ ? ({ connectionUrl } as (VisualizerConnectionArgs & {
238
+ type: 'ws';
239
+ })['data'])
240
+ : ({
241
+ config: {
242
+ clientId,
243
+ applicationIdentifier: '', // TODO:
244
+ authPayload: token,
245
+ signalingConnectionRetries: 5,
246
+ useLuxonisIceServers: true,
247
+ },
248
+ automaticTokenRefresh,
249
+ } as (VisualizerConnectionArgs & {
250
+ type: 'webrtc';
251
+ })['data'])) as never,
252
+ });
253
+
254
+ this.connection.on('open', () => {
255
+ if (!this.alive) return;
256
+ for (const listener of this.listeners.open ?? []) {
257
+ listener();
258
+ }
259
+ });
260
+
261
+ this.connection.on('close', () => {
262
+ if (!this.alive) return;
263
+ this.dispose();
264
+ });
265
+
266
+ this.connection.on('disconnect', (error) => {
267
+ for (const listener of this.listeners.disconnect ?? []) {
268
+ listener(error);
269
+ }
270
+ });
271
+
272
+ this.connection.on('services', (message) => {
273
+ if (!this.alive) return;
274
+ this.allowedServices.clear();
275
+ for (const service of message.data) {
276
+ this.allowedServices.add(service as DAIService);
277
+ if (
278
+ service === DAI_SERVICE_NAME.getDevices ||
279
+ service === DAI_SERVICE_NAME.pipelineSchema ||
280
+ service === DAI_SERVICE_NAME.topicGroups
281
+ ) {
282
+ this.fetchService(service as DAIService);
283
+ }
284
+ }
285
+ this.pollServices();
286
+ });
287
+
288
+ this.connection.on('channels', (message) => {
289
+ if (!this.alive) return;
290
+ this.channels = message.data;
291
+ this.reportTopics(type);
292
+
293
+ // If no default topic is enabled we want to enable at least one topic
294
+ if (this.enabledTopics.length === 0 && this.channels.at(0)?.topic) {
295
+ console.warn(
296
+ `No default topic is enabled - using ${this.channels.at(0)?.topic} as default topic.`,
297
+ );
298
+
299
+ this.toggleTopic(this.channels.at(0)!.topic);
300
+ }
301
+ });
302
+ }
303
+
304
+ async fetchService(
305
+ service: DAIService,
306
+ // biome-ignore lint/suspicious/noExplicitAny: Can't possibly be typed, we don't know request specifics
307
+ body?: Record<string, any> | string | number,
308
+ ) {
309
+ if (!this.alive) {
310
+ console.warn(`DAI: fetchService called while not alive for service "${service}"`);
311
+ return;
312
+ }
313
+
314
+ const canPoll = this.allowedServices.has(service);
315
+
316
+ if (canPoll) {
317
+ const data = await this.connection?.request({
318
+ name: DAI_SERVICE_NAME[service],
319
+ timeout: 60_000,
320
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
321
+ body: body as Record<string, any>,
322
+ });
323
+
324
+ if (data) {
325
+ this.decoderWorker.postMessage(
326
+ {
327
+ kind: service === 'pipelineSchema' ? 'pipeline' : 'json',
328
+ name: service,
329
+ data,
330
+ } as DecoderWorkerInput,
331
+ [data.buffer],
332
+ );
333
+ }
334
+ } else {
335
+ console.warn(
336
+ `DAI: fetchService called for service "${service}" while it's not allowed to poll`,
337
+ );
338
+ }
339
+ }
340
+
341
+ setActiveServices(services: DAIService[]) {
342
+ this.activeServices = new Set(services);
343
+ }
344
+
345
+ dispose() {
346
+ this.alive = false;
347
+ this.decoderWorker.terminate();
348
+ if (this.serviceInterval) {
349
+ clearInterval(this.serviceInterval);
350
+ }
351
+ this.connection?.close();
352
+ for (const listener of this.listeners.close ?? []) {
353
+ listener();
354
+ }
355
+ }
356
+
357
+ handleKeyPress(key: string) {
358
+ void this.connection?.request({
359
+ name: DAI_SERVICE_NAME.keyPressed,
360
+ body: { key },
361
+ ignoreResponse: true,
362
+ });
363
+ }
364
+
365
+ toggleTopic(topic: string) {
366
+ this.topicsTouched = true;
367
+ const enabledIndex = this.enabledTopics.indexOf(topic);
368
+
369
+ if (enabledIndex === -1) {
370
+ this.enabledTopics.push(topic);
371
+ } else {
372
+ this.enabledTopics.splice(enabledIndex, 1);
373
+ }
374
+
375
+ this.reportTopics();
376
+ }
377
+
378
+ stopTopics() {
379
+ this.topicsTouched = false;
380
+ this.previouslyEnabledTopics = this.enabledTopics;
381
+
382
+ this.enabledTopics = [];
383
+ this.reportTopics();
384
+ }
385
+
386
+ restartTopics() {
387
+ this.topicsTouched = true;
388
+ this.enabledTopics = this.previouslyEnabledTopics;
389
+ this.previouslyEnabledTopics = [];
390
+
391
+ this.reportTopics();
392
+ }
393
+
394
+ on<T extends keyof DaiConnectionEventMap>(event: T, callback: DaiConnectionEventMap[T]) {
395
+ this.listeners[event] ??= [];
396
+ this.listeners[event].push(callback);
397
+ }
398
+
399
+ setOnService<T>(service: string, callback: (data: T) => void) {
400
+ this.serviceListeners[service] = callback;
401
+ }
402
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ type Pipeline,
3
+ parsePipeline,
4
+ } from '../../../../node_modules/@luxonis/depthai-pipeline-lib/dist/src/services/pipeline.js';
5
+
6
+ function decodeService(kind: 'pipeline' | 'json', response: DataView | null) {
7
+ if (!response) {
8
+ return null;
9
+ }
10
+
11
+ try {
12
+ const decoder = new TextDecoder('utf-8');
13
+ const decodedPayload = decoder.decode(response);
14
+ let parsed = JSON.parse(decodedPayload);
15
+
16
+ if (kind === 'pipeline') {
17
+ parsed = parsePipeline(parsed);
18
+ }
19
+
20
+ return parsed;
21
+ } catch (error) {
22
+ console.error('Error decoding JSON:', error);
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export type DecoderWorkerInput = {
28
+ kind: 'pipeline' | 'json';
29
+ name?: string;
30
+ data: DataView;
31
+ };
32
+
33
+ export type DecoderWorkerOutput = { name?: string } & (
34
+ | {
35
+ kind: 'pipeline';
36
+ data: Pipeline;
37
+ }
38
+ | {
39
+ kind: 'json';
40
+ data: Record<string, string>;
41
+ }
42
+ );
43
+
44
+ self.addEventListener(
45
+ 'message',
46
+ ({ data: { kind, data, name } }: MessageEvent<DecoderWorkerInput>) => {
47
+ const decoded = decodeService(kind, data);
48
+ self.postMessage({ kind, name, data: decoded } as DecoderWorkerOutput);
49
+ },
50
+ );
@@ -0,0 +1,191 @@
1
+ import { useToast } from '@luxonis/common-fe-components';
2
+ import type { Pipeline } from '@luxonis/depthai-pipeline-lib';
3
+ import type { VisualizerConnection } from '@luxonis/visualizer-protobuf';
4
+ import React from 'react';
5
+ import { StreamTypes } from '../constants/streams.js';
6
+ import { DAIConnection, type DAIService } from '../dai-connection/connection.js';
7
+ import { useFlatSearch } from './search.js';
8
+
9
+ export type Topic = {
10
+ name: string;
11
+ enabled: boolean;
12
+ kind: 'pointCloud' | 'image';
13
+ annotations: string[];
14
+ extraAnnotations: string[];
15
+ };
16
+
17
+ export type ConnectionData = {
18
+ connected: boolean;
19
+ daiConnection: DAIConnection | null;
20
+ connections: VisualizerConnection[];
21
+ pipeline: Pipeline | null | undefined;
22
+ isPipelineLoading: boolean;
23
+ setIsPipelineLoading: (isLoading: boolean) => void;
24
+ topics: Topic[];
25
+ toggleTopic: (topic: string) => void;
26
+ };
27
+
28
+ export type UseCreateConnectionArgs = {
29
+ type: 'webrtc' | 'ws';
30
+ connectionUrl: string;
31
+ token: string;
32
+ clientId: string;
33
+ activeServices: DAIService[];
34
+ onNewConnection?: (connection: DAIConnection) => void;
35
+ };
36
+
37
+ export function useCreateConnection({
38
+ type,
39
+ activeServices,
40
+ onNewConnection,
41
+ ...params
42
+ }: UseCreateConnectionArgs): ConnectionData {
43
+ const { updateSearch, search } = useFlatSearch({
44
+ defaultInit: { streams: [], cid: '', t: '' },
45
+ arrayFields: ['streams'],
46
+ });
47
+ const [connected, setConnected] = React.useState(false);
48
+ const [topics, setTopics] = React.useState<Topic[]>([]);
49
+ const [pipeline, setPipeline] = React.useState<Pipeline | null | undefined>(undefined);
50
+ const [isPipelineLoading, setIsPipelineLoading] = React.useState(false);
51
+
52
+ const [connection, setConnection] = React.useState<DAIConnection | null>(null);
53
+
54
+ const { toast } = useToast();
55
+ const handleGetCameraIntrinsics = () => {
56
+ if (connection) {
57
+ const getCameraIntrinsics = localStorage.getItem('neuralCameraIntrinsics');
58
+ if (!getCameraIntrinsics) {
59
+ connection.setOnService('getCameraIntrinsics', (data) => {
60
+ // @ts-ignore - The responce is not typed
61
+ localStorage.setItem('neuralCameraIntrinsics', JSON.stringify(data.data));
62
+ });
63
+ connection.fetchService('getCameraIntrinsics');
64
+ }
65
+ } else {
66
+ console.warn('Failed to get camera intrinsics, no connection');
67
+ }
68
+ };
69
+ handleGetCameraIntrinsics();
70
+
71
+ const refreshConnection = () => {
72
+ const newConnection = new DAIConnection();
73
+
74
+ newConnection.on('error', console.error);
75
+ newConnection.on('open', () => {
76
+ setConnected(true);
77
+ handleGetCameraIntrinsics();
78
+ setIsPipelineLoading(true);
79
+ });
80
+ newConnection.on('close', () => {
81
+ setConnected(false);
82
+ setTopics([]);
83
+ setPipeline(undefined);
84
+ setIsPipelineLoading(false);
85
+ });
86
+ newConnection.on('topics', setTopics);
87
+ newConnection.on('pipeline', (pipeline) => {
88
+ setPipeline(pipeline);
89
+ setIsPipelineLoading(false);
90
+ });
91
+ newConnection.on('disconnect', (_error) => {
92
+ toast({
93
+ description:
94
+ 'The link to the application frontend is not valid. You need to open the frontend from Luxonis Hub again.',
95
+ colorVariant: 'error',
96
+ duration: 'long',
97
+ name: 'connection-error',
98
+ });
99
+ setConnected(false);
100
+ setTopics([]);
101
+ setPipeline(undefined);
102
+ });
103
+ onNewConnection?.(newConnection);
104
+
105
+ if (newConnection.alive) {
106
+ newConnection.init({
107
+ type,
108
+ ...params,
109
+ automaticTokenRefresh: (token) => {
110
+ updateSearch('t', token);
111
+ },
112
+ enabledStreams: search.streams as string[],
113
+ });
114
+
115
+ setConnection(newConnection);
116
+ }
117
+ };
118
+
119
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `params` will always change on re-renders
120
+ React.useEffect(() => {
121
+ if (!connected) {
122
+ refreshConnection();
123
+ } else {
124
+ handleGetCameraIntrinsics();
125
+ }
126
+ }, [params.clientId, params.connectionUrl, params.token, onNewConnection, connected]);
127
+
128
+ React.useEffect(() => {
129
+ connection?.setActiveServices(activeServices);
130
+ }, [connection, activeServices]);
131
+
132
+ const connections = React.useMemo(
133
+ () => (connection?.connection ? [connection.connection] : []),
134
+ [connection],
135
+ );
136
+
137
+ React.useEffect(() => {
138
+ if (connected) {
139
+ const callback = (event: KeyboardEvent) => connection?.handleKeyPress(event.key);
140
+ window.addEventListener('keydown', callback);
141
+ return () => window.removeEventListener('keydown', callback);
142
+ }
143
+ }, [connected, connection]);
144
+
145
+ React.useEffect(() => {
146
+ if (topics.length > 0) {
147
+ if (
148
+ topics.find((topic) => topic.name === StreamTypes.PointCloud)?.enabled &&
149
+ topics.find((t) => t.name === StreamTypes.Encoded)?.enabled === false
150
+ ) {
151
+ connection?.toggleTopic(StreamTypes.Encoded);
152
+ }
153
+ updateSearch(
154
+ 'streams',
155
+ topics.filter((topic) => topic.enabled).map((topic) => topic.name),
156
+ );
157
+ }
158
+ }, [topics, updateSearch]);
159
+
160
+ const toggleTopic = React.useCallback(
161
+ (topicName: string, isRawSteamEnabledManually?: boolean): void => {
162
+ if (
163
+ topicName === StreamTypes.PointCloud &&
164
+ topics.find((topic) => topic.name === topicName)?.enabled === false &&
165
+ topics.find((t) => t.name === StreamTypes.Encoded)?.enabled === false
166
+ ) {
167
+ connection?.toggleTopic(StreamTypes.Encoded);
168
+ }
169
+ if (
170
+ topicName === StreamTypes.PointCloud &&
171
+ isRawSteamEnabledManually === false &&
172
+ topics.find((n) => n.name === 'Point Cloud')?.enabled
173
+ ) {
174
+ connection?.toggleTopic(StreamTypes.Encoded);
175
+ }
176
+ connection?.toggleTopic(topicName);
177
+ },
178
+ [connection],
179
+ );
180
+
181
+ return {
182
+ connected,
183
+ daiConnection: connection,
184
+ connections,
185
+ pipeline,
186
+ isPipelineLoading,
187
+ setIsPipelineLoading,
188
+ topics,
189
+ toggleTopic,
190
+ };
191
+ }
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import { parseUrl as defaultParseUrl } from '../utils/url.js';
4
+
5
+ export function useNavigation(parseUrl?: (pathname: string) => { basePath: string; page: string }) {
6
+ const location = useLocation();
7
+
8
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
9
+ const { page, basePath } = React.useMemo(
10
+ () => (parseUrl ?? defaultParseUrl)(location.pathname),
11
+ [location.pathname],
12
+ );
13
+
14
+ const makePath = React.useCallback(
15
+ (page: string, options: { noSearch?: boolean; rootPage?: string } = {}) => {
16
+ const { noSearch = false, rootPage = 'streams' } = options;
17
+ const target = page === rootPage ? '' : page;
18
+
19
+ return `/${basePath}${basePath ? '/' : ''}${target}${noSearch ? '' : location.search}`;
20
+ },
21
+ [basePath, location.search],
22
+ );
23
+
24
+ return { basePath, page, makePath };
25
+ }
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+
4
+ export type UseFlatSearchArgs = {
5
+ defaultInit?: Record<string, string | string[]>;
6
+ arrayFields?: string[];
7
+ };
8
+
9
+ export function useFlatSearch({ defaultInit = {}, arrayFields }: UseFlatSearchArgs) {
10
+ const [search, setSearch] = useSearchParams(defaultInit);
11
+
12
+ const flatSearch = React.useMemo(
13
+ () => ({
14
+ ...defaultInit,
15
+ ...Object.fromEntries(
16
+ [...search.entries()].map(([key, value]) => [
17
+ key,
18
+ arrayFields?.includes(key) ? (value.length === 0 ? [] : value.split(',')) : value,
19
+ ]),
20
+ ),
21
+ }),
22
+ [arrayFields, defaultInit, search],
23
+ );
24
+
25
+ const updateSearch = React.useCallback(
26
+ (key: string, value: string | number | string[] | number[] | null) => {
27
+ setSearch((current) => {
28
+ if (Array.isArray(value)) {
29
+ current.set(key, value.join(','));
30
+ } else if (value !== null) {
31
+ current.set(key, value.toString());
32
+ } else {
33
+ current.delete(key);
34
+ }
35
+ return current;
36
+ });
37
+ },
38
+ [setSearch],
39
+ );
40
+
41
+ return {
42
+ search: flatSearch,
43
+ updateSearch: updateSearch,
44
+ };
45
+ }