diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c040481..ba2e1da 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -13,7 +13,6 @@ 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'; import StackRouter from './lib/components/StackRouter/StackRouter.svelte'; import SplashScreen from './lib/pages/SplashScreen.svelte'; import UsersPage from './lib/pages/UsersPage.svelte'; @@ -21,6 +20,7 @@ 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); @@ -91,9 +91,9 @@ {:else} {#if $user === null} - - - + + + {:else} {/if} diff --git a/frontend/src/lib/components/Card/TmdbCard.svelte b/frontend/src/lib/components/Card/TmdbCard.svelte index 6fdd7a2..20e56ef 100644 --- a/frontend/src/lib/components/Card/TmdbCard.svelte +++ b/frontend/src/lib/components/Card/TmdbCard.svelte @@ -2,7 +2,7 @@ import type { TmdbMovie, TmdbSeries } from '../../apis/tmdb/tmdb-api'; import { TMDB_POSTER_SMALL } from '../../constants'; import type { TitleType } from '../../types'; - import { navigate } from '../StackRouter/StackRouter'; + import { navigate } from '../StackRouter/stack-router.store'; import Card from './Card.svelte'; export let item: diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte index 9c93f0b..9cbb036 100644 --- a/frontend/src/lib/components/CardGrid.svelte +++ b/frontend/src/lib/components/CardGrid.svelte @@ -26,6 +26,7 @@ class={classNames('grid gap-x-8 gap-y-8', $$restProps.class)} style={`grid-template-columns: repeat(${cols}, minmax(0, 1fr));`} on:mount + on:back > diff --git a/frontend/src/lib/components/Collection/CollectionCard.svelte b/frontend/src/lib/components/Collection/CollectionCard.svelte index e52c09c..8af4b29 100644 --- a/frontend/src/lib/components/Collection/CollectionCard.svelte +++ b/frontend/src/lib/components/Collection/CollectionCard.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ComponentStack/ComponentStack.svelte b/frontend/src/lib/components/ComponentStack/ComponentStack.svelte new file mode 100644 index 0000000..f474a08 --- /dev/null +++ b/frontend/src/lib/components/ComponentStack/ComponentStack.svelte @@ -0,0 +1,22 @@ + + +{#each $items as item, i (item.id)} + +{/each} diff --git a/frontend/src/lib/components/ComponentStack/ComponentStackContainer.svelte b/frontend/src/lib/components/ComponentStack/ComponentStackContainer.svelte index 092902f..f8fe17e 100644 --- a/frontend/src/lib/components/ComponentStack/ComponentStackContainer.svelte +++ b/frontend/src/lib/components/ComponentStack/ComponentStackContainer.svelte @@ -15,7 +15,7 @@ $: hidden = $top?.group !== component?.group && $top?.id !== component?.id; export let trapFocus = false; - export let hideSidebar = false; + export let sidebar: boolean | undefined = undefined; export let preventScroll = false; setContext('component-stack-index', componentStackIndex + 1); @@ -38,7 +38,7 @@ class={classNames( 'fixed inset-0 overflow-x-hidden overflow-y-auto scrollbar-hide', { - 'z-[21]': hideSidebar, + 'z-[21]': sidebar === false, 'opacity-0': hidden }, $$restProps.class diff --git a/frontend/src/lib/components/ComponentStack/ComponentStackContextProvider.svelte b/frontend/src/lib/components/ComponentStack/ComponentStackContextProvider.svelte new file mode 100644 index 0000000..f93c5dc --- /dev/null +++ b/frontend/src/lib/components/ComponentStack/ComponentStackContextProvider.svelte @@ -0,0 +1,50 @@ + + + + {#if item.preventScroll && isTop} + + {/if} + + + + + diff --git a/frontend/src/lib/components/ComponentStack/ComponentStackProvider.svelte b/frontend/src/lib/components/ComponentStack/ComponentStackProvider.svelte deleted file mode 100644 index bd1cefd..0000000 --- a/frontend/src/lib/components/ComponentStack/ComponentStackProvider.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -
- {#if $bottom} - - {/if} -
diff --git a/frontend/src/lib/components/ComponentStack/component-stack.store.ts b/frontend/src/lib/components/ComponentStack/component-stack.store.ts new file mode 100644 index 0000000..d27a03b --- /dev/null +++ b/frontend/src/lib/components/ComponentStack/component-stack.store.ts @@ -0,0 +1,274 @@ +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 = { + component: ComponentType; + props: ComponentProps; + id?: symbol; + group?: symbol | 'top'; + preventScroll?: boolean; + trapFocus?: boolean; + sidebar?: boolean; +}; + +export type ComponentPage = { + id: symbol; + group: symbol; + component: ComponentType; + props: ComponentProps; + preventScroll: boolean; + trapFocus: boolean; + sidebar: boolean | undefined; + context: Record; + hasFocus: Readable; + setContext(key: string, value: U): void; + hasContext(key: string): boolean; + getContext(key: string): U; + _setFocus(value: boolean): void; +}; + +function useComponentPage(opts: { + component: ComponentType; + props: ComponentProps; + group?: symbol; + previousContext?: Record; + preventScroll?: boolean; + trapFocus?: boolean; + sidebar?: boolean; + id?: symbol; +}): ComponentPage { + const id = opts.id ?? Symbol(); + const group = opts.group ?? id; + const context = { + ...(opts.previousContext ?? {}) + }; + const hasFocus = writable(false); + + function setContext(key: string, value: U): void { + context[key] = value; + } + + function hasContext(key: string): boolean { + return key in context; + } + + function getContext(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 { +// id: symbol; +// group: symbol; +// component: ComponentType; +// props: ComponentProps; +// preventScroll: boolean; +// trapFocus: boolean; +// sidebar: boolean | undefined; +// context: Record; +// hasFocus: Readable; + +// constructor(opts: { +// component: ComponentType; +// props: ComponentProps; +// hasFocus: Readable; +// group?: symbol; +// previousContext?: Record; +// 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(key: string, value: U): void { +// this.context[key] = value; +// } + +// hasContext(key: string): boolean { +// return key in this.context; +// } + +// getContext(key: string): U { +// return this.context[key] as U; +// } +// } + +export type ComponentStackStore = ReturnType; + +export function useComponentStack(initial?: { context?: Record }) { + const items = writable[]>([]); + const top = derived(items, ($items) => $items[$items.length - 1]); + const initialContext: Record = 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) { + // const items = pages.map((opts) => + // useComponentPage({ + // ...opts, + // group: opts.group === 'top' ? get(top)?.group : opts.group, + // previousContext: initialContext + // }) + // ); + // } + + function push

>(opts: { + component: ComponentType>; + 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('component-stack'); + + if (existing) { + componentStack._initialContext = { + ...existing._initialContext, + ...componentStack._initialContext + }; + } + + return { + componentStack, + page + }; + }, + { + required: true + } +); + +export function setContext(key: string, value: T) { + const { componentStack, page } = componentStackContext.getContext(); + + if (!page) { + componentStack._initialContext[key] = value; + return; + } + + return page.setContext(key, value); +} + +export function getContext(key: string): T { + const { componentStack, page } = componentStackContext.getContext(); + + if (!page) { + return componentStack._initialContext[key] as T; + } + + return page.getContext(key); +} + +export function hasContext(key: string): boolean { + const { componentStack, page } = componentStackContext.getContext(); + + if (!page) { + return key in componentStack._initialContext; + } + + return page.hasContext(key); +} diff --git a/frontend/src/lib/components/ComponentStack/index.ts b/frontend/src/lib/components/ComponentStack/index.ts deleted file mode 100644 index b50aed2..0000000 --- a/frontend/src/lib/components/ComponentStack/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ComponentStackProvider.svelte'; -export * from './ComponentStackContainer.svelte'; diff --git a/frontend/src/lib/components/Dialog/AddUserDialog.svelte b/frontend/src/lib/components/Dialog/AddUserDialog.svelte index 9119c3e..cd686de 100644 --- a/frontend/src/lib/components/Dialog/AddUserDialog.svelte +++ b/frontend/src/lib/components/Dialog/AddUserDialog.svelte @@ -1,7 +1,7 @@

diff --git a/frontend/src/lib/components/Dialog/CreateOrEditProfileModal.svelte b/frontend/src/lib/components/Dialog/CreateOrEditProfileModal.svelte index 1e09fff..e10afd0 100644 --- a/frontend/src/lib/components/Dialog/CreateOrEditProfileModal.svelte +++ b/frontend/src/lib/components/Dialog/CreateOrEditProfileModal.svelte @@ -11,7 +11,7 @@ import { createModal, modalStack } from '../Modal/modal.store'; import ProfileIcon from '../ProfileIcon.svelte'; import SelectField from '../SelectField.svelte'; - import { navigate } from '../StackRouter/StackRouter'; + import { navigate } from '../StackRouter/stack-router.store'; import { useTabs } from '../Tab/Tab'; import Tab from '../Tab/Tab.svelte'; import TextField from '../TextField.svelte'; diff --git a/frontend/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte b/frontend/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte index e6a3f70..f16b9a7 100644 --- a/frontend/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte +++ b/frontend/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte @@ -8,7 +8,7 @@ import type { MediaSourceDto, ViewProviderDto } from '../../apis/reiverr/reiverr.openapi'; import { mediaSourceContext } from '../../pages/TitlePages/media-source.context'; import Modal from '../Modal/Modal.svelte'; - import ComponentStackProvider from '../ComponentStack/ComponentStackProvider.svelte'; + import ComponentStack from '../ComponentStack/ComponentStack.svelte'; type ViewItem = { label: string; @@ -110,7 +110,7 @@ {/each} {/await} {:else} - + {/if} diff --git a/frontend/src/lib/components/GlobalBackground/BackgroundStack.ts b/frontend/src/lib/components/GlobalBackground/BackgroundStack.ts index fd14596..4e89fc4 100644 --- a/frontend/src/lib/components/GlobalBackground/BackgroundStack.ts +++ b/frontend/src/lib/components/GlobalBackground/BackgroundStack.ts @@ -13,7 +13,7 @@ import YoutubeVideo from '../VideoPlayer/YoutubeVideo.svelte'; import TmdbVideoPlayer from '../VideoPlayer/TmdbVideoPlayer.svelte'; import type { MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi'; -const BACKGROUND_CONTEXT_KEY = Symbol('BACKGROUND_CONTEXT_KEY'); +export const BACKGROUND_CONTEXT_KEY = Symbol('BACKGROUND_CONTEXT_KEY'); export type Background = { backdropUri: string; diff --git a/frontend/src/lib/components/HeroShowcase/TmdbMoviesHeroShowcase.svelte b/frontend/src/lib/components/HeroShowcase/TmdbMoviesHeroShowcase.svelte index 31a00c0..76696c1 100644 --- a/frontend/src/lib/components/HeroShowcase/TmdbMoviesHeroShowcase.svelte +++ b/frontend/src/lib/components/HeroShowcase/TmdbMoviesHeroShowcase.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/Modal/modal.store.ts b/frontend/src/lib/components/Modal/modal.store.ts index e52717f..fcd5daa 100644 --- a/frontend/src/lib/components/Modal/modal.store.ts +++ b/frontend/src/lib/components/Modal/modal.store.ts @@ -1,5 +1,7 @@ +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'; type ModalItem = { id: symbol; @@ -8,7 +10,7 @@ type ModalItem = { props: Record; }; -export function createModalStack() { +function createModalStack() { const items = writable([]); const top = derived(items, ($items) => $items[$items.length - 1]); @@ -55,6 +57,26 @@ export function createModalStack() { }; } -export const modalStack = createModalStack(); +export const modalStack = useComponentStack(); export const modalStackTop = modalStack.top; -export const createModal = modalStack.create; +export const createModal = >( + component: ComponentType>, + props: Omit, + group?: symbol +) => { + const id = Symbol(); + return modalStack.push({ + id, + component, + // @ts-ignore + props: { + ...props, + modalId: id, + groupId: group + } + }); +}; + +// export const modalStackContext = _createStoreContext('modal-stack', () => modalStack, { +// required: true +// }); diff --git a/frontend/src/lib/components/OnboardingDialog/OnboardingDialog.svelte b/frontend/src/lib/components/OnboardingDialog/OnboardingDialog.svelte index 5411501..4c7cdf6 100644 --- a/frontend/src/lib/components/OnboardingDialog/OnboardingDialog.svelte +++ b/frontend/src/lib/components/OnboardingDialog/OnboardingDialog.svelte @@ -3,7 +3,7 @@ import Button from '../Button/Button.svelte'; import Container from '../Container.svelte'; import Dialog from '../Dialog/Dialog.svelte'; - import { navigate } from '../StackRouter/StackRouter'; + import { navigate } from '../StackRouter/stack-router.store'; import { user } from '$lib/stores/user.store'; export let modalId: symbol; diff --git a/frontend/src/lib/components/PersonCard/PersonCard.svelte b/frontend/src/lib/components/PersonCard/PersonCard.svelte index ddee5e7..5cba53c 100644 --- a/frontend/src/lib/components/PersonCard/PersonCard.svelte +++ b/frontend/src/lib/components/PersonCard/PersonCard.svelte @@ -3,7 +3,7 @@ import Container from '../Container.svelte'; import AnimateScale from '../AnimateScale.svelte'; import type { Readable } from 'svelte/store'; - import { navigate } from '../StackRouter/StackRouter'; + import { navigate } from '../StackRouter/stack-router.store'; export let tmdbId: number; // export let type: TitleType = 'person'; diff --git a/frontend/src/lib/components/Sidebar/Sidebar.svelte b/frontend/src/lib/components/Sidebar/Sidebar.svelte index 82ef491..ebc39a0 100644 --- a/frontend/src/lib/components/Sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar/Sidebar.svelte @@ -16,7 +16,7 @@ import { sessions } from '../../stores/session.store'; import { user } from '../../stores/user.store'; import Container from '../Container.svelte'; - import { navigate, stackRouter } from '../StackRouter/StackRouter'; + import { navigate, stackRouter } from '../StackRouter/stack-router.store'; import { useTabs } from '../Tab/Tab'; import { sidebarRegistrar, unfocusSidebar } from './sidebar'; @@ -70,21 +70,24 @@ onMount(() => { // Set active tab based on bottommost page stackRouter.subscribe((r) => { - const bottomPage = r[0]; - if (bottomPage) { - activeIndex = - { - '/users': Tabs.Users, - '/': Tabs.Series, - '/series': Tabs.Series, - '/movies': Tabs.Movies, - '/library': Tabs.Library, - '/search': Tabs.Search, - '/manage': Tabs.Manage - }[bottomPage.route.path] ?? -1; - selectable.focusIndex.set(activeIndex); - selectedIndex = activeIndex; - } + const initialUri = window.location.pathname; + const uri = initialUri === '/' ? '/' : `/${initialUri.split('/')[1]}`; + + // const bottomPage = r[0]; + // if (bottomPage) { + activeIndex = + { + '/users': Tabs.Users, + '/': Tabs.Series, + '/series': Tabs.Series, + '/movies': Tabs.Movies, + '/library': Tabs.Library, + '/search': Tabs.Search, + '/manage': Tabs.Manage + }[uri] ?? -1; + selectable.focusIndex.set(activeIndex); + selectedIndex = activeIndex; + // } }); }); diff --git a/frontend/src/lib/components/StackRouter/StackRouter.svelte b/frontend/src/lib/components/StackRouter/StackRouter.svelte index 995ec9d..c97b88e 100644 --- a/frontend/src/lib/components/StackRouter/StackRouter.svelte +++ b/frontend/src/lib/components/StackRouter/StackRouter.svelte @@ -1,18 +1,15 @@ {#each $stack as page, index (page.id)} - {@const topmost = index === $stack.length - 1} - + {@const nextPage = $stack[index + 1]} + {@const lastPage = $stack[$stack.length - 1]} + {@const isHidden = nextPage?.group === page.group || lastPage?.pages !== page.pages} + {/each} diff --git a/frontend/src/lib/components/StackRouter/StackRouter.ts b/frontend/src/lib/components/StackRouter/StackRouter.ts deleted file mode 100644 index bc2664b..0000000 --- a/frontend/src/lib/components/StackRouter/StackRouter.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { getContext, hasContext, setContext, type ComponentType } from 'svelte'; -import { derived, get, writable } from 'svelte/store'; -import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte'; -import ManagePage from '../../pages/ManagePage/ManagePage.svelte'; -import MoviesHomePage from '../../pages/MoviesHomePage.svelte'; -import PageNotFound from '../../pages/PageNotFound.svelte'; -import PersonPage from '../../pages/PersonPage.svelte'; -import SearchPage from '../../pages/SearchPage.svelte'; -import SeriesHomePage from '../../pages/SeriesHomePage.svelte'; -import EpisodePage from '../../pages/TitlePages/EpisodePage.svelte'; -import MoviePage from '../../pages/TitlePages/MoviePage/MoviePage.svelte'; -import SeriesPage from '../../pages/TitlePages/SeriesPage/SeriesPage.svelte'; -import UiComponents from '../../pages/UIComponents.svelte'; -import UsersPage from '../../pages/UsersPage.svelte'; -import { modalStack } from '../Modal/modal.store'; -import NetworkPage from '$lib/pages/CollectionPages/NetworkPage.svelte'; -import ListPage from '$lib/pages/CollectionPages/ListPage.svelte'; -import CompanyPage from '$lib/pages/CollectionPages/CompanyPage.svelte'; -import { useRegistrar } from '$lib/selectable'; - -interface Page { - id: symbol; - route: Route; - props?: Record; -} - -interface Route { - path: string; - component: ComponentType; - default?: boolean; - // When root is navigated to, stcak is cleared - root?: boolean; - // Parent route, that is always rendered under this route, - // possibly sharing props that are a subset of the child's props. - // Child's props are also passed to these. - parent?: Route; - sidebar?: boolean; -} - -export type StackRouterStore = ReturnType; - -type Indexes = { id: string; bottom: number; top: number }; - -export function useStackRouter({ - routes, - notFound, - maxDepth -}: { - routes: Route[]; - notFound: Route; - maxDepth?: number; -}) { - const { initialPages, initialIndexes } = getInitialValues(); - const pageStack = writable<{ pages: Page[]; indexes: Indexes }>({ - pages: initialPages, - indexes: initialIndexes - }); - const visibleStack = derived(pageStack, ($stack) => { - return $stack.pages.slice( - maxDepth - ? Math.max($stack.indexes.bottom, $stack.indexes.top - maxDepth + 1) - : $stack.indexes.bottom, - $stack.indexes.top + 1 - ); - }); - - function getInitialValues() { - const initialUrl = window.location.pathname; - const initialPages = [routeStringToRoute(initialUrl)]; - - let initialRouteParent = initialPages[0]?.route.parent; - const initialProps = initialPages[0]?.props; - while (initialRouteParent) { - const newPage: Page = { - id: Symbol(), - route: initialRouteParent, - props: initialProps - }; - initialPages.unshift(newPage); - initialRouteParent = initialRouteParent.parent; - } - const initialIndexes: Indexes = { - id: Math.random().toString(36).slice(2), - bottom: 0, - top: initialPages.length - 1 - }; - - history.replaceState(initialIndexes, '', initialUrl); - return { - initialPages, - initialIndexes - }; - } - - function routeStringToRoute(routeString: string): Page { - for (const route of routes) { - const targetParts = routeString.split('/'); - const routeParts = route.path.split('/'); - const params: Record = {}; - - if (targetParts.length !== routeParts.length) continue; - let i = 0; - while (i < targetParts.length) { - if (routeParts[i]?.startsWith(':')) { - const paramName = routeParts[i]?.slice(1); - if (paramName) { - // @ts-ignore - params[paramName] = targetParts[i]; - } - } else if (routeParts[i] !== targetParts[i]) break; - i++; - } - if (i === targetParts.length) { - return { props: params, id: Symbol(), route }; - } - } - - return { - id: Symbol(), - route: notFound - }; - } - - const navigate = ( - routeString: string, - options: { replaceStack?: boolean; refresh?: boolean } = {} - ) => { - if (options.refresh) { - location.assign(routeString); - return; - } - const page: Page = routeStringToRoute(routeString); - const replaceStack = page.route.root || options.replaceStack || false; - - pageStack.update((prev) => { - let pages = prev.pages; - let idxs = prev.indexes; - - if (replaceStack) pages = [page]; - else pages.splice(idxs.top + 1, Infinity, page); - - if (replaceStack) { - const indexes: Indexes = { - id: Math.random().toString(36).slice(2), - bottom: pages.length - 1, - top: pages.length - 1 - }; - history.pushState(indexes, '', routeString); - idxs = indexes; - } else { - const indexes: Indexes = { id: idxs.id, bottom: idxs.bottom, top: idxs.top + 1 }; - history.pushState(indexes, '', routeString); - idxs = indexes; - } - - return { pages, indexes: idxs }; - }); - }; - - const handlePopState = (e: PopStateEvent) => { - const newIndexes: Indexes = e.state; - const prevIndexes = get(pageStack); - - modalStack.reset(); - - if (prevIndexes.indexes.id === newIndexes.id) { - pageStack.update((p) => ({ ...p, indexes: newIndexes })); - } else { - const initialValues = getInitialValues(); - pageStack.set({ indexes: initialValues.initialIndexes, pages: initialValues.initialPages }); - } - }; - - const back = () => { - // pageStack.update((prev) => { - // if (prev.length > 1) { - // prev.pop(); - // } - // return prev; - // }); - - history.back(); - }; - - return { - subscribe: visibleStack.subscribe, - navigate, - back, - handlePopState - }; -} - -const usersRoute: Route = { - path: '/users', - root: true, - component: UsersPage, - sidebar: false -}; - -const seriesHomeRoute: Route = { - path: '/series', - default: true, - root: true, - component: SeriesHomePage -}; - -const seriesRoute: Route = { - path: '/series/:id', - component: SeriesPage, - parent: seriesHomeRoute -}; - -const episodeRoute: Route = { - path: '/series/:id/season/:season/episode/:episode', - component: EpisodePage, - parent: seriesRoute -}; - -const networkRoute: Route = { - path: '/network/:network', - component: NetworkPage, - parent: seriesHomeRoute -}; - -const moviesHomeRoute: Route = { - path: '/movies', - component: MoviesHomePage, - root: true -}; - -const movieRoute: Route = { - path: '/movie/:id', - component: MoviePage, - parent: moviesHomeRoute -}; - -const collectionRoute: Route = { - path: '/collection/:collection', - component: ListPage, - parent: moviesHomeRoute -}; - -const companyRoute: Route = { - path: '/company/:company', - component: CompanyPage, - parent: moviesHomeRoute -}; - -const personRoute: Route = { - path: '/person/:id', - component: PersonPage -}; - -const libraryRoute: Route = { - path: '/library', - component: LibraryPage, - root: true -}; - -const searchRoute: Route = { - path: '/search', - component: SearchPage, - root: true -}; - -const manageRoute: Route = { - path: '/manage', - component: ManagePage, - root: true -}; - -const uiComponentsRoute: Route = { - path: '/ui-components', - component: UiComponents, - root: true -}; - -const notFoundRoute: Route = { - path: '/404', - component: PageNotFound, - root: true -}; - -export const stackRouter = useStackRouter({ - routes: [ - usersRoute, - seriesHomeRoute, - seriesRoute, - episodeRoute, - networkRoute, - moviesHomeRoute, - movieRoute, - collectionRoute, - companyRoute, - personRoute, - libraryRoute, - searchRoute, - manageRoute, - uiComponentsRoute - ], - notFound: notFoundRoute -}); -// export const defaultStackRouter = useStackRouter({ -// '/': { -// component: SeriesHomePage, -// default: true -// }, -// '/series/:id': { -// component: SeriesPage -// }, -// '/series/:id/season/:season/episode/:episode': { -// component: EpisodePage -// } -// // '/movies': { -// // component: MoviesHomePage -// // }, -// // '/movies/:id': { -// // component: MoviePage -// // }, -// // '/library': { -// // component: LibraryPage -// // }, -// // '/settings': { -// // component: ManagePage -// // } -// } as const); - -function useStackRouterPage() { - const topSelectable = useRegistrar(); - const hasFocus = writable(false); - - function handleGoBack() { - history.back(); - } - - function handleGoToTop() { - const selectable = get(topSelectable); - if (selectable && get(selectable.focusIndex) === 0 && get(selectable.hasFocusWithin)) { - history.back(); - } else if (selectable) { - selectable.focusChild(0, { cycleTo: true }) || selectable.focus({ cycleTo: true }); - } else handleGoBack(); - } - - return { - handleGoBack, - handleGoToTop, - registrar: topSelectable.registrar, - hasFocus - }; -} - -const STACK_ROUTER_PAGE = Symbol('STACK_ROUTER_PAGE'); - -export function createStackRouterPage() { - const store = useStackRouterPage(); - setContext(STACK_ROUTER_PAGE, store); - return store; -} - -export function getStackRouterPage(): ReturnType { - if (hasContext(STACK_ROUTER_PAGE)) return getContext(STACK_ROUTER_PAGE); - console.error('[StackRouterControls] Not found'); - return useStackRouterPage(); -} - -export const navigate = stackRouter.navigate; -export const back = stackRouter.back; diff --git a/frontend/src/lib/components/StackRouter/StackRouterPage.svelte b/frontend/src/lib/components/StackRouter/StackRouterPage.svelte index 37049f2..2fab21c 100644 --- a/frontend/src/lib/components/StackRouter/StackRouterPage.svelte +++ b/frontend/src/lib/components/StackRouter/StackRouterPage.svelte @@ -1,49 +1,56 @@ diff --git a/frontend/src/lib/components/StackRouter/StackRouterPage.type.ts b/frontend/src/lib/components/StackRouter/StackRouterPage.type.ts deleted file mode 100644 index 463fb8a..0000000 --- a/frontend/src/lib/components/StackRouter/StackRouterPage.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Registrar } from '$lib/selectable'; - -export type StackRouterPageProps = { - handleGoBack: () => void; - registrar: Registrar; -}; diff --git a/frontend/src/lib/components/StackRouter/stack-router.store.ts b/frontend/src/lib/components/StackRouter/stack-router.store.ts new file mode 100644 index 0000000..45007dd --- /dev/null +++ b/frontend/src/lib/components/StackRouter/stack-router.store.ts @@ -0,0 +1,474 @@ +import CompanyPage from '$lib/pages/CollectionPages/CompanyPage.svelte'; +import ListPage from '$lib/pages/CollectionPages/ListPage.svelte'; +import NetworkPage from '$lib/pages/CollectionPages/NetworkPage.svelte'; +import { Selectable } from '$lib/selectable'; +import { linkedListToArray } from '$lib/utils'; +import { + getContext as getSvelteContext, + SvelteComponentTyped, + type ComponentProps, + type ComponentType +} from 'svelte'; +import { writable, type Readable, type Writable } from 'svelte/store'; +import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte'; +import ManagePage from '../../pages/ManagePage/ManagePage.svelte'; +import MoviesHomePage from '../../pages/MoviesHomePage.svelte'; +import PageNotFound from '../../pages/PageNotFound.svelte'; +import PersonPage from '../../pages/PersonPage.svelte'; +import SearchPage from '../../pages/SearchPage.svelte'; +import SeriesHomePage from '../../pages/SeriesHomePage.svelte'; +import EpisodePage from '../../pages/TitlePages/EpisodePage.svelte'; +import MoviePage from '../../pages/TitlePages/MoviePage/MoviePage.svelte'; +import SeriesPage from '../../pages/TitlePages/SeriesPage/SeriesPage.svelte'; +import UiComponents from '../../pages/UIComponents.svelte'; +import UsersPage from '../../pages/UsersPage.svelte'; + +export type CreateCompStackPage = { + component: ComponentType; + props: ComponentProps; + + id?: symbol; + group?: symbol | 'top'; + + preventScroll?: boolean; + trapFocus?: boolean; + sidebar?: boolean; +}; + +export interface CompStackPage extends CreateCompStackPage { + context: Record; + pages: CompStackPage[]; + + hasContext: (key: string) => boolean; + getContext: (key: string) => T; + setContext: (key: string, value: T) => void; + handleMount: () => void; + close: () => void; + push: (opts: CreateCompStackPage) => void; +} + +export interface CompStackForPageContext extends Readonly { + readonly root: Readable; +} + +class RouteGroup { + pages: CompStackPage[] = []; + groups: RouteGroup[]; + handleUpdate: () => void; + + constructor(opts: { + groups: RouteGroup[]; + route: Route; + props: Record; + handleUpdate: () => void; + }) { + this.groups = opts.groups; + this.handleUpdate = opts.handleUpdate; + this.createRoutePage(opts.route, opts.props); + } + + private hasContext(context: Record) { + return (key: string): boolean => key in context; + } + + private getContext(context: Record) { + return (key: string): T => context[key] as T; + } + + private setContext(context: Record) { + return (key: string, value: T): void => { + context[key] = value; + }; + } + + private close(id: symbol) { + const index = this.pages.findIndex((p) => p.id === id); + if (index !== -1) { + this.pages.splice(index, 1); + } + this.handleUpdate(); + } + + push(opts: CreateCompStackPage) { + const previousPage = this.pages.length > 0 ? this.pages[this.pages.length - 1] : undefined; + const previousContext = previousPage?.context ?? {}; + const context = {}; + + const id = opts.id ?? Symbol(); + + const page: CompStackPage = { + id, + group: (opts.group === 'top' ? previousPage?.group : opts.group) ?? Symbol(), + component: opts.component, + props: opts.props, + preventScroll: opts.preventScroll ?? false, + trapFocus: opts.trapFocus ?? true, + sidebar: opts.sidebar ?? false, + context, + pages: this.pages, + hasContext: this.hasContext(context), + getContext: this.getContext(context), + setContext: this.setContext(context), + handleMount: () => this.initializeContext(context, previousContext), + close: () => this.close(id), + push: (newOpts) => this.push(newOpts) + }; + this.pages.push(page); + this.handleUpdate(); + } + + private createRoutePage(route: Route, props: Record) { + const previousGroup = this.groups[this.groups.length - 1]; + const previousContext = previousGroup?.pages[previousGroup.pages.length - 1]?.context ?? {}; + + const context: Record = {}; + const page: CompStackPage = { + id: Symbol(), + group: Symbol(), + component: route.component, + props, + preventScroll: false, + trapFocus: true, + sidebar: route.sidebar ?? true, + context, + pages: this.pages, + hasContext: this.hasContext(context), + getContext: this.getContext(context), + setContext: this.setContext(context), + handleMount: () => this.initializeContext(context, previousContext), + close: () => history.back(), + push: (opts) => this.push(opts) + }; + this.pages.push(page); + // Don't update visibleStack yet, update history first + } + + initializeContext(context: Record, previousContext: Record) { + if (context.didInitialize) return; + context.didInitialize = true; + + for (const key in previousContext) { + if (!(key in context)) { + context[key] = previousContext[key]; + } + } + } + + handleUnmount() { + this.pages.splice(1); + } +} + +interface Route { + path: string; + component: ComponentType; + default?: boolean; + // When root is navigated to, stack is cleared + root?: boolean; + // Parent route, that is always rendered under this route, possibly sharing props that are a subset of the child's props. + // Child's props are also passed to these. + parent?: Route; + sidebar?: boolean; +} + +// Defines what part of the stack is visible (not always the last x pages) +type HistoryState = { + // Id is used to identify if the history state belongs to the same stack instance, to deduce if stack has to be rebuilt + id: string; + end: number; +}; + +export type StackRouterStore = ReturnType; + +export function useStackRouter({ + routes, + notFound, + maxDepth +}: { + routes: Route[]; + notFound: Route; + maxDepth?: number; +}) { + const groups: RouteGroup[] = []; + let historyState: HistoryState = { + id: Math.random().toString(36).slice(2), + end: 0 + }; + // Only updates when historyState changes + const visibleStack: Writable> = writable([]); + visibleStack.subscribe(console.log); + + initializeFromUrl(); + + function updateVisibleStack() { + const start = Math.max(0, historyState.end - (maxDepth ?? Infinity) + 1); + const newVisibleStack = groups + .slice(start, historyState.end + 1) + .map((g) => g.pages) + .flat(); + + // Unmount hidden + for (let i = 0; i < start; i++) { + groups[i]?.handleUnmount(); + } + + visibleStack.set(newVisibleStack); + } + + function routeStringToRoutes(routeString: string): { + top: Route; + routes: Array; + params: Record; + } { + let matchedRoute: Route = notFound; + let matchedParams: Record = {}; + + for (const route of routes) { + const targetParts = routeString.split('/'); + const routeParts = route.path.split('/'); + const params: Record = {}; + + if (targetParts.length !== routeParts.length) continue; + let i = 0; + while (i < targetParts.length) { + if (routeParts[i]?.startsWith(':')) { + const paramName = routeParts[i]?.slice(1); + if (paramName) { + // @ts-ignore + params[paramName] = targetParts[i]; + } + } else if (routeParts[i] !== targetParts[i]) break; + i++; + } + if (i === targetParts.length) { + matchedRoute = route; + matchedParams = params; + break; + } + } + + const allRoutes = linkedListToArray(matchedRoute, (r) => r.parent).reverse(); + + return { + top: matchedRoute, + routes: allRoutes, + params: matchedParams + }; + } + + function initializeFromUrl() { + const initialUrl = window.location.pathname; + const { routes, params } = routeStringToRoutes(initialUrl); + + groups.splice(0); + for (const route of routes) { + groups.push( + new RouteGroup({ groups, route, props: params, handleUpdate: updateVisibleStack }) + ); + } + + historyState = { + id: Math.random().toString(36).slice(2), + end: groups.length - 1 + }; + + history.replaceState(historyState, '', initialUrl); + updateVisibleStack(); + } + + const navigate = ( + routeString: string, + options: { replaceStack?: boolean; refresh?: boolean } = {} + ) => { + if (options.refresh) { + location.assign(routeString); + return; + } + + const { top: topRoute, params } = routeStringToRoutes(routeString); + + const replaceStack = options.replaceStack ?? topRoute.root ?? false; + + const group = new RouteGroup({ + groups, + route: topRoute, + props: params, + handleUpdate: updateVisibleStack + }); + + if (replaceStack) { + groups.splice(0); + groups.push(group); + historyState = { + id: Math.random().toString(36).slice(2), + end: 0 + }; + history.pushState(historyState, '', routeString); + } else { + groups.splice(historyState.end + 1, Infinity, group); // Remove any forward history + + historyState.end = historyState.end + 1; + history.pushState(historyState, '', routeString); + } + + updateVisibleStack(); + }; + + /** Handle browser history navigation, https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event */ + const handlePopState = (e: PopStateEvent) => { + const newHistory: HistoryState = e.state; + + if (historyState.id === newHistory.id) { + historyState = newHistory; + updateVisibleStack(); + } else { + // Rebuild stack + initializeFromUrl(); + } + }; + + const back = () => { + if (historyState.end === 0) return; + + history.back(); + }; + + return { + subscribe: visibleStack.subscribe, + navigate, + back, + handlePopState + }; +} + +const usersRoute: Route = { + path: '/users', + root: true, + component: UsersPage, + sidebar: false +}; + +const seriesHomeRoute: Route = { + path: '/series', + default: true, + root: true, + component: SeriesHomePage +}; + +const seriesRoute: Route = { + path: '/series/:id', + component: SeriesPage, + parent: seriesHomeRoute +}; + +const episodeRoute: Route = { + path: '/series/:id/season/:season/episode/:episode', + component: EpisodePage, + parent: seriesRoute +}; + +const networkRoute: Route = { + path: '/network/:network', + component: NetworkPage, + parent: seriesHomeRoute +}; + +const moviesHomeRoute: Route = { + path: '/movies', + component: MoviesHomePage, + root: true +}; + +const movieRoute: Route = { + path: '/movie/:id', + component: MoviePage, + parent: moviesHomeRoute +}; + +const collectionRoute: Route = { + path: '/collection/:collection', + component: ListPage, + parent: moviesHomeRoute +}; + +const companyRoute: Route = { + path: '/company/:company', + component: CompanyPage, + parent: moviesHomeRoute +}; + +const personRoute: Route = { + path: '/person/:id', + component: PersonPage +}; + +const libraryRoute: Route = { + path: '/library', + component: LibraryPage, + root: true +}; + +const searchRoute: Route = { + path: '/search', + component: SearchPage, + root: true +}; + +const manageRoute: Route = { + path: '/manage', + component: ManagePage, + root: true +}; + +const uiComponentsRoute: Route = { + path: '/ui-components', + component: UiComponents, + root: true +}; + +const notFoundRoute: Route = { + path: '/404', + component: PageNotFound, + root: true +}; + +export const stackRouter = useStackRouter({ + routes: [ + usersRoute, + seriesHomeRoute, + seriesRoute, + episodeRoute, + networkRoute, + moviesHomeRoute, + movieRoute, + collectionRoute, + companyRoute, + personRoute, + libraryRoute, + searchRoute, + manageRoute, + uiComponentsRoute + ], + notFound: notFoundRoute + // maxDepth: 3 +}); + +export const STACK_ROUTER_CONTEXT = 'stack-router-context'; + +export function useComponentStack() { + return getSvelteContext(STACK_ROUTER_CONTEXT); +} + +export function hasContext(key: string): boolean { + return useComponentStack().hasContext(key); +} + +export function getContext(key: string): T { + return useComponentStack().getContext(key); +} + +export function setContext(key: string, value: T): void { + return useComponentStack().setContext(key, value); +} + +export const navigate = stackRouter.navigate; diff --git a/frontend/src/lib/pages/CollectionPages/CollectionPage.svelte b/frontend/src/lib/pages/CollectionPages/CollectionPage.svelte index 8d95feb..1dad1df 100644 --- a/frontend/src/lib/pages/CollectionPages/CollectionPage.svelte +++ b/frontend/src/lib/pages/CollectionPages/CollectionPage.svelte @@ -4,7 +4,6 @@ import Container from '$lib/components/Container.svelte'; import FloatingHeader from '$lib/components/FloatingHeader.svelte'; import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; - import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; import TitleText from '$lib/components/TitleText.svelte'; import { scrollIntoView } from '$lib/selectable'; import { setScrollContext } from '$lib/stores/scroll.store'; @@ -14,7 +13,6 @@ export let subtitle = ''; export let items: ComponentProps['item'][]; export let loading = false; - const { registrar } = getStackRouterPage(); const background = createBackgroundPage(); const { registrar: registerScroll, topVisible } = setScrollContext(); @@ -41,7 +39,7 @@

Loading...

{:else if items.length} - + {#each items as item, index} { diff --git a/frontend/src/lib/pages/LibraryPage/CatalogueTab.svelte b/frontend/src/lib/pages/LibraryPage/CatalogueTab.svelte index 88cf6bd..5fa7ec0 100644 --- a/frontend/src/lib/pages/LibraryPage/CatalogueTab.svelte +++ b/frontend/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -6,7 +6,6 @@ import Container from '$lib/components/Container.svelte'; import FloatingHeader from '$lib/components/FloatingHeader.svelte'; import { createModal } from '$lib/components/Modal/modal.store'; - import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; import TitleText from '$lib/components/TitleText.svelte'; import { scrollIntoView } from '$lib/selectable'; import { usePaginatedRequest } from '$lib/stores/data.store'; @@ -20,7 +19,6 @@ export let source: MediaSourceDto; const { topVisible } = getScrollContext(); - const { registrar } = getStackRouterPage(); // const viewSettings = createLocalStorageStore<{ // order: string | undefined; @@ -160,7 +158,7 @@
{#if $data.length} - + {#each $data.map((i) => i.tmdbItem) as item, index (item.id)} { didMount = true; - registrar(e); + // registrar(e); }} focusedChild class="flex-1 flex flex-col" @@ -159,7 +157,7 @@ {#if $libraryViewSettings.separateWatched}
Unwatched
{/if} - + {#each $data as item, index (item.tmdbId)} import Container from '$components/Container.svelte'; - import { navigate } from '../components/StackRouter/StackRouter'; + import { navigate } from '../components/StackRouter/stack-router.store'; import { onMount } from 'svelte'; onMount(() => { diff --git a/frontend/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte b/frontend/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte index 692ef0e..a65f1b1 100644 --- a/frontend/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte +++ b/frontend/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte @@ -8,7 +8,7 @@ const { componentStack } = titlePageContext.getContext(); - +
- + { diff --git a/frontend/src/lib/pages/TitlePages/ActionsPage/actions-page.ts b/frontend/src/lib/pages/TitlePages/ActionsPage/actions-page.ts index d2ac455..48de67c 100644 --- a/frontend/src/lib/pages/TitlePages/ActionsPage/actions-page.ts +++ b/frontend/src/lib/pages/TitlePages/ActionsPage/actions-page.ts @@ -10,10 +10,10 @@ import { } from '$lib/stores/user-data/title-user-data.store'; import { reiverrApi } from '$lib/stores/user.store'; import { createStoreContext } from '$lib/utils'; -import { getContext, hasContext } from 'svelte'; import { writable } from 'svelte/store'; import ManageMenu from '../ManageMenu.svelte'; import ActionsMenu from './ActionsMenu.svelte'; +import { getContext, hasContext } from '$lib/components/ComponentStack/component-stack.store'; function usePlayableDataStore(options: { tmdbId: string; season?: number; episode?: number }) { const { tmdbId, season, episode } = options; diff --git a/frontend/src/lib/pages/TitlePages/EpisodePage.svelte b/frontend/src/lib/pages/TitlePages/EpisodePage.svelte index 362ba99..79a9cf2 100644 --- a/frontend/src/lib/pages/TitlePages/EpisodePage.svelte +++ b/frontend/src/lib/pages/TitlePages/EpisodePage.svelte @@ -2,12 +2,11 @@ import Container from '$components/Container.svelte'; import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte'; - import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; import { useEpisodeUserData } from '$lib/stores/user-data/title-user-data.store'; import { Check, ExternalLink, Play } from 'radix-icons-svelte'; import { onDestroy } from 'svelte'; import Button from '../../components/Button/Button.svelte'; - import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants'; + import { PLATFORM_WEB } from '../../constants'; import { formatThousands } from '../../utils'; import TitleProperties from './HeroTitleInfo.svelte'; @@ -15,8 +14,6 @@ export let season: string; export let episode: string; - const { registrar } = getStackRouterPage(); - const background = createBackgroundPage({ videoMediaId: id }); const { @@ -82,12 +79,7 @@ properties={titleProperties} overview={tmdbEpisode?.overview ?? ''} /> - + - - {#if trailerId} - - {/if} - - {#if !$inLibrary} - + {#if $nextEpisode?.episode && $nextEpisode?.season} + Play S{$nextEpisode?.season}E{$nextEpisode?.episode} {:else} - + Play {/if} + + - - + - {#if PLATFORM_WEB} - - {/if} - - - -
- {#await $tmdbSeries then tmdbSeries} - {#if $episodes.length} - { - const episode = $episodes[i]; - - selectedEpisode.set({ - season: episode?.season_number ?? 1, - episode: episode?.episode_number ?? 1 - }); - }} - let:scrollToIndex + {#if PLATFORM_WEB} +