feat: Implement back button and selectable registrars
This commit is contained in:
@@ -3,10 +3,11 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import {
|
||||
type NavigationActions,
|
||||
Selectable,
|
||||
type EnterEvent,
|
||||
type NavigateEvent
|
||||
type NavigateEvent,
|
||||
type KeyEvent,
|
||||
type Registrar
|
||||
} from './lib/selectable';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -17,6 +18,8 @@
|
||||
enter: EnterEvent;
|
||||
mount: Selectable;
|
||||
navigate: NavigateEvent;
|
||||
back: KeyEvent;
|
||||
playPause: KeyEvent;
|
||||
}>();
|
||||
|
||||
export let name: string = '';
|
||||
@@ -28,14 +31,14 @@
|
||||
export let debugOutline = false;
|
||||
export let focusOnClick = false;
|
||||
|
||||
export let active = true;
|
||||
export let registrars: Registrar[] = [];
|
||||
export let registrar: Registrar = () => {};
|
||||
|
||||
export let handleNavigateOut: NavigationActions = {};
|
||||
export let active = true;
|
||||
|
||||
const { registerer, ...rest } = new Selectable(name)
|
||||
.setDirection(direction === 'grid' ? 'horizontal' : direction)
|
||||
.setGridColumns(gridCols)
|
||||
.setNavigationActions(handleNavigateOut)
|
||||
.setTrapFocus(trapFocus)
|
||||
.setCanFocusEmpty(canFocusEmpty)
|
||||
.setOnFocus((selectable, options) => {
|
||||
@@ -67,20 +70,46 @@
|
||||
dispatch('select');
|
||||
dispatch('clickOrSelect');
|
||||
})
|
||||
.setOnBack((selectable, options) => {
|
||||
function stopPropagation() {
|
||||
options.propagate = false;
|
||||
}
|
||||
|
||||
function bubble() {
|
||||
options.propagate = true;
|
||||
}
|
||||
|
||||
dispatch('back', { selectable, options, stopPropagation, bubble });
|
||||
})
|
||||
.setOnPlayPause((selectable, options) => {
|
||||
function stopPropagation() {
|
||||
options.propagate = false;
|
||||
}
|
||||
|
||||
function bubble() {
|
||||
options.propagate = true;
|
||||
}
|
||||
|
||||
dispatch('playPause', { selectable, options, stopPropagation, bubble });
|
||||
})
|
||||
.getStores();
|
||||
export const container = rest.container;
|
||||
|
||||
$: registrars.forEach((r) => r(rest.container));
|
||||
$: registrar(rest.container);
|
||||
|
||||
export const selectable = rest.container;
|
||||
export const hasFocus = rest.hasFocus;
|
||||
export const hasFocusWithin = rest.hasFocusWithin;
|
||||
export const focusIndex = rest.focusIndex;
|
||||
|
||||
export let tag = 'div';
|
||||
|
||||
$: container.setIsActive(active);
|
||||
$: container.setGridColumns(gridCols);
|
||||
$: selectable.setIsActive(active);
|
||||
$: selectable.setGridColumns(gridCols);
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (focusOnClick) {
|
||||
container.focus();
|
||||
selectable.focus();
|
||||
}
|
||||
|
||||
dispatch('click', e);
|
||||
@@ -109,5 +138,10 @@
|
||||
})}
|
||||
use:registerer
|
||||
>
|
||||
<slot hasFocus={$hasFocus} hasFocusWithin={$hasFocusWithin} focusIndex={$focusIndex} />
|
||||
<slot
|
||||
hasFocus={$hasFocus}
|
||||
hasFocusWithin={$hasFocusWithin}
|
||||
focusIndex={$focusIndex}
|
||||
{selectable}
|
||||
/>
|
||||
</svelte:element>
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
|
||||
async function onJump(index: number) {
|
||||
showcaseIndex = index;
|
||||
console.log(showcaseIndex);
|
||||
}
|
||||
|
||||
// Cycle movies every 5 seconds
|
||||
|
||||
@@ -45,12 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<Container
|
||||
direction="horizontal"
|
||||
handleNavigateOut={{ left: () => true }}
|
||||
let:focusIndex
|
||||
on:enter
|
||||
>
|
||||
<Container direction="horizontal" let:focusIndex on:enter>
|
||||
<div
|
||||
class={classNames(
|
||||
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
}black 5%, black 95%, ${fadeRight ? '' : 'black 100%, '}transparent 100%);`}
|
||||
on:scroll={updateScrollPosition}
|
||||
bind:this={element}
|
||||
handleNavigateOut={{ left: () => true }}
|
||||
let:focusIndex
|
||||
>
|
||||
<slot {focusIndex} />
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
</script>
|
||||
import { type KeyEvent, type NavigateEvent, useRegistrar } from '../../selectable.js';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
<Container
|
||||
on:navigate={({ detail }) => {
|
||||
if (
|
||||
detail.direction === 'left' &&
|
||||
detail.willLeaveContainer &&
|
||||
detail.selectable === detail.options.target
|
||||
) {
|
||||
history.back();
|
||||
const selectable = useRegistrar();
|
||||
|
||||
function handleGoBack({ detail }: CustomEvent<KeyEvent> | CustomEvent<NavigateEvent>) {
|
||||
if ('willLeaveContainer' in detail) {
|
||||
if (detail.direction !== 'left' || !detail.willLeaveContainer) return;
|
||||
detail.preventNavigation();
|
||||
}
|
||||
}}
|
||||
focusOnMount
|
||||
trapFocus
|
||||
class="fixed inset-0 z-20 bg-stone-950 overflow-y-auto"
|
||||
>
|
||||
<slot />
|
||||
|
||||
history.back();
|
||||
}
|
||||
|
||||
function handleGoToTop({ detail }: CustomEvent<KeyEvent> | CustomEvent<NavigateEvent>) {
|
||||
if ('willLeaveContainer' in detail) {
|
||||
// Navigate event
|
||||
if (detail.direction === 'left' && detail.willLeaveContainer) {
|
||||
detail.preventNavigation();
|
||||
get(selectable)?.focus();
|
||||
}
|
||||
} else {
|
||||
// Back event
|
||||
get(selectable)?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="fixed inset-0 z-20 bg-stone-950 overflow-y-auto" trapFocus direction="horizontal">
|
||||
<Container />
|
||||
<Container on:navigate={handleGoToTop} on:back={handleGoToTop} focusOnMount>
|
||||
<slot {handleGoBack} registrar={selectable.registrar} />
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import PageDots from '../HeroShowcase/PageDots.svelte';
|
||||
import SidebarMargin from '../SidebarMargin.svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let urls: Promise<string[]>;
|
||||
|
||||
@@ -47,15 +50,21 @@
|
||||
<Container
|
||||
class="flex-1 flex"
|
||||
on:enter
|
||||
on:navigate={({ detail }) => {
|
||||
on:navigate={(event) => {
|
||||
const detail = event.detail;
|
||||
if (!backgroundHasFocus) return;
|
||||
if (detail.options.direction === 'right') {
|
||||
if (onNext()) detail.preventNavigation();
|
||||
} else if (detail.options.direction === 'left') {
|
||||
if (onPrevious()) detail.preventNavigation();
|
||||
} else if (detail.options.direction === 'up') {
|
||||
Selectable.giveFocus('left', false);
|
||||
if (detail.direction === 'right') {
|
||||
if (onNext()) {
|
||||
detail.preventNavigation();
|
||||
detail.stopPropagation();
|
||||
}
|
||||
} else if (detail.direction === 'left') {
|
||||
if (onPrevious()) {
|
||||
detail.preventNavigation();
|
||||
detail.stopPropagation();
|
||||
}
|
||||
} else {
|
||||
dispatch('navigate', detail);
|
||||
}
|
||||
}}
|
||||
bind:hasFocusWithin
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '../../constants';
|
||||
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
|
||||
import SidebarMargin from '../SidebarMargin.svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { sidebarSelectable } from '../../selectable';
|
||||
|
||||
export let items: Promise<ShowcaseItemProps[]> = Promise.resolve([]);
|
||||
|
||||
@@ -17,6 +19,11 @@
|
||||
urls={items.then((items) => items.map((i) => `${TMDB_IMAGES_ORIGINAL}${i.backdropUrl}`))}
|
||||
bind:index={showcaseIndex}
|
||||
on:enter
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'up') {
|
||||
get(sidebarSelectable)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="h-full flex-1 flex overflow-hidden z-10 relative">
|
||||
{#await items}
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
|
||||
// Handle focus next episode
|
||||
nextJellyfinEpisode.subscribe(($jellyfinEpisode) => {
|
||||
console.log('got next jellyfin episode', $jellyfinEpisode, tmdbEpisode, selectable);
|
||||
|
||||
const isNextEpisode =
|
||||
$jellyfinEpisode?.IndexNumber === tmdbEpisode.episode_number &&
|
||||
$jellyfinEpisode?.ParentIndexNumber === tmdbEpisode.season_number;
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
$: showEpisodeInfo = scrollTop > 140;
|
||||
</script>
|
||||
|
||||
<DetachedPage>
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
<ScrollHelper bind:scrollTop />
|
||||
<div class="relative">
|
||||
<Container
|
||||
@@ -141,10 +141,17 @@
|
||||
{/if}
|
||||
{/await}
|
||||
{#await Promise.all([$jellyfinItem, $sonarrItem]) then [jellyfinItem, sonarrItem]}
|
||||
<Container direction="horizontal" class="flex mt-8" focusOnMount>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex mt-8"
|
||||
focusOnMount
|
||||
on:navigate={handleGoBack}
|
||||
on:back={handleGoBack}
|
||||
{registrar}
|
||||
>
|
||||
{#if $nextJellyfinEpisode}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
$nextJellyfinEpisode?.Id &&
|
||||
playerState.streamJellyfinId($nextJellyfinEpisode.Id)}
|
||||
@@ -156,7 +163,7 @@
|
||||
{/if}
|
||||
{#if sonarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(SonarrMediaMangerModal, { id: sonarrItem.id || -1 })}
|
||||
>
|
||||
@@ -169,7 +176,7 @@
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() => addSeriesToSonarr(Number(id))}
|
||||
inactive={$addSeriesToSonarrFetching}
|
||||
>
|
||||
@@ -178,11 +185,11 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-2">
|
||||
<Button class="mr-4">
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
<Button class="mr-2">
|
||||
<Button class="mr-4">
|
||||
Open In Jellyfin
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
@@ -192,7 +199,7 @@
|
||||
</div>
|
||||
</HeroCarousel>
|
||||
</Container>
|
||||
<Container on:enter={scrollIntoView({ vertical: 64 })} bind:container={episodesSelectable}>
|
||||
<Container on:enter={scrollIntoView({ vertical: 64 })} bind:selectable={episodesSelectable}>
|
||||
<EpisodeCarousel
|
||||
id={Number(id)}
|
||||
tmdbSeries={tmdbSeriesData}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { type Readable, writable, type Writable } from 'svelte/store';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { useNavigate } from 'svelte-navigator';
|
||||
import type { Selectable } from '../../selectable';
|
||||
import { type Selectable, sidebarSelectable } from '../../selectable';
|
||||
|
||||
const navigate = useNavigate();
|
||||
let selectedIndex = 0;
|
||||
@@ -47,7 +47,8 @@
|
||||
)}
|
||||
bind:hasFocusWithin={isNavBarOpen}
|
||||
bind:focusIndex
|
||||
bind:container={selectable}
|
||||
bind:selectable
|
||||
registrar={sidebarSelectable.registrar}
|
||||
>
|
||||
<!-- Background -->
|
||||
<div
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
? item?.UserData?.PlaybackPositionTicks / 10_000_000
|
||||
: undefined
|
||||
};
|
||||
console.log('startTime', playbackInfo.startTime);
|
||||
|
||||
if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
on:timeupdate={() => (progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime)}
|
||||
on:progress={handleProgress}
|
||||
on:loadeddata={() => {
|
||||
console.log('loadedData');
|
||||
video.currentTime = progressTime;
|
||||
videoDidLoad = true;
|
||||
}}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
class={classNames('absolute inset-x-12 bottom-8 transition-opacity flex flex-col', {
|
||||
'opacity-0': !showInterface
|
||||
})}
|
||||
bind:container
|
||||
bind:selectable={container}
|
||||
>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
|
||||
@@ -305,7 +305,6 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('Video destroyed');
|
||||
clearInterval(progressInterval);
|
||||
if (fullscreen) exitFullscreen?.();
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DetachedPage>
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
<div class="min-h-screen flex flex-col py-12 px-20 relative">
|
||||
<HeroCarousel
|
||||
urls={$movieDataP.then(
|
||||
@@ -79,10 +79,17 @@
|
||||
{/if}
|
||||
{/await}
|
||||
{#await Promise.all([$jellyfinItemP, $radarrItemP]) then [jellyfinItem, radarrItem]}
|
||||
<Container direction="horizontal" class="flex mt-8" focusOnMount>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex mt-8"
|
||||
focusOnMount
|
||||
on:navigate={handleGoBack}
|
||||
on:back={handleGoBack}
|
||||
{registrar}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)}
|
||||
>
|
||||
@@ -92,7 +99,7 @@
|
||||
{/if}
|
||||
{#if radarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(ManageMediaModal, { id: radarrItem.id || -1 })}
|
||||
>
|
||||
@@ -105,7 +112,7 @@
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}
|
||||
inactive={$isFetching.handleAddToRadarr}
|
||||
>
|
||||
@@ -114,11 +121,11 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-2">
|
||||
<Button class="mr-4">
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
<Button class="mr-2">
|
||||
<Button class="mr-4">
|
||||
Open In Jellyfin
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
|
||||
@@ -2,15 +2,10 @@ import { derived, get, type Readable, type Writable, writable } from 'svelte/sto
|
||||
import { getScrollParent } from './utils';
|
||||
|
||||
export type Registerer = (htmlElement: HTMLElement) => { destroy: () => void };
|
||||
export type Registrar = (selectable: Selectable) => void;
|
||||
|
||||
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
export type FlowDirection = 'vertical' | 'horizontal';
|
||||
export type NavigationActions = {
|
||||
[direction in Direction]?: (selectable: Selectable) => boolean;
|
||||
} & {
|
||||
back?: (selectable: Selectable) => boolean;
|
||||
enter?: (selectable: Selectable) => boolean;
|
||||
};
|
||||
|
||||
type FocusEventOptions = {
|
||||
setFocusedElement: boolean | HTMLElement;
|
||||
@@ -58,12 +53,30 @@ const createNavigateHandlerOptions = (
|
||||
direction
|
||||
});
|
||||
|
||||
type KeyEventOptions = {
|
||||
propagate: boolean;
|
||||
target: Selectable;
|
||||
};
|
||||
|
||||
export type KeyEvent = {
|
||||
selectable: Selectable;
|
||||
options: KeyEventOptions;
|
||||
stopPropagation: () => void;
|
||||
bubble: () => void;
|
||||
};
|
||||
|
||||
const createKeyEventOptions = (target: Selectable): KeyEventOptions => ({
|
||||
propagate: true,
|
||||
target
|
||||
});
|
||||
|
||||
export type FocusHandler = (selectable: Selectable, options: FocusEventOptions) => void;
|
||||
export type NavigationHandler = (
|
||||
selectable: Selectable,
|
||||
options: NavigateEventOptions,
|
||||
willLeaveContainer: boolean
|
||||
) => void;
|
||||
export type KeyEventHandler = (selectable: Selectable, options: KeyEventOptions) => void;
|
||||
|
||||
export class Selectable {
|
||||
id: symbol;
|
||||
@@ -79,10 +92,12 @@ export class Selectable {
|
||||
};
|
||||
private canFocusEmpty: boolean = true;
|
||||
private trapFocus: boolean = false;
|
||||
private navigationActions: NavigationActions = {};
|
||||
private onNavigate: NavigationHandler = () => {};
|
||||
private isActive: boolean = true;
|
||||
|
||||
private onNavigate: NavigationHandler = () => {};
|
||||
private onFocus: FocusHandler = () => {};
|
||||
private onBack: KeyEventHandler = () => {};
|
||||
private onPlayPause: KeyEventHandler = () => {};
|
||||
private onSelect?: () => void;
|
||||
|
||||
private direction: FlowDirection = 'vertical';
|
||||
@@ -581,19 +596,22 @@ export class Selectable {
|
||||
this.onSelect?.();
|
||||
}
|
||||
|
||||
back(options?: KeyEventOptions) {
|
||||
const _options = options || createKeyEventOptions(this);
|
||||
this.onBack(this, _options);
|
||||
if (this.parent && _options.propagate) this.parent.back(_options);
|
||||
}
|
||||
|
||||
playPause(options?: KeyEventOptions) {
|
||||
const _options = options || createKeyEventOptions(this);
|
||||
this.onPlayPause(this, _options);
|
||||
if (this.parent && _options.propagate) this.parent.playPause(_options);
|
||||
}
|
||||
|
||||
getFocusedChild() {
|
||||
return this.children[get(this.focusIndex)];
|
||||
}
|
||||
|
||||
setNavigationActions(actions: NavigationActions) {
|
||||
this.navigationActions = actions;
|
||||
return this;
|
||||
}
|
||||
|
||||
getNavigationActions(): NavigationActions {
|
||||
return this.navigationActions;
|
||||
}
|
||||
|
||||
setIsActive(isActive: boolean) {
|
||||
this.isActive = isActive;
|
||||
return this;
|
||||
@@ -636,6 +654,16 @@ export class Selectable {
|
||||
this.onNavigate = onNavigate;
|
||||
return this;
|
||||
}
|
||||
|
||||
setOnBack(onBack: KeyEventHandler) {
|
||||
this.onBack = onBack;
|
||||
return this;
|
||||
}
|
||||
|
||||
setOnPlayPause(onPlayPause: KeyEventHandler) {
|
||||
this.onPlayPause = onPlayPause;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
@@ -652,7 +680,6 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const navigationActions = currentlyFocusedObject.getNavigationActions();
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (Selectable.giveFocus('up')) event.preventDefault();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
@@ -662,17 +689,15 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
if (Selectable.giveFocus('right')) event.preventDefault();
|
||||
} else if (event.key === 'Enter') {
|
||||
if (navigationActions.enter && navigationActions.enter(currentlyFocusedObject))
|
||||
event.preventDefault();
|
||||
else {
|
||||
currentlyFocusedObject.select();
|
||||
}
|
||||
} else if (event.key === 'Back' || event.key === 'XF86Back') {
|
||||
currentlyFocusedObject.back();
|
||||
} else if (event.key === 'MediaPlayPause') {
|
||||
currentlyFocusedObject.playPause();
|
||||
}
|
||||
}
|
||||
|
||||
Selectable.focusedObject.subscribe(console.log);
|
||||
Selectable.focusedObject.subscribe(console.debug);
|
||||
|
||||
type Offsets = Partial<
|
||||
Record<
|
||||
@@ -786,3 +811,18 @@ export const scrollIntoView: (...args: [Offsets]) => (e: CustomEvent<EnterEvent>
|
||||
scrollElementIntoView(element, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
export const useRegistrar = (): { registrar: Registrar } & Readable<Selectable> => {
|
||||
const selectable = writable<Selectable>();
|
||||
|
||||
function registrar(_selectable: Selectable) {
|
||||
selectable.set(_selectable);
|
||||
}
|
||||
|
||||
return {
|
||||
registrar,
|
||||
subscribe: selectable.subscribe
|
||||
};
|
||||
};
|
||||
|
||||
export const sidebarSelectable = useRegistrar();
|
||||
|
||||
Reference in New Issue
Block a user