refactor: migrate modals to use StackRouter, removed ComponentStack

temp reworked modals to use stackrouter, modals are now *chefs kiss*, removed compstack completely
This commit is contained in:
Aleksi Lassila
2026-02-09 18:44:44 +02:00
parent 0e08aa2d6f
commit a4f9ab71a6
15 changed files with 82 additions and 444 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import GlobalBackgroundStack from '$lib/components/GlobalBackground/BackgroundStack.svelte';
import OnboardingDialog from '$lib/components/OnboardingDialog/OnboardingDialog.svelte';
import StackRouterPage from '$lib/components/StackRouter/StackRouterPage.svelte';
import { stackRouter } from '$lib/components/StackRouter/stack-router.store';
import { inputMode } from '$lib/stores/input-mode.store';
import { userActivity } from '$lib/stores/user-activity.store';
import axios from 'axios';
@@ -10,7 +10,6 @@
import UpdateDialog from './lib/components/Dialog/UpdateDialog.svelte';
import I18n from './lib/components/Lang/I18n.svelte';
import { createModal } from './lib/components/Modal/modal.store';
import ModalStack from './lib/components/Modal/ModalStack.svelte';
import { createErrorNotification } from './lib/components/Notifications/notification.store';
import NotificationStack from './lib/components/Notifications/NotificationStack.svelte';
import StackRouter from './lib/components/StackRouter/StackRouter.svelte';
@@ -20,7 +19,6 @@
import { localSettings } from './lib/stores/localstorage.store';
import { sessions } from './lib/stores/session.store';
import { isAppInitialized, user } from './lib/stores/user.store';
import { stackRouter } from '$lib/components/StackRouter/stack-router.store';
user.subscribe((s) => {
console.log('user', s);
@@ -100,8 +98,6 @@
</GlobalBackgroundStack>
{/if}
<ModalStack />
<NotificationStack />
{#if import.meta.env.DEV}

View File

@@ -1,274 +0,0 @@
import { _createStoreContext, useSubscribes } from '$lib/utils';
import {
getContext as getSvelteContext,
type ComponentProps,
type ComponentType,
type SvelteComponentTyped
} from 'svelte';
import { derived, get, writable, type Readable, type Writable } from 'svelte/store';
export type CreateComponentPageOptions<T extends SvelteComponentTyped = SvelteComponentTyped> = {
component: ComponentType<T>;
props: ComponentProps<T>;
id?: symbol;
group?: symbol | 'top';
preventScroll?: boolean;
trapFocus?: boolean;
sidebar?: boolean;
};
export type ComponentPage<T extends SvelteComponentTyped = SvelteComponentTyped> = {
id: symbol;
group: symbol;
component: ComponentType<T>;
props: ComponentProps<T>;
preventScroll: boolean;
trapFocus: boolean;
sidebar: boolean | undefined;
context: Record<string, unknown>;
hasFocus: Readable<boolean>;
setContext<U = unknown>(key: string, value: U): void;
hasContext(key: string): boolean;
getContext<U = unknown>(key: string): U;
_setFocus(value: boolean): void;
};
function useComponentPage<T extends SvelteComponentTyped = SvelteComponentTyped>(opts: {
component: ComponentType<T>;
props: ComponentProps<T>;
group?: symbol;
previousContext?: Record<string, unknown>;
preventScroll?: boolean;
trapFocus?: boolean;
sidebar?: boolean;
id?: symbol;
}): ComponentPage<T> {
const id = opts.id ?? Symbol();
const group = opts.group ?? id;
const context = {
...(opts.previousContext ?? {})
};
const hasFocus = writable(false);
function setContext<U = unknown>(key: string, value: U): void {
context[key] = value;
}
function hasContext(key: string): boolean {
return key in context;
}
function getContext<U = unknown>(key: string): U {
return context[key] as U;
}
function _setFocus(value: boolean) {
hasFocus.set(value);
}
return {
id,
group,
component: opts.component,
props: opts.props,
preventScroll: opts.preventScroll ?? false,
trapFocus: opts.trapFocus ?? true,
sidebar: opts.sidebar,
hasFocus,
context,
setContext,
hasContext,
getContext,
_setFocus
};
}
// export class ComponentPage<T extends SvelteComponentTyped = SvelteComponentTyped> {
// id: symbol;
// group: symbol;
// component: ComponentType<T>;
// props: ComponentProps<T>;
// preventScroll: boolean;
// trapFocus: boolean;
// sidebar: boolean | undefined;
// context: Record<string, unknown>;
// hasFocus: Readable<boolean>;
// constructor(opts: {
// component: ComponentType<T>;
// props: ComponentProps<T>;
// hasFocus: Readable<boolean>;
// group?: symbol;
// previousContext?: Record<string, unknown>;
// preventScroll?: boolean;
// trapFocus?: boolean;
// sidebar?: boolean;
// id?: symbol;
// }) {
// this.id = opts.id ?? Symbol();
// this.group = opts.group ?? this.id;
// this.component = opts.component;
// this.props = opts.props;
// this.preventScroll = opts.preventScroll ?? false;
// this.trapFocus = opts.trapFocus ?? true;
// this.sidebar = opts.sidebar;
// this.context = {
// ...(opts.previousContext ?? {})
// };
// this.hasFocus = opts.hasFocus;
// }
// setContext<U = unknown>(key: string, value: U): void {
// this.context[key] = value;
// }
// hasContext(key: string): boolean {
// return key in this.context;
// }
// getContext<U = unknown>(key: string): U {
// return this.context[key] as U;
// }
// }
export type ComponentStackStore = ReturnType<typeof useComponentStack>;
export function useComponentStack(initial?: { context?: Record<string, unknown> }) {
const items = writable<ComponentPage<SvelteComponentTyped>[]>([]);
const top = derived(items, ($items) => $items[$items.length - 1]);
const initialContext: Record<string, unknown> = initial?.context ?? {};
function close(symbol: symbol) {
items.update((prev) => prev.filter((i) => i.id !== symbol));
}
function closeGroup(group: symbol) {
items.update((prev) => prev.filter((i) => i.group !== group));
}
// function set(pages: Array<CreateComponentPageOptions>) {
// const items = pages.map((opts) =>
// useComponentPage({
// ...opts,
// group: opts.group === 'top' ? get(top)?.group : opts.group,
// previousContext: initialContext
// })
// );
// }
function push<P extends Record<string, unknown>>(opts: {
component: ComponentType<SvelteComponentTyped<P>>;
props: P;
id?: symbol;
group?: symbol | 'top';
preventScroll?: boolean;
trapFocus?: boolean;
sidebar?: boolean;
}) {
const previousContext = get(top)?.context ?? initialContext;
const item = useComponentPage({
...opts,
previousContext,
group: opts.group === 'top' ? get(top)?.group : opts.group
});
// _setTopFocus(false);
items.update((prev) => [...prev, item]);
item._setFocus(true);
return item.id;
}
function reset() {
items.set([]);
}
function closeTopmost() {
const t = get(top);
if (t) {
close(t.id);
}
}
// function _setTopFocus(value: boolean) {
// get(top)?._setFocus(value);
// }
return {
_items: items,
_initialContext: initialContext,
subscribe: items.subscribe,
top: {
subscribe: top.subscribe
},
push,
close,
closeGroup,
closeTopmost,
pop: closeTopmost,
reset
};
}
// export const useComponentStack: typeof _useComponentStack = (...args) => {
// const componentStack = _useComponentStack(...args);
// setSvelteContext('component-stack', {
// componentStack,
// get page() {
// return get(componentStack._items)[0];
// }
// });
// return componentStack;
// };
export const componentStackContext = _createStoreContext(
'component-stack',
(componentStack: ComponentStackStore = useComponentStack(), page?: ComponentPage) => {
const existing = getSvelteContext<ComponentStackStore>('component-stack');
if (existing) {
componentStack._initialContext = {
...existing._initialContext,
...componentStack._initialContext
};
}
return {
componentStack,
page
};
},
{
required: true
}
);
export function setContext<T>(key: string, value: T) {
const { componentStack, page } = componentStackContext.getContext();
if (!page) {
componentStack._initialContext[key] = value;
return;
}
return page.setContext<T>(key, value);
}
export function getContext<T>(key: string): T {
const { componentStack, page } = componentStackContext.getContext();
if (!page) {
return componentStack._initialContext[key] as T;
}
return page.getContext<T>(key);
}
export function hasContext(key: string): boolean {
const { componentStack, page } = componentStackContext.getContext();
if (!page) {
return key in componentStack._initialContext;
}
return page.hasContext(key);
}

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import Container from '../Container.svelte';
import Button from '../Button/Button.svelte';
import { modalStack } from '../Modal/modal.store';
import Dialog from './Dialog.svelte';
import { useComponentStack } from '../StackRouter/stack-router.store';
type ActionFn = (() => Promise<any>) | (() => any);
export let modalId: symbol;
const { close } = useComponentStack();
export let header: string;
export let body: string;
@@ -21,10 +20,10 @@
fetching = true;
result.then(() => {
fetching = false;
modalStack.close(modalId);
close();
});
} else {
modalStack.close(modalId);
close();
}
}
</script>

View File

@@ -8,10 +8,9 @@
import Button from '../Button/Button.svelte';
import Container from '../Container.svelte';
import IconToggle from '../IconToggle.svelte';
import { createModal, modalStack } from '../Modal/modal.store';
import ProfileIcon from '../ProfileIcon.svelte';
import SelectField from '../SelectField.svelte';
import { navigate } from '../StackRouter/stack-router.store';
import { navigate, useComponentStack } from '../StackRouter/stack-router.store';
import { useTabs } from '../Tab/Tab';
import Tab from '../Tab/Tab.svelte';
import TextField from '../TextField.svelte';
@@ -26,7 +25,7 @@
ProfilePictures
}
export let modalId: symbol;
const { close, push } = useComponentStack();
export let user: ReiverrUser | undefined = undefined;
export let onComplete: () => void = () => {};
@@ -130,7 +129,7 @@
if (error) {
errorMessage = error;
} else {
modalStack.closeTopmost();
close();
onComplete();
}
}
@@ -149,7 +148,7 @@
if (error) {
errorMessage = error;
} else {
modalStack.closeTopmost();
close();
onComplete();
}
}
@@ -164,7 +163,7 @@
if (error) {
errorMessage = error;
} else {
modalStack.close(modalId);
close();
if (self) {
sessions.removeSession();
navigate('/');
@@ -230,10 +229,13 @@
type="primary-dark"
icon={Trash}
on:clickOrSelect={() =>
createModal(ConfirmDialog, {
header: 'Delete Account',
body: 'Are you sure you want to delete your account?',
confirm: handleDeleteAccount
push({
component: ConfirmDialog,
props: {
header: 'Delete Account',
body: 'Are you sure you want to delete your account?',
confirm: handleDeleteAccount
}
})}
>
Delete Account

View File

@@ -2,7 +2,9 @@
import Dialog from '../Dialog/Dialog.svelte';
import type { JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
import SelectItem from '../SelectItem.svelte';
import { modalStack } from '../Modal/modal.store';
import { useComponentStack } from '../StackRouter/stack-router.store';
const { close } = useComponentStack();
// TODO: Add labels to the options
export let title: string = 'Select';
@@ -11,11 +13,9 @@
export let selectedOption: string | undefined = undefined;
export let handleSelectOption: (option: string) => void;
export let modalId: symbol;
function handleSelect(option: string) {
handleSelectOption(option);
modalStack.close(modalId);
close();
}
</script>

View File

@@ -1,9 +1,11 @@
<script>
import Dialog from '../Dialog/Dialog.svelte';
import TmdbIntegrationConnect from './TmdbIntegrationConnect.svelte';
import { modalStack } from '../Modal/modal.store';
import { useComponentStack } from '../StackRouter/stack-router.store';
const { close } = useComponentStack();
</script>
<Dialog>
<TmdbIntegrationConnect on:connected={() => modalStack.closeTopmost()} />
<TmdbIntegrationConnect on:connected={() => close()} />
</Dialog>

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import { modalStack, modalStackTop } from './modal.store';
import { onDestroy } from 'svelte';
import classNames from 'classnames';
// function handleShortcuts(event: KeyboardEvent) {
// const top = $modalStackTop;
// if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) {
// modalStack.close(top.id);
// }
// }
onDestroy(() => {
modalStack.reset();
});
</script>
<!--<svelte:window on:keydown={handleShortcuts} />-->
<svelte:head>
{#if $modalStackTop}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
{#each $modalStack as modal (modal.id)}
{@const hidden = $modalStackTop?.group === modal.group && $modalStackTop?.id !== modal.id}
<div class="fixed inset-0 z-30">
<svelte:component
this={modal.component}
{...modal.props}
modalId={modal.id}
{hidden}
groupId={modal.group}
{modal}
/>
</div>
{/each}

View File

@@ -1,82 +1,14 @@
import { _createStoreContext, createStoreContext } from '$lib/utils';
import type { ComponentType, SvelteComponentTyped } from 'svelte';
import { derived, get, writable } from 'svelte/store';
import { useComponentStack } from '../ComponentStack/component-stack.store';
import { stackRouter } from '../StackRouter/stack-router.store';
type ModalItem = {
id: symbol;
group: symbol;
component: ComponentType;
props: Record<string, any>;
};
function createModalStack() {
const items = writable<ModalItem[]>([]);
const top = derived(items, ($items) => $items[$items.length - 1]);
function close(symbol: symbol) {
items.update((prev) => prev.filter((i) => i.id !== symbol));
}
function closeGroup(group: symbol) {
items.update((prev) => prev.filter((i) => i.group !== group));
}
function create<P extends Record<string, any>>(
component: ComponentType<SvelteComponentTyped<P>>,
props: Omit<P, 'modal' | 'groupId' | 'modalId' | 'hidden'>,
group: symbol | undefined = undefined
) {
const id = Symbol();
const item = { id, component, props, group: group || id };
items.update((prev) => [...prev, item]);
return id;
}
function reset() {
items.set([]);
}
function closeTopmost() {
const t = get(top);
if (t) {
close(t.id);
}
}
return {
subscribe: items.subscribe,
top: {
subscribe: top.subscribe
},
create,
close,
closeGroup,
closeTopmost,
reset
};
}
export const modalStack = useComponentStack();
export const modalStackTop = modalStack.top;
export const createModal = <T extends Record<string, unknown>>(
component: ComponentType<SvelteComponentTyped<T>>,
props: Omit<T, 'isHidden' | 'isTop' | 'page'>,
group?: symbol
props: T,
group?: symbol | 'top'
) => {
const id = Symbol();
return modalStack.push({
id,
return stackRouter.push({
component,
// @ts-ignore
props: {
...props,
modalId: id,
groupId: group
}
props,
group
});
};
// export const modalStackContext = _createStoreContext('modal-stack', () => modalStack, {
// required: true
// });

View File

@@ -6,8 +6,6 @@
import { navigate } from '../StackRouter/stack-router.store';
import { user } from '$lib/stores/user.store';
export let modalId: symbol;
async function finalizeSetup() {
await user.updateUser((prev) => ({
...prev,

View File

@@ -7,6 +7,16 @@
<svelte:window on:popstate={stack.handlePopState} />
<svelte:head>
{#if $stack[$stack.length - 1]?.preventScroll}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
{#each $stack as page, index (page.id)}
{@const nextPage = $stack[index + 1]}
{@const lastPage = $stack[$stack.length - 1]}

View File

@@ -324,6 +324,17 @@ export function useStackRouter({
}
};
const push = <TProps extends Record<string, unknown> = Record<string, unknown>>(
opts: CreateCompStackPage<TProps>
) => {
const currentGroup = groups[historyState.end];
if (!currentGroup) {
throw new Error('No group to push to');
}
currentGroup.push(opts);
updateVisibleStack();
};
const back = () => {
if (historyState.end === 0) return;
@@ -333,6 +344,7 @@ export function useStackRouter({
return {
subscribe: visibleStack.subscribe,
navigate,
push,
back,
handlePopState
};

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { AudioTrack, SubtitleInfo, Subtitles } from './VideoPlayer';
import Button from '../Button/Button.svelte';
import { modalStack } from '../Modal/modal.store.js';
import { scrollIntoView } from '$lib/selectable';
import { ChatBubble, Check, TextAlignLeft } from 'radix-icons-svelte';
import Dialog from '../Dialog/Dialog.svelte';
import { ChatBubble, Check } from 'radix-icons-svelte';
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
import Button from '../Button/Button.svelte';
import Dialog from '../Dialog/Dialog.svelte';
import { useComponentStack } from '../StackRouter/stack-router.store';
import type { AudioTrack } from './VideoPlayer';
export let modalId: symbol;
const { close } = useComponentStack();
export let selectedAudioStreamIndex: number;
export let audioTracks: AudioTrack[];
@@ -15,7 +15,7 @@
export let onClose = () => {};
</script>
<Dialog {modalId}>
<Dialog>
<div>
<h1 class="h3 mb-4 flex items-center space-x-4">
<span>Audio</span>
@@ -25,7 +25,7 @@
{#each audioTracks || [] as track}
<Button
on:clickOrSelect={() => {
modalStack.close(modalId);
close();
onClose();
if (track.index !== selectedAudioStreamIndex) selectAudioStream(track.index);
}}

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import Button from '../Button/Button.svelte';
import { modalStack } from '../Modal/modal.store.js';
import { useComponentStack } from '../StackRouter/stack-router.store';
import { scrollIntoView } from '$lib/selectable';
import { Check, TextAlignLeft } from 'radix-icons-svelte';
import Dialog from '../Dialog/Dialog.svelte';
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
import type { SubtitlesDto as Subtitles } from '$lib/apis/reiverr/reiverr.openapi';
export let modalId: symbol;
const { close } = useComponentStack();
export let subtitles: Subtitles[];
export let selectedSubtitles: Subtitles | undefined;
export let selectSubtitles: (subtitles?: Subtitles) => void;
</script>
<Dialog {modalId}>
<Dialog>
<h1 class="h3 mb-4 flex items-center space-x-4">
<span>Subtitles</span>
<TextAlignLeft size={32} />
@@ -22,7 +22,7 @@
<div class="flex flex-col space-y-4 overflow-y-auto scrollbar-hide flex-1 px-4 -mx-4 py-2 -my-2">
<Button
on:clickOrSelect={() => {
modalStack.close(modalId);
close();
selectSubtitles(undefined);
}}
class="relative"
@@ -38,7 +38,7 @@
{#each subtitles as subtitles}
<Button
on:clickOrSelect={() => {
modalStack.close(modalId);
close();
selectSubtitles(subtitles);
}}
on:enter={scrollIntoView({ vertical: 64 })}

View File

@@ -3,16 +3,17 @@
import classNames from 'classnames';
import { Pause, TextAlignLeft } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
import { useRegistrar, type Selectable } from '../../selectable';
import { useRegistrar } from '../../selectable';
import Container from '../Container.svelte';
import IconButton from '../IconButton.svelte';
import { modalStack } from '../Modal/modal.store';
import Spinner from '../Utils/Spinner.svelte';
import ProgressBar from './ProgressBar.svelte';
import { get } from 'svelte/store';
import { createModal } from '../Modal/modal.store';
import SelectSubtitlesModal from './SelectSubtitlesModal.svelte';
import VideoElement from './VideoElement.svelte';
import type { SubtitleInfo, VideoPlayerProps, VideoSource } from './VideoPlayer';
import { get } from 'svelte/store';
export let load: VideoPlayerProps['load'];
export let paused: VideoPlayerProps['paused'];
@@ -227,7 +228,7 @@
<IconButton
on:clickOrSelect={() => {
// video.pause();
modalStack.create(SelectSubtitlesModal, {
createModal(SelectSubtitlesModal, {
subtitles: subtitleInfo.availableSubtitles,
selectedSubtitles: subtitleInfo.subtitles,
selectSubtitles
@@ -239,10 +240,13 @@
{/if}
<!-- <IconButton
on:clickOrSelect={() => {
modalStack.create(SelectAudioModal, {
selectedAudioStreamIndex: playbackInfo?.audioStreamIndex || -1,
audioTracks: playbackInfo?.audioTracks || [],
selectAudioStream
push({
component: SelectAudioModal,
props: {
selectedAudioStreamIndex: playbackInfo?.audioStreamIndex || -1,
audioTracks: playbackInfo?.audioTracks || [],
selectAudioStream
}
});
}}
>

View File

@@ -11,12 +11,12 @@
import { get, writable } from 'svelte/store';
import Button from '../../components/Button/Button.svelte';
import Dialog from '../../components/Dialog/Dialog.svelte';
import { modalStack } from '../../components/Modal/modal.store';
import { useComponentStack } from '../../components/StackRouter/stack-router.store';
import TextField from '../../components/TextField.svelte';
import Toggle from '../../components/Toggle.svelte';
import { reiverrApi, user } from '../../stores/user.store';
export let modalId: symbol;
const { close } = useComponentStack();
export let sourceId: string;
@@ -67,7 +67,7 @@
'Media source updated',
`${updateResponse.mediaSource.name} has been enabled`
);
modalStack.close(modalId);
close();
} else {
createErrorNotification(
'Incomplete configuration',
@@ -99,7 +99,7 @@
async function handleRemovePlugin() {
await sources.deleteSource(sourceId);
modalStack.close(modalId);
close();
}
</script>