diff --git a/.gitignore b/.gitignore index 9e7be89b..edd71bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ dist/ build/ .nyc_output/ coverage/ -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json diff --git a/TODO.md b/TODO.md index 023979c1..8b78e33b 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,7 @@ - [x] Get clear on paths - [x] ESLint root package dependency usage - [x] Move themes to `packages/themes` +- [ ] Separated server vs client settings + interface (new events?) ## Backend @@ -35,8 +36,11 @@ - [x] Move off CRA to Vite - [ ] Separate out line jump into per file - [x] Convert to TS -- [ ] Refactor routing to be centralised (per feature) - [ ] Sort out `react-hotkeys` usage (error regarding no `parentId`) +- [ ] Use new events from BE +- [ ] Refactor into concept of `content` +- [ ] Render navigator's bar extra icons in controller +- [ ] Better settings management via hooks ## Electron diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 0502dd87..4b88f2df 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -4,4 +4,12 @@ module.exports = { project: './tsconfig.json', tsconfigRootDir: __dirname, }, + overrides: [ { + // Targeting route files + files: [ '**/*/app/**/*.ts?(x)' ], + rules: { + // Referencing the exported route leads to a use before define in components, but it's fine + '@typescript-eslint/no-use-before-define': 'off', + }, + } ], } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f4db177e..3e857c7a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -22,6 +22,8 @@ "@mui/material": "^5.16.6", "@presenter/themes": "*", "@sentry/browser": "^6.19.7", + "@tanstack/react-router": "^1.49.2", + "@tanstack/router-zod-adapter": "^1.51.6", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.1", "detect-browser": "^5.3.0", @@ -31,21 +33,21 @@ "lodash": "^4.17.21", "memoizee": "^0.4.17", "notistack": "^3.0.1", - "qs": "^6.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hotkeys": "^2.0.0", "react-idle-timer": "^5.7.2", - "react-router-dom": "^6.26.0", "react-transition-group": "^4.4.5", "reconnecting-websocket": "^4.4.0", - "scroll-into-view": "^1.16.0" + "scroll-into-view": "^1.16.0", + "zod": "^3.8.2-alpha.6" }, "devDependencies": { "@presenter/contract": "*", + "@tanstack/router-devtools": "^1.49.2", + "@tanstack/router-plugin": "^1.49.3", "@types/lodash": "^4.17.7", "@types/memoizee": "^0.4.11", - "@types/qs": "^6.9.15", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/scroll-into-view": "^1.16.0", diff --git a/apps/frontend/src/App.css b/apps/frontend/src/app/__root.css similarity index 82% rename from apps/frontend/src/App.css rename to apps/frontend/src/app/__root.css index 1da7e4a5..f139a2d9 100644 --- a/apps/frontend/src/App.css +++ b/apps/frontend/src/app/__root.css @@ -1,6 +1,6 @@ -@import url('./fonts/noto-sans/index.css'); -@import url('./fonts/OpenGurbaniAkhar/index.css'); -@import url('./fonts/OpenAnmolUni/index.css'); +@import url('../fonts/noto-sans/index.css'); +@import url('../fonts/OpenGurbaniAkhar/index.css'); +@import url('../fonts/OpenAnmolUni/index.css'); @import url('@fontsource/roboto'); /* Shared CSS constants */ diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/app/__root.tsx similarity index 73% rename from apps/frontend/src/App.tsx rename to apps/frontend/src/app/__root.tsx index 6165e273..c796ed73 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/app/__root.tsx @@ -1,14 +1,13 @@ -import './App.css' +import './__root.css' import { RecommendedSources, Shabad, Writer } from '@presenter/contract' +import { createRootRoute, Navigate, Outlet } from '@tanstack/react-router' import classNames from 'classnames' import { SnackbarProvider } from 'notistack' -import { ElementType, lazy, PureComponent, ReactNode, Suspense } from 'react' -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' +import { PureComponent, Suspense } from 'react' import Loader from '~/components/Loader' -import Overlay from '~/features/overlay' -import { BACKEND_URL, isDesktop, isMobile, isTablet, OVERLAY_URL, PRESENTER_URL, SCREEN_READER_URL, SETTINGS_URL } from '~/helpers/consts' +import { API_URL, isDesktop, isMobile, isTablet } from '~/helpers/consts' import { BookmarksContext, ContentContext, @@ -22,20 +21,9 @@ import { DEFAULT_OPTIONS } from '~/helpers/options' import { merge } from '~/helpers/utils' import controller from '~/services/controller' -const ScreenReader = lazy( () => import( '~/features/screen-reader' ) ) -const Presenter = lazy( () => import( '~/features/presenter' ) ) -const Settings = lazy( () => import( '~/features/settings' ) ) - const loadSettings = () => merge( { local: controller.readSettings() }, DEFAULT_OPTIONS ) class App extends PureComponent { - components = [ - [ Overlay, OVERLAY_URL ], - [ ScreenReader, SCREEN_READER_URL ], - [ Settings, SETTINGS_URL ], - [ Presenter, PRESENTER_URL ], - ] as const - state = { connected: false, connectedAt: null, @@ -72,7 +60,7 @@ class App extends PureComponent { controller.on( 'settings', this.onSettings ) // Get recommended sources and set as settings, if there are none - void fetch( `${BACKEND_URL}/sources` ) + void fetch( `${API_URL}/sources` ) .then( ( res ) => res.json() ) .then( ( { recommendedSources }: { recommendedSources: RecommendedSources['recommendedSources'] } ) => { //* Update default options and settings with fetched recommended sources @@ -82,7 +70,7 @@ class App extends PureComponent { } ) // Get writers - void fetch( `${BACKEND_URL}/writers` ) + void fetch( `${API_URL}/writers` ) .then( ( res ) => res.json() ) .then( ( { writers }: { writers: Writer[] } ) => this.setState( { writers } ) ) } @@ -172,37 +160,33 @@ class App extends PureComponent { settings, } = this.state - // Generate a context wrapper function - const withContexts = ( [ - [ StatusContext.Provider, { connected, connectedAt, status } ], - [ SettingsContext.Provider, settings ], - [ HistoryContext.Provider, { viewedLines, transitionHistory, latestLines } ], - [ BookmarksContext.Provider, banis ], - [ WritersContext.Provider, writers ], - [ RecommendedSourcesContext.Provider, recommendedSources ], - [ ContentContext.Provider, { bani, shabad, lineId, mainLineId, nextLineId } ], - [ SnackbarProvider ], - ] as [ElementType, any][] ) - .reduce( ( withContexts, [ Provider, value ] ) => ( children ) => withContexts( - - {children} - , - ), ( context: ReactNode ) => context ) - - return withContexts( + return (
}> - - - {this.components.map( ( [ Component, path ] ) => ( - } /> - ) )} - - + + + + + + + + + + + + + + + + + -
, + ) } } -export default App +export const Route = createRootRoute( { + component: App, + notFoundComponent: () => , +} ) diff --git a/apps/frontend/src/app/index.tsx b/apps/frontend/src/app/index.tsx new file mode 100644 index 00000000..91fef4da --- /dev/null +++ b/apps/frontend/src/app/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute( '/' )( { + beforeLoad: () => redirect( { to: '/presenter', throw: true } ), +} ) diff --git a/apps/frontend/src/features/overlay/Line.css b/apps/frontend/src/app/overlay/-components/Line/index.css similarity index 100% rename from apps/frontend/src/features/overlay/Line.css rename to apps/frontend/src/app/overlay/-components/Line/index.css diff --git a/apps/frontend/src/features/overlay/Line.tsx b/apps/frontend/src/app/overlay/-components/Line/index.tsx similarity index 94% rename from apps/frontend/src/features/overlay/Line.tsx rename to apps/frontend/src/app/overlay/-components/Line/index.tsx index c7aa249b..32da4f88 100644 --- a/apps/frontend/src/features/overlay/Line.tsx +++ b/apps/frontend/src/app/overlay/-components/Line/index.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/no-array-index-key */ -import './Line.css' +import './index.css' import classNames from 'classnames' @@ -14,12 +14,9 @@ type LineProps = { larivaarGurbani?: boolean, larivaarAssist?: boolean, } -/** - * Overlay Line Component. - * Renders the various aspects of a single line. - */ + const Line = ( { - className = '', + className, gurmukhi, larivaarGurbani: larivaar = false, larivaarAssist = false, diff --git a/apps/frontend/src/features/overlay/ThemeLoader.tsx b/apps/frontend/src/app/overlay/-components/ThemeLoader.tsx similarity index 68% rename from apps/frontend/src/features/overlay/ThemeLoader.tsx rename to apps/frontend/src/app/overlay/-components/ThemeLoader.tsx index 0e68a5a0..421256e4 100644 --- a/apps/frontend/src/features/overlay/ThemeLoader.tsx +++ b/apps/frontend/src/app/overlay/-components/ThemeLoader.tsx @@ -1,15 +1,12 @@ import { useContext } from 'react' -import { OVERLAY_THEMES_URL } from '~/helpers/consts' +import { API_URL } from '~/helpers/consts' import { StatusContext } from '~/helpers/contexts' +const OVERLAY_THEMES_URL = `${API_URL}/themes/overlay` + type ThemeLoaderProps = { name: string } -/** - * Component to load a theme using a `` tag. - * @param name The name of the CSS theme to load from the server. - * @constructor - */ const ThemeLoader = ( { name }: ThemeLoaderProps ) => { const { connectedAt } = useContext( StatusContext ) diff --git a/apps/frontend/src/features/overlay/index.css b/apps/frontend/src/app/overlay/index.css similarity index 100% rename from apps/frontend/src/features/overlay/index.css rename to apps/frontend/src/app/overlay/index.css diff --git a/apps/frontend/src/features/overlay/index.tsx b/apps/frontend/src/app/overlay/index.lazy.tsx similarity index 88% rename from apps/frontend/src/features/overlay/index.tsx rename to apps/frontend/src/app/overlay/index.lazy.tsx index 1592ad53..2f0c121c 100644 --- a/apps/frontend/src/features/overlay/index.tsx +++ b/apps/frontend/src/app/overlay/index.lazy.tsx @@ -1,5 +1,4 @@ -import './index.css' - +import { createLazyFileRoute } from '@tanstack/react-router' import classNames from 'classnames' import { mapValues } from 'lodash' import { useContext } from 'react' @@ -10,8 +9,8 @@ import { customiseLine, getTransliterators } from '~/helpers/line' import { filterFalsyValues } from '~/helpers/utils' import { useCurrentLine, useTranslations } from '~/hooks' -import Line from './Line' -import ThemeLoader from './ThemeLoader' +import Line from './-components/Line' +import ThemeLoader from './-components/ThemeLoader' const Overlay = () => { const settings = useContext( SettingsContext ) @@ -60,4 +59,6 @@ const Overlay = () => { ) } -export default Overlay +export const Route = createLazyFileRoute( '/overlay/' )( { + component: Overlay, +} ) diff --git a/apps/frontend/src/features/presenter/Display.css b/apps/frontend/src/app/presenter/-components/Display/index.css similarity index 100% rename from apps/frontend/src/features/presenter/Display.css rename to apps/frontend/src/app/presenter/-components/Display/index.css diff --git a/apps/frontend/src/features/presenter/Display.tsx b/apps/frontend/src/app/presenter/-components/Display/index.tsx similarity index 93% rename from apps/frontend/src/features/presenter/Display.tsx rename to apps/frontend/src/app/presenter/-components/Display/index.tsx index 6aab02cd..e0d84658 100644 --- a/apps/frontend/src/features/presenter/Display.tsx +++ b/apps/frontend/src/app/presenter/-components/Display/index.tsx @@ -1,4 +1,4 @@ -import './Display.css' +import './index.css' import classNames from 'classnames' import { mapValues } from 'lodash' @@ -9,18 +9,12 @@ import { ClientSettings } from '~/helpers/options' import { filterFalsyValues } from '~/helpers/utils' import { useCurrentLine, useCurrentLines, useTranslations } from '~/hooks' -import Line from './Line' +import Line from '../Line' type DisplayProps = { settings: Pick, } -/** - * Display Component. - * Displays the current Shabad, with visual settings. - * @param shabad The Shabad to render. - * @param lineId The current line in the Shabad. - */ const Display = ( { settings }: DisplayProps ) => { const { layout, diff --git a/apps/frontend/src/features/presenter/Line.css b/apps/frontend/src/app/presenter/-components/Line/index.css similarity index 100% rename from apps/frontend/src/features/presenter/Line.css rename to apps/frontend/src/app/presenter/-components/Line/index.css diff --git a/apps/frontend/src/features/presenter/Line.tsx b/apps/frontend/src/app/presenter/-components/Line/index.tsx similarity index 82% rename from apps/frontend/src/features/presenter/Line.tsx rename to apps/frontend/src/app/presenter/-components/Line/index.tsx index a417f10a..7ef2c430 100644 --- a/apps/frontend/src/features/presenter/Line.tsx +++ b/apps/frontend/src/app/presenter/-components/Line/index.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/no-array-index-key */ -import './Line.css' +import './index.css' import classNames from 'classnames' import { countSyllables, toSyllabicSymbols } from 'gurmukhi-utils' @@ -77,32 +77,6 @@ const { theme: { simpleGraphics }, } = DEFAULT_OPTIONS.local -/** - * Line Component. - * Renders the various aspects of a single line. - * @param {string} className An optional class name to append. - * @param {string} gurmukhi The Gurmukhi of the line to render. - * @param {string} translations The Punjabi translation of the line to render. - * @param {string} transliterators The Punjabi translation of the line to render. - * @param {string} spacing The justify content value for spacing between the lines. - * @param {boolean} centerText Whether to center text. - * @param {boolean} justifyText Whether to justify (edge to edge) wrapped text (2+ lines long). - * @param {number} presenterFontSize The global font size of presenter lines. - * @param {number} relativeGurmukhiFontSize Relative size for gurmukhi ascii font. - * @param {number} relativeEnglishFontSize Relative size for latin scripts (english/spanish). - * @param {number} relativePunjabiFontSize Relative size for punjabi unicode font. - * @param {number} relativeHindiFontSize Relative font size for hindi unicode font. - * @param {number} relativeUrduFontSize Relative font size for urdu unicode font. - * @param {boolean} larivaarGurbani Whether Gurbani should be continuous or not. - * @param {boolean} larivaarAssist If `larivaarGurbani`, whether alternate words should be coloured. - * @param {boolean} vishraamColors Enables colors for vishraams. - * @param {boolean} vishraamCharacters Enables display of vishraam characters. - * @param {boolean} vishraamLight Enables colors for light vishraams. - * @param {boolean} vishraamMedium Enables colors for medium vishraams. - * @param {boolean} vishraamHeavy Enables colors for heavy vishraams. - * @param {boolean} splitOnVishraam If the line is too long, split it on the vishraam word. - * @param {boolean} simpleGraphics Disables transitions and other intensive effects. - */ const Line = ( { className = undefined, gurmukhi, diff --git a/apps/frontend/src/features/presenter/StatusToast.css b/apps/frontend/src/app/presenter/-components/StatusToast/index.css similarity index 100% rename from apps/frontend/src/features/presenter/StatusToast.css rename to apps/frontend/src/app/presenter/-components/StatusToast/index.css diff --git a/apps/frontend/src/features/presenter/StatusToast.tsx b/apps/frontend/src/app/presenter/-components/StatusToast/index.tsx similarity index 96% rename from apps/frontend/src/features/presenter/StatusToast.tsx rename to apps/frontend/src/app/presenter/-components/StatusToast/index.tsx index 5b8454bd..bf47f73f 100644 --- a/apps/frontend/src/features/presenter/StatusToast.tsx +++ b/apps/frontend/src/app/presenter/-components/StatusToast/index.tsx @@ -1,4 +1,4 @@ -import './StatusToast.css' +import './index.css' import { faInfoCircle } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' diff --git a/apps/frontend/src/app/presenter/-controller-location-history.ts b/apps/frontend/src/app/presenter/-controller-location-history.ts new file mode 100644 index 00000000..a6af6175 --- /dev/null +++ b/apps/frontend/src/app/presenter/-controller-location-history.ts @@ -0,0 +1,9 @@ +import createUseLocationHistory from '~/hooks/use-location-history' + +export const { + Provider: ControllerLocationHistoryProvider, + useLocationHistory: useControllerLocationHistory, +} = createUseLocationHistory( + '/presenter/controller/search', + { filter: ( href ) => !!href.match( '/presenter/controller/.*' ) } +) diff --git a/apps/frontend/src/features/controller/ToolbarButton.tsx b/apps/frontend/src/app/presenter/controller/-components/ToolbarButton.tsx similarity index 60% rename from apps/frontend/src/features/controller/ToolbarButton.tsx rename to apps/frontend/src/app/presenter/controller/-components/ToolbarButton.tsx index 8151e034..bc2fbd71 100644 --- a/apps/frontend/src/features/controller/ToolbarButton.tsx +++ b/apps/frontend/src/app/presenter/controller/-components/ToolbarButton.tsx @@ -9,17 +9,9 @@ type ToolbarButtonProps = { onClick?: () => void, onMouseEnter?: () => void, onMouseLeave?: () => void, + flip?: 'horizontal' | 'vertical', } -/** - * Renders an individual icon button, setting the state with the name on hover and click. - * @param {string} name The human-readable name of the icon. - * @param {string} icon The font-awesome icon. - * @param {Function} onClick Optional click handler. - * @param {Function} onMouseEnter MouseEnter click handler. - * @param {Function} onMouseLeave MouseLeave click handler. - * @param {string} className Optional classname. - */ const ToolbarButton = ( { name, icon, @@ -27,6 +19,7 @@ const ToolbarButton = ( { onMouseEnter, onMouseLeave, className, + flip, }: ToolbarButtonProps ) => ( - + ) diff --git a/apps/frontend/src/features/controller/Bookmarks.css b/apps/frontend/src/app/presenter/controller/bookmarks/index.css similarity index 100% rename from apps/frontend/src/features/controller/Bookmarks.css rename to apps/frontend/src/app/presenter/controller/bookmarks/index.css diff --git a/apps/frontend/src/features/controller/Bookmarks.tsx b/apps/frontend/src/app/presenter/controller/bookmarks/index.tsx similarity index 80% rename from apps/frontend/src/features/controller/Bookmarks.tsx rename to apps/frontend/src/app/presenter/controller/bookmarks/index.tsx index 03cfa3f8..5356b367 100644 --- a/apps/frontend/src/features/controller/Bookmarks.tsx +++ b/apps/frontend/src/app/presenter/controller/bookmarks/index.tsx @@ -1,6 +1,7 @@ -import './Bookmarks.css' +import './index.css' import { List, ListItem } from '@mui/material' +import { createFileRoute } from '@tanstack/react-router' import { useContext } from 'react' import { withNavigationHotkeys } from '~/components/NavigationHotkeys' @@ -33,7 +34,9 @@ const Bookmarks = ( { register, focused = 0 }: BookmarkProps ) => { ) } -export default withNavigationHotkeys( { - arrowKeys: true, - lineKeys: true, -} )( Bookmarks ) +export const Route = createFileRoute( '/presenter/controller/bookmarks/' )( { + component: withNavigationHotkeys( { + arrowKeys: true, + lineKeys: true, + } )( Bookmarks ), +} ) diff --git a/apps/frontend/src/features/controller/History.css b/apps/frontend/src/app/presenter/controller/history/index.css similarity index 100% rename from apps/frontend/src/features/controller/History.css rename to apps/frontend/src/app/presenter/controller/history/index.css diff --git a/apps/frontend/src/features/controller/History.tsx b/apps/frontend/src/app/presenter/controller/history/index.tsx similarity index 91% rename from apps/frontend/src/features/controller/History.tsx rename to apps/frontend/src/app/presenter/controller/history/index.tsx index 0330fa30..dd004356 100644 --- a/apps/frontend/src/features/controller/History.tsx +++ b/apps/frontend/src/app/presenter/controller/history/index.tsx @@ -1,4 +1,4 @@ -import './History.css' +import './index.css' import { faDownload, @@ -8,6 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemIcon from '@mui/material/ListItemIcon' +import { createFileRoute } from '@tanstack/react-router' import { stripVishraams } from 'gurmukhi-utils' import { useContext, useMemo } from 'react' @@ -78,7 +79,9 @@ const History = ( { register, focused = 0 }: HistoryProps ) => { ), [ register, focused, transitionHistory, latestLines ] ) } -export default withNavigationHotkeys( { - arrowKeys: true, - lineKeys: true, -} )( History ) +export const Route = createFileRoute( '/presenter/controller/history/' )( { + component: withNavigationHotkeys( { + arrowKeys: true, + lineKeys: true, + } )( History ), +} ) diff --git a/apps/frontend/src/app/presenter/controller/index.tsx b/apps/frontend/src/app/presenter/controller/index.tsx new file mode 100644 index 00000000..35231b2c --- /dev/null +++ b/apps/frontend/src/app/presenter/controller/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, Navigate } from '@tanstack/react-router' + +import { useControllerLocationHistory } from '../-controller-location-history' + +const Redirect = () => { + const lastControllerUrl = useControllerLocationHistory() + + return +} + +export const Route = createFileRoute( '/presenter/controller/' )( { + component: Redirect, +} ) diff --git a/apps/frontend/src/features/controller/ShabadInfo.css b/apps/frontend/src/app/presenter/controller/navigator/-components/ShabadInfo/index.css similarity index 100% rename from apps/frontend/src/features/controller/ShabadInfo.css rename to apps/frontend/src/app/presenter/controller/navigator/-components/ShabadInfo/index.css diff --git a/apps/frontend/src/features/controller/ShabadInfo.tsx b/apps/frontend/src/app/presenter/controller/navigator/-components/ShabadInfo/index.tsx similarity index 99% rename from apps/frontend/src/features/controller/ShabadInfo.tsx rename to apps/frontend/src/app/presenter/controller/navigator/-components/ShabadInfo/index.tsx index e009a9cf..19b5e57a 100644 --- a/apps/frontend/src/features/controller/ShabadInfo.tsx +++ b/apps/frontend/src/app/presenter/controller/navigator/-components/ShabadInfo/index.tsx @@ -1,4 +1,4 @@ -import './ShabadInfo.css' +import './index.css' import { faInfoCircle, faTimesCircle } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' diff --git a/apps/frontend/src/features/controller/Navigator.css b/apps/frontend/src/app/presenter/controller/navigator/index.css similarity index 100% rename from apps/frontend/src/features/controller/Navigator.css rename to apps/frontend/src/app/presenter/controller/navigator/index.css diff --git a/apps/frontend/src/features/controller/Navigator.tsx b/apps/frontend/src/app/presenter/controller/navigator/index.tsx similarity index 91% rename from apps/frontend/src/features/controller/Navigator.tsx rename to apps/frontend/src/app/presenter/controller/navigator/index.tsx index c74d5906..8d11ea16 100644 --- a/apps/frontend/src/features/controller/Navigator.tsx +++ b/apps/frontend/src/app/presenter/controller/navigator/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-multi-comp */ -import './Navigator.css' +import './index.css' import { faAngleDoubleLeft, @@ -13,25 +13,24 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' +import { createFileRoute, useLocation } from '@tanstack/react-router' import classNames from 'classnames' import { stripVishraams } from 'gurmukhi-utils' import { invert } from 'lodash' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { Navigate, useLocation } from 'react-router-dom' import GlobalHotKeys from '~/components/GlobalHotKeys' import { withNavigationHotkeys } from '~/components/NavigationHotkeys' import NavigatorHotKeys from '~/components/NavigatorHotkeys' import { getJumpLines, getNextJumpLine } from '~/helpers/auto-jump' -import { SEARCH_URL } from '~/helpers/consts' import { ContentContext, HistoryContext } from '~/helpers/contexts' import { LINE_HOTKEYS } from '~/helpers/keyMap' import { findLineIndex } from '~/helpers/line' import { useCurrentLines } from '~/hooks' import controller from '~/services/controller' -import ShabadInfo from './ShabadInfo' -import ToolbarButton from './ToolbarButton' +import ToolbarButton from '../-components/ToolbarButton' +import ShabadInfo from './-components/ShabadInfo' type NavigatorLineProps = { register: ( id, line, ) => any, @@ -44,12 +43,6 @@ type NavigatorLineProps = { timestamp?: string | null, } -/** -* Line component that attaches click handlers. -* @param gurmukhi The Gurmukhi for the line to render. -* @param id The id of the line. -* @param index The index of the line. -*/ const NavigatorLine = ( { id, register, @@ -100,15 +93,7 @@ type NavigatorProps = { focused?: string, } -/** - * Navigator Component. - * Displays lines from Shabad and allows navigation. - */ -const Navigator = ( { - updateFocus, register, focused, -}: NavigatorProps ) => { - const location = useLocation() - +const Navigator = ( { updateFocus, register, focused }: NavigatorProps ) => { const { viewedLines } = useContext( HistoryContext ) const content = useContext( ContentContext ) @@ -138,7 +123,7 @@ const Navigator = ( { } ), {} ), [] ) // If there's no Shabad to show, go back to the controller - if ( !lines.length ) return + // if ( !lines.length ) return const jumpLines = invert( getJumpLines( content ) ) const nextLineId = getNextJumpLine( content ) @@ -181,8 +166,6 @@ const NavigatorWithAllHotKeys = ( props ) => ( ) -export default NavigatorWithAllHotKeys - type BarProps = { onHover: ( text: string | null ) => Record, } @@ -289,3 +272,8 @@ export const Bar = ( { onHover }: BarProps ) => { ) } + +export const Route = createFileRoute( '/presenter/controller/navigator/' )( { + component: NavigatorWithAllHotKeys, + +} ) diff --git a/apps/frontend/src/features/controller/index.css b/apps/frontend/src/app/presenter/controller/route.css similarity index 100% rename from apps/frontend/src/features/controller/index.css rename to apps/frontend/src/app/presenter/controller/route.css diff --git a/apps/frontend/src/app/presenter/controller/route.tsx b/apps/frontend/src/app/presenter/controller/route.tsx new file mode 100644 index 00000000..d017a116 --- /dev/null +++ b/apps/frontend/src/app/presenter/controller/route.tsx @@ -0,0 +1,219 @@ +import './route.css' + +import { faStar } from '@fortawesome/free-regular-svg-icons' +import { + faCog, + faHistory, + faMap, + faSearch, + faSignOutAlt, + faVideoSlash, + faWindowMaximize, + faWindowMinimize, +} from '@fortawesome/free-solid-svg-icons' +import Toolbar from '@mui/material/Toolbar' +import Typography from '@mui/material/Typography' +import { createFileRoute, Outlet, ToPathOption, useLocation, useNavigate, useRouter } from '@tanstack/react-router' +import { zodSearchValidator } from '@tanstack/router-zod-adapter' +import classNames from 'classnames' +import { useContext, useEffect, useState } from 'react' +import { z } from 'zod' + +import { ContentContext, SettingsContext } from '~/helpers/contexts' +import { useCurrentLines, usePrevious } from '~/hooks' +import { useNavigateUtils } from '~/hooks/navigate' +import controller from '~/services/controller' + +import ToolbarButton from './-components/ToolbarButton' + +type OnHover = ( message: string | null ) => Record + +type TopBarProps = { + title?: string, + onHover?: OnHover, +} + +const TopBar = ( { title = '', onHover = () => ( {} ) }: TopBarProps ) => { + const resetHover = () => onHover( null ) + + const router = useRouter() + const navigate = Route.useNavigate() + const { open } = useNavigateUtils() + const { controllerOnly } = Route.useSearch() + + return ( + + open( { to: '/settings' } )} + onMouseEnter={() => onHover( 'Settings' )} + onMouseLeave={resetHover} + /> + + {title} + + void navigate( { to: '/' } )} + onMouseEnter={() => onHover( 'Hide Controller' )} + onMouseLeave={resetHover} + /> + + {controllerOnly ? ( + void navigate( { + search: ( s ) => ( { ...s, controllerOnly: undefined } ), + } )} + onMouseEnter={() => onHover( 'Minimize Controller' )} + onMouseLeave={resetHover} + /> + ) : ( + void navigate( { + search: ( s ) => ( { ...s, controllerOnly: true } ), + } )} + onMouseEnter={() => onHover( 'Maximize Controller' )} + onMouseLeave={resetHover} + /> + )} + + { + const { href } = router.buildLocation( { + search: ( s ) => ( { ...s, controllerOnly: true } ), + } ) + + window.open( href, '_blank' ) + }} + onMouseEnter={() => onHover( 'Pop Out Controller' )} + onMouseLeave={resetHover} + /> + + ) +} + +type BottomBarProps = { + onHover?: OnHover, + renderContent?: () => any, +} + +const BottomBar = ( { renderContent = () => null, onHover = () => ( {} ) }: BottomBarProps ) => { + const navigate = useNavigate() + + const lines = useCurrentLines() + + const resetHover = () => onHover( null ) + + return ( + + void navigate( { to: '/presenter/controller/search', search: ( s ) => s } )} onHover={onHover} /> + + void navigate( { to: '/presenter/controller/history', search: ( s ) => s } )} + onMouseEnter={() => onHover( 'History' )} + onMouseLeave={resetHover} + /> + + void navigate( { to: '/presenter/controller/bookmarks', search: ( s ) => s } )} + onMouseEnter={() => onHover( 'Bookmarks' )} + onMouseLeave={resetHover} + /> + +
{renderContent()}
+ + {!!lines.length && ( + void navigate( { to: '/presenter/controller/navigator', search: ( s ) => s } )} + onMouseEnter={() => onHover( 'Navigator' )} + onMouseLeave={resetHover} + /> + )} + + onHover( 'Clear' )} + onMouseLeave={resetHover} + /> +
+ ) +} + +const Controller = ( props ) => { + const { shabad, bani } = useContext( ContentContext ) + const lines = useCurrentLines() + + const previousLines = usePrevious( lines ) + + const [ hovered, setHovered ] = useState( null ) + + const pathname = useLocation( { select: ( l ) => l.pathname } ) + const navigate = Route.useNavigate() + + useEffect( () => { + const redirects = [ + '/presenter/controller/search', + '/presenter/controller/history', + '/presenter/controller/bookmarks', + ] satisfies ToPathOption['to'][] + + // Redirect to navigator tab if on one of the redirectable pages + const isTransition = lines.length && lines !== previousLines + + if ( isTransition && redirects.some( ( route ) => pathname.includes( route ) ) ) { + void navigate( { to: '/presenter/controller/navigator' } ) + } + }, [ history, lines, previousLines, navigate, pathname ] ) + + const settings = useContext( SettingsContext ) + const { local: { theme: { simpleGraphics: simple } } } = settings + + return ( +
+ + +
+ +
+ + BarComponent && ( + // + // )} + /> +
+ + ) +} + +export const Route = createFileRoute( '/presenter/controller' )( { + component: Controller, + validateSearch: zodSearchValidator( z.object( { + query: z.string().optional(), + } ) ), +} ) diff --git a/apps/frontend/src/features/controller/Search/Result.tsx b/apps/frontend/src/app/presenter/controller/search/-components/Result.tsx similarity index 84% rename from apps/frontend/src/features/controller/Search/Result.tsx rename to apps/frontend/src/app/presenter/controller/search/-components/Result.tsx index 1ff312b2..00325c21 100644 --- a/apps/frontend/src/features/controller/Search/Result.tsx +++ b/apps/frontend/src/app/presenter/controller/search/-components/Result.tsx @@ -20,20 +20,7 @@ type ResultProps = { translations: Record[], } -/** - * Renders a single result, highlighting the match. - * @param {string} gurmukhi The shabad line to display. - * @param {int} typeId The type id of line. - * @param {string} lineId The id of the line. - * @param {string} shabadId The id of the shabad. - * @param {int} sourceId The id of source. - * @param {Object} shabad The object containing section information and other metadata. - * @param {Boolean} focused Whether the line is focused or not. - * @param {Function} highlighter The match highlighter. - * @param {int} sourcePage The page number of shabad in source. - * @param {string} translations The translations of shabad line to display. - * @param {string} transliterations The transliterations of shabad line to display. - */ + const Result = forwardRef( ( { gurmukhi, typeId, diff --git a/apps/frontend/src/features/controller/Search/match-highlighter.ts b/apps/frontend/src/app/presenter/controller/search/-match-highlighter.ts similarity index 73% rename from apps/frontend/src/features/controller/Search/match-highlighter.ts rename to apps/frontend/src/app/presenter/controller/search/-match-highlighter.ts index 09c3c243..b391e8ef 100644 --- a/apps/frontend/src/features/controller/Search/match-highlighter.ts +++ b/apps/frontend/src/app/presenter/controller/search/-match-highlighter.ts @@ -7,12 +7,6 @@ type MatchOptions = { target: string, } -/** - * Highlights a full word query against a matched line. - * Finds the position to highlight in the target string using the Gurmukhi matched line, - * and using the position of the highlighted words, highlights the same words in the target - * string. - */ const fullWordMatches = ( query: string ) => ( { target, gurmukhi }: MatchOptions ) => { // Remove vishraams to prevent query from not matching const baseGurmukhi = stripVishraams( gurmukhi ) @@ -47,11 +41,6 @@ const fullWordMatches = ( query: string ) => ( { target, gurmukhi }: MatchOption ] } -/** - * Highlights a first letter query against a matched line. - * Finds the words to match in the Gurmukhi string, and highlights - * the corresponding target string. - */ const firstLetterMatches = ( query: string ) => ( { target, gurmukhi }: MatchOptions ) => { // Remove vishraams to prevent query from not matching const baseGurmukhi = stripVishraams( gurmukhi ) @@ -73,12 +62,6 @@ const firstLetterMatches = ( query: string ) => ( { target, gurmukhi }: MatchOpt ] } -/** - * Supported search mode match highlighters. - * Highlighters must support highlighting against a 1-1 transliteration or gurmukhi string. - * Highlighters all receive the same parameters. - * Highlights must return a tuple of [ beforeMatch, match, afterMatch ] - */ const highlighters = { [ SEARCH_TYPES.fullWord ]: fullWordMatches, [ SEARCH_TYPES.firstLetter ]: firstLetterMatches, @@ -88,15 +71,6 @@ const UNDERSCORE_REGEX = /_/g type SearchMode = ( typeof SEARCH_TYPES )[keyof typeof SEARCH_TYPES] -/** - * Separates the line into words before the first match, the first match, and after the match. - * @param target The text to highlight. - * @param context Contains gurmukhi and other contextual information required by all highlighters. - * @param searchQuery The string inputted by the user. - * @param searchMode The type of search being performed, either first word or full word. - * @return An array of [ beforeMatch, match, afterMatch ], - * with `match` being the highlighted section.`. - */ const getHighlighter = ( searchQuery: string, searchMode: SearchMode ) => ( context: Omit ) => ( target: string ) => { if ( !target ) return [ '', '', '' ] diff --git a/apps/frontend/src/features/controller/Search/index.css b/apps/frontend/src/app/presenter/controller/search/index.css similarity index 100% rename from apps/frontend/src/features/controller/Search/index.css rename to apps/frontend/src/app/presenter/controller/search/index.css diff --git a/apps/frontend/src/features/controller/Search/index.tsx b/apps/frontend/src/app/presenter/controller/search/index.tsx similarity index 79% rename from apps/frontend/src/features/controller/Search/index.tsx rename to apps/frontend/src/app/presenter/controller/search/index.tsx index 2cdd3f0e..c6c8fb1b 100644 --- a/apps/frontend/src/features/controller/Search/index.tsx +++ b/apps/frontend/src/app/presenter/controller/search/index.tsx @@ -3,10 +3,9 @@ import './index.css' import { faTimes } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { IconButton, Input, InputAdornment, List } from '@mui/material' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import classNames from 'classnames' -import { stringify } from 'qs' import { useCallback, useContext, useEffect, useRef, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' import { withNavigationHotkeys } from '~/components/NavigationHotkeys' import { @@ -16,27 +15,24 @@ import { SEARCH_TYPES, } from '~/helpers/consts' import { SettingsContext } from '~/helpers/contexts' -import { getUrlState } from '~/helpers/utils' import controller from '~/services/controller' -import getHighlighter from './match-highlighter' -import Result from './Result' +import Result from './-components/Result' +import getHighlighter from './-match-highlighter' // Generate the regex for capturing anchor chars, optionally const searchRegex = new RegExp( `^([${Object.keys( SEARCH_ANCHORS ).map( ( anchor ) => `\\${anchor}` ).join( '' )}])?(.*)` ) -const getSearchParams = ( searchQuery ) => { +const getSearchParams = ( searchQuery = '' ) => { // Extract anchors and search query - const [ , anchor, query ] = searchQuery.match( searchRegex ) - - const inputValue = query + const [ , anchor, query ] = searchQuery.match( searchRegex ) ?? [] // Get search type from anchor char, if any const type = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter const value = type === SEARCH_TYPES.firstLetter - ? inputValue.slice().replace( new RegExp( SEARCH_CHARS.wildcard, 'g' ), '_' ) - : inputValue + ? query.slice().replace( new RegExp( SEARCH_CHARS.wildcard, 'g' ), '_' ) + : query return { anchor, value, type } } @@ -47,11 +43,6 @@ type SearchProps = { focused?: string | number, } -/** - * Search Component. - * Converts ASCII to unicode on input. - * Displays results. - */ const Search = ( { updateFocus, register, focused }: SearchProps ) => { const { local: { search: { @@ -63,8 +54,7 @@ const Search = ( { updateFocus, register, focused }: SearchProps ) => { // Set the initial search query from URL const navigate = useNavigate() - const { search } = useLocation() - const { query = '' } = getUrlState( search ) + const { query } = Route.useSearch() const [ searchedValue, setSearchedValue ] = useState( '' ) @@ -78,22 +68,13 @@ const Search = ( { updateFocus, register, focused }: SearchProps ) => { const inputRef = useRef( null ) - /** - * Set the received results and update the searched vale. - * @param {Object[]} results An array of the returned results. - */ const onResults = useCallback( ( results ) => { setSearchedValue( inputValue.current ) setResults( results ) updateFocus( 0 ) }, [ updateFocus ] ) - /** - * Run on change of value in the search box. - * Converts ascii to unicode if need be. - * Sends the search through to the controller. - * @param {string} value The new value of the search box. - */ + const onChange = useCallback( ( { target: { value } } ) => { const { anchor, type: searchType, value: searchValue } = getSearchParams( value ) @@ -112,13 +93,15 @@ const Search = ( { updateFocus, register, focused }: SearchProps ) => { setAnchor( anchor ) // Update URL with search - navigate( { search: `?${stringify( { - ...getUrlState( search ), - query: value, - } )}` } ) + void navigate( { + search: ( s ) => ( { + ...s, + query: value, + } ), + replace: true, + } ) }, [ navigate, - search, resultTranslationLanguage, resultTransliterationLanguage, showResultCitations, @@ -205,11 +188,13 @@ const Search = ( { updateFocus, register, focused }: SearchProps ) => { ) } -export default withNavigationHotkeys( { - keymap: { - next: [ 'down', 'tab' ], - previous: [ 'up', 'shift+tab' ], - first: null, - last: null, - }, -} )( Search ) +export const Route = createFileRoute( '/presenter/controller/search/' )( { + component: withNavigationHotkeys( { + keymap: { + next: [ 'down', 'tab' ], + previous: [ 'up', 'shift+tab' ], + first: null, + last: null, + }, + } )( Search ), +} ) diff --git a/apps/frontend/src/features/presenter/index.css b/apps/frontend/src/app/presenter/route.css similarity index 100% rename from apps/frontend/src/features/presenter/index.css rename to apps/frontend/src/app/presenter/route.css diff --git a/apps/frontend/src/features/presenter/index.tsx b/apps/frontend/src/app/presenter/route.lazy.tsx similarity index 52% rename from apps/frontend/src/features/presenter/index.tsx rename to apps/frontend/src/app/presenter/route.lazy.tsx index e7054162..719756a9 100644 --- a/apps/frontend/src/features/presenter/index.tsx +++ b/apps/frontend/src/app/presenter/route.lazy.tsx @@ -1,14 +1,13 @@ -import './index.css' +import './route.css' import { faPlus } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import CssBaseline from '@mui/material/CssBaseline' +import { CssBaseline } from '@mui/material' import IconButton from '@mui/material/IconButton' +import { createLazyFileRoute, Outlet, useLocation } from '@tanstack/react-router' import classNames from 'classnames' -import queryString from 'qs' import { lazy, Suspense, useContext, useRef, useState } from 'react' import { EventsType, useIdleTimer } from 'react-idle-timer' -import { Route, Routes, useLocation, useNavigate } from 'react-router-dom' import CopyHotkeys from '~/components/CopyHotkeys' import { withErrorFallback } from '~/components/ErrorFallback' @@ -16,29 +15,20 @@ import GlobalHotKeys from '~/components/GlobalHotKeys' import Loader from '~/components/Loader' import NavigatorHotKeys from '~/components/NavigatorHotkeys' import ThemeLoader from '~/components/ThemeLoader' -import { - BOOKMARKS_URL, - CONTROLLER_URL, - HISTORY_URL, - isDesktop, - isMobile, - NAVIGATOR_URL, - SEARCH_URL, - SETTINGS_URL, - STATES, -} from '~/helpers/consts' +import { isDesktop } from '~/helpers/consts' import { SettingsContext } from '~/helpers/contexts' import { toggleFullscreen } from '~/helpers/electron-utils' import { GLOBAL_SHORTCUTS } from '~/helpers/keyMap' -import { OPTIONS } from '~/helpers/options' -import { getUrlState } from '~/helpers/utils' -import { useCurrentLines, useMount } from '~/hooks' +import { CLIENT_OPTIONS } from '~/helpers/options' +import { useCurrentLines } from '~/hooks' +import { useNavigateUtils } from '~/hooks/navigate' import controller from '~/services/controller' -import StatusToast from './StatusToast' +import StatusToast from './-components/StatusToast' +import { ControllerLocationHistoryProvider } from './-controller-location-history' +import { Route as PresenterRoute } from './route' -const Display = lazy( () => import( './Display' ) ) -const Controller = lazy( () => import( '~/features/controller' ) ) +const Display = lazy( () => import( './-components/Display' ) ) export const IDLE_TIMEOUT = 1000 * 3 const IDLE_EVENTS = [ @@ -54,10 +44,12 @@ const IDLE_EVENTS = [ ] as const satisfies EventsType[] const Presenter = () => { - const navigate = useNavigate() - const location = useLocation() - const { search, pathname } = location - const { controllerOnly } = getUrlState( search ) + const { open } = useNavigateUtils() + const navigate = PresenterRoute.useNavigate() + const pathname = useLocation( { select: ( l ) => l.pathname } ) + const { controllerOnly } = PresenterRoute.useSearch() + + const isControllerOpen = pathname.includes( '/presenter/controller' ) const [ isIdle, setIsIdle ] = useState( false ) @@ -71,8 +63,6 @@ const Presenter = () => { const lines = useCurrentLines() - const isControllerOpen = pathname.includes( CONTROLLER_URL ) - const { local: localSettings } = useContext( SettingsContext ) const { theme: { themeName }, @@ -80,70 +70,24 @@ const Presenter = () => { hotkeys, } = localSettings - /** - * Sets the query string parameters, retaining any currently present. - * @param params The query string parameters. - */ - const setQueryParams = ( params ) => navigate( { - ...location, - search: queryString.stringify( { ...getUrlState( search ), ...params } ), - } ) + const toggleController = () => void navigate( { to: isControllerOpen ? '/' : '/presenter/controller' } ) - /** - * More concise form to navigate to URLs, retaining query params. - * @param pathname The path to navigate to. - */ - const go = ( pathname ) => navigate( { ...location, pathname } ) - - /** - * Toggles the controller. - */ - const toggleController = () => { - const nextURL = pathname.includes( CONTROLLER_URL ) ? '/' : CONTROLLER_URL - go( nextURL ) - } - - /** - * Always puts the controller in fullscreen. - */ - const setFullscreenController = () => navigate( { - pathname: CONTROLLER_URL, - search: queryString.stringify( { [ STATES.controllerOnly ]: true } ), + const { controllerZoom } = CLIENT_OPTIONS + const setZoom = ( controllerZoom: number ) => controller.setSettings( { + layout: { controllerZoom }, } ) - const { controllerZoom } = OPTIONS - const setZoom = ( controllerZoom ) => controller.setSettings( { layout: { controllerZoom } } ) const zoomInController = () => setZoom( Math.min( controllerZoom.max, zoom + 0.1 ) ) const zoomOutController = () => setZoom( Math.max( controllerZoom.min, zoom - 0.1 ) ) const zoomResetController = () => setZoom( 1 ) - /** - * Toggles the given query string parameter. - * @param query The query string parameter to toggle. - */ - const toggleQuery = ( query ) => { - const parsed = getUrlState( search ) - - setQueryParams( { - ...parsed, - [ query ]: parsed[ query ] ? undefined : true, - } ) - } - - /** - * Toggles the controller in fullscreen. - */ const toggleFullscreenController = () => { - // Navigates to the controller first, if not there - if ( !pathname.includes( CONTROLLER_URL ) ) toggleController() - - toggleQuery( STATES.controllerOnly ) + void navigate( { + ...( !pathname.includes( '/presenter/controller' ) && { pathname: '/presenter/controller' } ), + search: ( s ) => ( { controllerOnly: !s.controllerOnly } ), + } ) } - /** - * Prevents the default action from occurring for each handler. - * @param events An object containing the event names and corresponding handlers. - */ const preventDefault = ( events ) => Object.entries( events ) .reduce( ( events, [ name, handler ] ) => ( { ...events, @@ -156,22 +100,24 @@ const Presenter = () => { [ GLOBAL_SHORTCUTS.zoomOutController.name ]: zoomOutController, [ GLOBAL_SHORTCUTS.zoomResetController.name ]: zoomResetController, [ GLOBAL_SHORTCUTS.toggleController.name ]: toggleController, - [ GLOBAL_SHORTCUTS.newController.name ]: () => controller.openWindow( `${CONTROLLER_URL}?${STATES.controllerOnly}=true`, { alwaysOnTop: true } ), - [ GLOBAL_SHORTCUTS.settings.name ]: () => controller.openWindow( SETTINGS_URL ), - [ GLOBAL_SHORTCUTS.search.name ]: () => go( SEARCH_URL ), - [ GLOBAL_SHORTCUTS.history.name ]: () => go( HISTORY_URL ), - [ GLOBAL_SHORTCUTS.bookmarks.name ]: () => go( BOOKMARKS_URL ), - [ GLOBAL_SHORTCUTS.navigator.name ]: () => lines.length && go( NAVIGATOR_URL ), + [ GLOBAL_SHORTCUTS.newController.name ]: () => { + open( { + search: ( s ) => ( { ...s, controllerOnly: true } ), + } ) + }, + [ GLOBAL_SHORTCUTS.settings.name ]: () => { + open( { to: '/settings' } ) + }, + [ GLOBAL_SHORTCUTS.search.name ]: () => void navigate( { to: '/presenter/controller/search' } ), + [ GLOBAL_SHORTCUTS.history.name ]: () => void navigate( { to: '/presenter/controller/history' } ), + [ GLOBAL_SHORTCUTS.bookmarks.name ]: () => void navigate( { to: '/presenter/controller/bookmarks' } ), + [ GLOBAL_SHORTCUTS.navigator.name ]: () => lines.length && void navigate( { to: '/presenter/controller/navigator' } ), [ GLOBAL_SHORTCUTS.clearDisplay.name ]: controller.clear, [ GLOBAL_SHORTCUTS.toggleFullscreenController.name ]: toggleFullscreenController, [ GLOBAL_SHORTCUTS.toggleFullscreen.name ]: toggleFullscreen, [ GLOBAL_SHORTCUTS.quit.name ]: window.close, } ) - useMount( () => { - if ( isMobile ) setFullscreenController() - } ) - // Required for mouse shortcuts const presenterRef = useRef( null ) @@ -193,12 +139,9 @@ const Presenter = () => { - - } - /> - + + + @@ -210,4 +153,6 @@ const Presenter = () => { ) } -export default withErrorFallback( Presenter ) +export const Route = createLazyFileRoute( '/presenter' )( { + component: withErrorFallback( Presenter ), +} ) diff --git a/apps/frontend/src/app/presenter/route.tsx b/apps/frontend/src/app/presenter/route.tsx new file mode 100644 index 00000000..3e71a791 --- /dev/null +++ b/apps/frontend/src/app/presenter/route.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { zodSearchValidator } from '@tanstack/router-zod-adapter' +import { z } from 'zod' + +import { isMobile } from '~/helpers/consts' + +export const Route = createFileRoute( '/presenter' )( { + beforeLoad: () => { + if ( isMobile ) { + redirect( { + throw: true, + to: '/presenter/controller', + search: { + controllerOnly: true, + }, + } ) + } + }, + validateSearch: zodSearchValidator( z.object( { + controllerOnly: z.boolean().optional(), + } ) ), +} ) diff --git a/apps/frontend/src/features/screen-reader/index.css b/apps/frontend/src/app/screen-reader/index.css similarity index 100% rename from apps/frontend/src/features/screen-reader/index.css rename to apps/frontend/src/app/screen-reader/index.css diff --git a/apps/frontend/src/features/screen-reader/index.tsx b/apps/frontend/src/app/screen-reader/index.lazy.tsx similarity index 92% rename from apps/frontend/src/features/screen-reader/index.tsx rename to apps/frontend/src/app/screen-reader/index.lazy.tsx index 7aa648ab..685065d2 100644 --- a/apps/frontend/src/features/screen-reader/index.tsx +++ b/apps/frontend/src/app/screen-reader/index.lazy.tsx @@ -1,5 +1,6 @@ import './index.css' +import { createLazyFileRoute } from '@tanstack/react-router' import classNames from 'classnames' import { useContext } from 'react' @@ -80,4 +81,6 @@ const ScreenReader = () => { ) } -export default ScreenReader +export const Route = createLazyFileRoute( '/screen-reader/' )( { + component: ScreenReader, +} ) diff --git a/apps/frontend/src/features/settings/CopyButton.css b/apps/frontend/src/app/settings/-components/CopyButton/index.css similarity index 100% rename from apps/frontend/src/features/settings/CopyButton.css rename to apps/frontend/src/app/settings/-components/CopyButton/index.css diff --git a/apps/frontend/src/features/settings/CopyButton.tsx b/apps/frontend/src/app/settings/-components/CopyButton/index.tsx similarity index 96% rename from apps/frontend/src/features/settings/CopyButton.tsx rename to apps/frontend/src/app/settings/-components/CopyButton/index.tsx index e92569db..e80c480c 100644 --- a/apps/frontend/src/features/settings/CopyButton.tsx +++ b/apps/frontend/src/app/settings/-components/CopyButton/index.tsx @@ -1,4 +1,4 @@ -import './CopyButton.css' +import './index.css' import { Button, Tooltip } from '@mui/material' diff --git a/apps/frontend/src/features/settings/DynamicOptions.tsx b/apps/frontend/src/app/settings/-components/DynamicOptions.tsx similarity index 86% rename from apps/frontend/src/features/settings/DynamicOptions.tsx rename to apps/frontend/src/app/settings/-components/DynamicOptions.tsx index bdc49b67..23ba881e 100644 --- a/apps/frontend/src/features/settings/DynamicOptions.tsx +++ b/apps/frontend/src/app/settings/-components/DynamicOptions.tsx @@ -5,10 +5,10 @@ import { ClientSettings, Settings } from '@presenter/contract' import { useContext } from 'react' import { SettingsContext } from '~/helpers/contexts' -import { DEFAULT_OPTIONS, FLAT_OPTION_GROUPS, OPTIONS, PRIVACY_TYPES } from '~/helpers/options' +import { CLIENT_OPTIONS, DEFAULT_OPTIONS, FLAT_OPTION_GROUPS } from '~/helpers/options' import controller from '~/services/controller' -import SettingComponentFactory, { Button } from './SettingComponents' +import SettingComponentFactory, { Button } from './SettingsComponents' export const slotSizes = { icon: { xs: 2, sm: 1 }, @@ -71,7 +71,6 @@ export const ResetButton = ( { group, disabled = false, device }: ResetButtonPro + @@ -55,7 +47,7 @@ const OverlaySettings = () => { Overlay URL {Object.entries( addresses ).map( ( [ name, address ] ) => ( - {`${address}:${OVERLAY_PORT}/overlay (${name})`} + {`${address}:${PORT}/overlay (${name})`} ) )} @@ -65,4 +57,16 @@ const OverlaySettings = () => { ) } -export default OverlaySettings +type AboutType = { + addresses: string[], +} + +export const Route = createFileRoute( '/settings/tools/overlay/' )( { + component: OverlaySettings, + loader: async () => { + const about = await fetch( `${API_URL}/about` ) + .then( ( res ) => res.json() as Promise ) + + return { about } + }, +} ) diff --git a/apps/frontend/src/components/Empty.tsx b/apps/frontend/src/components/Empty.tsx new file mode 100644 index 00000000..a3862184 --- /dev/null +++ b/apps/frontend/src/components/Empty.tsx @@ -0,0 +1,3 @@ +const Empty = () => null + +export default Empty diff --git a/apps/frontend/src/components/NavigationHotkeys.tsx b/apps/frontend/src/components/NavigationHotkeys.tsx index 401e14ea..bbc71ded 100644 --- a/apps/frontend/src/components/NavigationHotkeys.tsx +++ b/apps/frontend/src/components/NavigationHotkeys.tsx @@ -36,14 +36,6 @@ type State = { focusedIndex: number, } -/** - * HOC to automatically add navigational key bindings to child elements. - * @param {boolean} arrowKeys Navigate with arrow keys to the next and previous DOM elements. - * @param {boolean} lineKeys Enable line jumping via hotkeys. - * @param {boolean} clickOnFocus Simulate a click on the item that is newly focused. - * @param {Object} keymap Keymap to combine with existing keymap. - * @returns {Component} The decorated component. - */ export const withNavigationHotkeys = ( { arrowKeys = true, lineKeys, @@ -86,16 +78,10 @@ export const withNavigationHotkeys = ( { this.setFocus() } - /** - * Sets the length of the nodes to the correct size. - */ setNodeSize = () => this.nodes.forEach( ( value, key ) => ( ( value || this.nodes.delete( key ) ) ) ) - /** - * Sets the focus in the DOM to the `focusedIndex`'th element of the children. - */ setFocus = () => { const { focusedIndex } = this.state @@ -105,9 +91,6 @@ export const withNavigationHotkeys = ( { if ( node ) scrollIntoCenter( node ) } - /** - * Simulates a click on the focused component. - */ simulateClick = debounceHotKey( () => { const { focusedIndex } = this.state @@ -119,21 +102,11 @@ export const withNavigationHotkeys = ( { } } ) - /** - * Jump to an item given it's name/identifier. - * @param name The name of the element. - * @param click Trigger the click. - */ jumpToName = ( name: string, click = true ) => this.jumpTo( [ ...this.nodes.keys() ].findIndex( ( key ) => key === name ), click, ) - /** - * Jumps to an element. - * @param focusedIndex The element index to jump to. - * @param click Trigger the click. - */ jumpTo = ( focusedIndex: number, click = true ) => { this.setState( { focusedIndex } ) @@ -143,18 +116,12 @@ export const withNavigationHotkeys = ( { } } - /** - * Jumps to the first element, excluding inputs. - */ jumpToFirst = () => { const index = [ ...this.nodes.values() ].findIndex( ( element ) => !isInput( element ) ) this.jumpTo( index ) } - /** - * Focuses the previous item in the list of elements. - */ prevItem = () => { const { focusedIndex: prevIndex } = this.state @@ -166,9 +133,6 @@ export const withNavigationHotkeys = ( { this.jumpTo( focusedIndex ) } - /** - * Focuses the next item in the list of elements. - */ nextItem = () => { const { focusedIndex: prevIndex } = this.state @@ -180,16 +144,8 @@ export const withNavigationHotkeys = ( { this.jumpTo( focusedIndex ) } - /** - * Registers the ref under the current list of nodes. - * @param name The name to identify the ref. - * @param ref The ref to store. - */ registerRef = ( name, ref ) => this.nodes.set( name, ref ) - /** - * Generates handlers for each of the nodes, using the keys from LINE HOTKEYS to jump to them. - */ lineHandlers = LINE_HOTKEYS.reduce( ( handlers, key, i ) => ( { ...handlers, [ key ]: () => this.jumpTo( i ), diff --git a/apps/frontend/src/components/NavigatorHotkeys.tsx b/apps/frontend/src/components/NavigatorHotkeys.tsx index 1d791f97..b9669494 100644 --- a/apps/frontend/src/components/NavigatorHotkeys.tsx +++ b/apps/frontend/src/components/NavigatorHotkeys.tsx @@ -16,9 +16,6 @@ type NavigatorHotKeysProps = { mouseTargetRef?: { current: EventTarget | null }, } -/** - * Hotkeys for controlling the navigator. - */ const NavigatorHotKeys = ( { active = false, children = null, mouseTargetRef = { current: null } }: NavigatorHotKeysProps ) => { @@ -114,10 +111,6 @@ const NavigatorHotKeys = ( controller.line( id ) } - /** - * Prevents the default action from occurring for each handler. - * @param events An object containing the event names and corresponding handlers. - */ const preventDefault = ( events ) => Object.entries( events ) .reduce( ( events, [ name, handler ] ) => ( { ...events, diff --git a/apps/frontend/src/components/ThemeLoader.tsx b/apps/frontend/src/components/ThemeLoader.tsx index 310a5dfa..135d2a97 100644 --- a/apps/frontend/src/components/ThemeLoader.tsx +++ b/apps/frontend/src/components/ThemeLoader.tsx @@ -1,9 +1,11 @@ import defaultTheme from '@presenter/themes/presenter/Day.css?url' import { useContext } from 'react' -import { PRESENTER_THEMES_URL } from '~/helpers/consts' +import { API_URL } from '~/helpers/consts' import { StatusContext } from '~/helpers/contexts' +const PRESENTER_THEMES_URL = `${API_URL}/themes/presenter` + type ThemeLoaderProps = { name: string } const ThemeLoader = ( { name = 'Day' }: ThemeLoaderProps ) => { diff --git a/apps/frontend/src/features/controller/index.tsx b/apps/frontend/src/features/controller/index.tsx deleted file mode 100644 index 97016468..00000000 --- a/apps/frontend/src/features/controller/index.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import './index.css' - -import { faStar } from '@fortawesome/free-regular-svg-icons' -import { - faCog, - faHistory, - faMap, - faSearch, - faSignOutAlt, - faVideoSlash, - faWindowMaximize, - faWindowMinimize, -} from '@fortawesome/free-solid-svg-icons' -import Toolbar from '@mui/material/Toolbar' -import Typography from '@mui/material/Typography' -import classNames from 'classnames' -import queryString from 'qs' -import { useContext, useEffect, useState } from 'react' -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' - -import { - BOOKMARKS_URL, - CONTROLLER_URL, - HISTORY_URL, - NAVIGATOR_URL, - PRESENTER_URL, - SEARCH_URL, - SETTINGS_URL, - STATES, -} from '~/helpers/consts' -import { ContentContext, SettingsContext } from '~/helpers/contexts' -import { getUrlState } from '~/helpers/utils' -import { useCurrentLines, useEffectOnce, usePrevious } from '~/hooks' -import controller from '~/services/controller' - -import Bookmarks from './Bookmarks' -import History from './History' -import Navigator, { Bar as NavigatorBar } from './Navigator' -import Search from './Search' -import ToolbarButton from './ToolbarButton' - -type OnHover = ( message: string | null ) => Record - -type TopBarProps = { - title?: string, - onHover?: OnHover, -} - -/** - * Renders the top navigation bar, showing the current path in the URL, and the hover state. - * @param title The title text in the top bar. - * @param onHover Fired on hover with name. - */ -const TopBar = ( { title = '', onHover = () => ( {} ) }: TopBarProps ) => { - const resetHover = () => onHover( null ) - - const location = useLocation() - const { search, pathname } = location - const navigate = useNavigate() - - const state = getUrlState( search ) - - return ( - - controller.openWindow( SETTINGS_URL )} - onMouseEnter={() => onHover( 'Settings' )} - onMouseLeave={resetHover} - /> - {title} - navigate( PRESENTER_URL )} - onMouseEnter={() => onHover( 'Hide Controller' )} - onMouseLeave={resetHover} - /> - {state[ STATES.controllerOnly ] ? ( - navigate( { - ...location, - search: queryString.stringify( { ...state, [ STATES.controllerOnly ]: undefined } ), - } )} - onMouseEnter={() => onHover( 'Minimize Controller' )} - onMouseLeave={resetHover} - /> - ) : ( - navigate( { - ...location, - search: queryString.stringify( { ...state, [ STATES.controllerOnly ]: true } ), - } )} - onMouseLeave={resetHover} - /> - )} - { - const popOutQuery = queryString.stringify( { ...state, [ STATES.controllerOnly ]: true } ) - - controller.openWindow( `${pathname}?${popOutQuery}`, { alwaysOnTop: true } ) - navigate( PRESENTER_URL ) - }} - onMouseEnter={() => onHover( 'Pop Out Controller' )} - onMouseLeave={resetHover} - /> - - ) -} - -type BottomBarProps = { - onHover?: OnHover, - renderContent?: () => any, -} - -/** - * Renders the bottom navigation bar. - * @param history A `history` object. - * @param renderContent A render prop for content in the bottom bar. - * @param location A `location` object. - * @param onHover Fired on hover with name. - */ -const BottomBar = ( { renderContent = () => null, onHover = () => ( {} ) }: BottomBarProps ) => { - const navigate = useNavigate() - const location = useLocation() - - const lines = useCurrentLines() - - const go = ( pathname: string ) => () => navigate( { ...location, pathname } ) - const resetHover = () => onHover( null ) - - return ( - - - onHover( 'History' )} - onMouseLeave={resetHover} - /> - onHover( 'Bookmarks' )} - onMouseLeave={resetHover} - /> -
{renderContent()}
- {!!lines.length && ( - onHover( 'Navigator' )} - onMouseLeave={resetHover} - /> - )} - onHover( 'Clear' )} - onMouseLeave={resetHover} - /> -
- ) -} - -/** - * Controller controls the display and configures settings. - */ -const Controller = ( props ) => { - const { shabad, bani } = useContext( ContentContext ) - const lines = useCurrentLines() - - const previousLines = usePrevious( lines ) - - const [ hovered, setHovered ] = useState( null ) - - const location = useLocation() - const { search } = location - - const navigate = useNavigate() - - const [ lastUrl, setLastUrl ] = useState( `${NAVIGATOR_URL}${search}` ) - - // Save navigation to any subroutes by listening to history - useEffectOnce( () => history.listen( ( { pathname, search } ) => { - if ( pathname.match( `${CONTROLLER_URL}/.*` ) ) setLastUrl( `${pathname}${search}` ) - } ) ) - - useEffect( () => { - const { pathname } = location - const redirects = [ SEARCH_URL, HISTORY_URL, BOOKMARKS_URL ] - - // Redirect to navigator tab if on one of the redirectable pages - const isTransition = lines.length && lines !== previousLines - - if ( isTransition && redirects.some( ( route ) => pathname.includes( route ) ) ) { - navigate( { ...location, pathname: NAVIGATOR_URL } ) - } - }, [ history, lines, previousLines, location ] ) - - const settings = useContext( SettingsContext ) - const { local: { theme: { simpleGraphics: simple } } } = settings - - const routes = [ - [ SEARCH_URL, Search ], - [ NAVIGATOR_URL, Navigator, NavigatorBar ], - [ HISTORY_URL, History ], - [ BOOKMARKS_URL, Bookmarks ], - ] - - return ( - - {routes.map( ( [ route, Component, BarComponent ] ) => ( - - - -
- -
- - BarComponent && ( - - )} - /> - - )} - /> - ) )} - - } /> -
- ) -} - -export default Controller diff --git a/apps/frontend/src/features/settings/About.css b/apps/frontend/src/features/settings/About.css deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/helpers/auto-jump.ts b/apps/frontend/src/helpers/auto-jump.ts index 289eeee8..337ef970 100644 --- a/apps/frontend/src/helpers/auto-jump.ts +++ b/apps/frontend/src/helpers/auto-jump.ts @@ -26,10 +26,6 @@ const isBaniJumpLine = ( baniId, lines ) => ( return filter() } -/** - * Produces a map of the line hotkey that corresponds to the line index. - * @param {*} An Object containing a shabad or bani, which contains lines. - */ export const getJumpLines = memoize( ( { shabad, bani } ) => { if ( !( shabad || bani ) ) return {} @@ -144,10 +140,6 @@ export const getBaniNextJumpLine = ( { bani, lineId } ) => { return baniNextLineId } -/** - * Gets the next jump line id for a shabad or bani. - * @param {*} An Object containing a shabad or bani, which contains lines. - */ export const getNextJumpLine = ( { nextLineId, shabad, bani, lineId } ) => { if ( !( shabad || bani ) ) return null diff --git a/apps/frontend/src/helpers/consts.ts b/apps/frontend/src/helpers/consts.ts index c6568369..2bb2697f 100644 --- a/apps/frontend/src/helpers/consts.ts +++ b/apps/frontend/src/helpers/consts.ts @@ -1,11 +1,6 @@ import { detect } from 'detect-browser' import detectMobile from 'is-mobile' -/** - * Application Constants - * @ignore - */ - export const isElectron = navigator.userAgent.indexOf( 'Electron' ) > -1 export const isDev = process.env.NODE_ENV !== 'production' @@ -27,33 +22,7 @@ export const WS_URL = `ws://${BASE_URL}/api` export const SENTRY_DSN = 'https://51b714c1e7544cba86efb2cad85152ff@sentry.io/1363390' export const SENTRY_PROJECT = 'desktop-frontend' -/* Navigator */ -// URLs -export const PRESENTER_URL = '' -export const CONTROLLER_URL = `${PRESENTER_URL}/controller` -export const SEARCH_URL = `${CONTROLLER_URL}/search` -export const BOOKMARKS_URL = `${CONTROLLER_URL}/bookmarks` -export const NAVIGATOR_URL = `${CONTROLLER_URL}/navigator` -export const HISTORY_URL = `${CONTROLLER_URL}/history` -export const HISTORY_DOWNLOAD_URL = `${BACKEND_URL}/history.csv` -export const PRESENTER_THEMES_URL = `${BACKEND_URL}/themes/presenter` -export const OVERLAY_THEMES_URL = `${BACKEND_URL}/themes/overlay` - -export const SETTINGS_URL = '/settings' -export const SETTINGS_DEVICE_URL = `${SETTINGS_URL}/device` -export const SETTINGS_SERVER_URL = `${SETTINGS_URL}/server` - -export const SETTINGS_ABOUT_URL = `${SETTINGS_SERVER_URL}/about` -export const SETTINGS_TOOLS_URL = `${SETTINGS_URL}/tools` - -export const OVERLAY_URL = '/overlay' -export const SCREEN_READER_URL = '/screenreader' - -// URL states -export const STATES = { - controllerOnly: 'controllerOnly', // Fullscreen controller - query: 'query', // Search query -} +export const HISTORY_DOWNLOAD_URL = `${BASE_URL}/history.csv` // Search export const MIN_SEARCH_CHARS = 2 @@ -70,9 +39,9 @@ export const SEARCH_CHARS = { wordAnywhere: '#', wordOrder: '^', larivaarAccentless: '%', -} +} as const // Search modifier anchors export const SEARCH_ANCHORS = { [ SEARCH_CHARS.wordAnywhere ]: SEARCH_TYPES.fullWord, -} +} as const diff --git a/apps/frontend/src/helpers/contexts.tsx b/apps/frontend/src/helpers/contexts.tsx index e893cf07..4d41b765 100644 --- a/apps/frontend/src/helpers/contexts.tsx +++ b/apps/frontend/src/helpers/contexts.tsx @@ -1,4 +1,4 @@ -import { Line, Shabad, Bani } from '@presenter/contract' +import { Bani, Line, Shabad } from '@presenter/contract' import { ComponentType, Context, createContext } from 'react' import { SettingsState } from './options' diff --git a/apps/frontend/src/helpers/keyMap.ts b/apps/frontend/src/helpers/keyMap.ts index 642b2386..13ff3720 100644 --- a/apps/frontend/src/helpers/keyMap.ts +++ b/apps/frontend/src/helpers/keyMap.ts @@ -7,16 +7,12 @@ type Keymap = { required?: boolean, } -/** - * Adds a group to a given keymap. - * @param {*} keymap The keymap to decorate. - */ -const decorateGroup = ( keymap: { [hotkey: string]: Keymap } ) => ( group: string ) => Object +const decorateGroup = ( keymap: Record ) => ( group: string ) => Object .entries( keymap ) .reduce( ( obj, [ name, content ] ) => ( { ...obj, [ name ]: { group, ...content }, - } ), {} as { [name: string]: Keymap & { group: string } } ) + } ), {} as Record ) // Jump to navigation line ordered hot keys export const LINE_HOTKEYS = Array.from( '1234567890qwertyuiopasdfg' ) diff --git a/apps/frontend/src/helpers/line.ts b/apps/frontend/src/helpers/line.ts index 66830fb0..ed6e6cf0 100644 --- a/apps/frontend/src/helpers/line.ts +++ b/apps/frontend/src/helpers/line.ts @@ -16,10 +16,6 @@ export const sortBy = ( [ languageB ]: [string, any] ) => sortOrder[ languageA ] - sortOrder[ languageB ] -/** - * Produces a map of the line hotkey that corresponds to the line index. - * @param {*} An Object containing a shabad or bani, which contains lines. - */ export const findLineIndex = memoize( ( lines: Line[], lineId: string ) => lines.findIndex( ( { id } ) => id === lineId ), { @@ -32,12 +28,6 @@ export const findLineIndex = memoize( type CustomiseLineParams = { lineEnding: boolean, typeId: number } type LineTransformer = [boolean, ( text: string ) => string] -/** - * - * @param {string} line The line to modify. - * @param {Object} settings Different boolean values for transformations. - * @returns {string} With different transformations applied. - */ export const customiseLine = ( line: string, { lineEnding, typeId }: CustomiseLineParams ) => ( [ [ lineEnding, stripEndings ], ] as LineTransformer[] ) @@ -47,11 +37,6 @@ export const customiseLine = ( line: string, { lineEnding, typeId }: CustomiseLi typeId === LINE_TYPES.sirlekh ? line : fn( line ) ), line ) -/** - * Classifies the pause for a single word, returning an object of the word and type. - * @param word The word to classify. - * @param strip Whether or not to strip the vishraam character. - */ export const classifyWord = ( word: string, strip = true ) => ( { word: strip ? stripVishraams( word ) : word, type: Object @@ -62,19 +47,10 @@ export const classifyWord = ( word: string, strip = true ) => ( { word.slice( -1 ) === pauseChar ? pauseType : type ), null ), } ) -/** - * Returns an array of objects with their text and pause type. - * @param line The line to process. - * @param strip Whether or not to strip vishraam characters. - */ export const classifyWords = ( line: string, strip = true ) => line.split( ' ' ).map( ( word ) => classifyWord( word, strip ) ) - type ClassifiedWords = { type: string | null, word: string } - /** - * Partitions the line by heavy pause into arrays. - * @param line The line to partition. - * @param strip Whether or not to strip vishraam chars from the word. - */ +type ClassifiedWords = { type: string | null, word: string } + export const partitionLine = ( line: string, strip = true ) => classifyWords( line, strip ) .reduce( ( words: ClassifiedWords[][], { type, word } ) => { // Get last list of words, removing it from the words list @@ -94,20 +70,13 @@ export const partitionLine = ( line: string, strip = true ) => classifyWords( li recommendedSources: Source, languageId: number, } - /** - * Returns the corresponding translation for a given line. - * @param {Object} [shabad] The current shabad. - * @param {Line} line The current line. - * @param {Source} sources Any sources. - * @param {Source} recommendedSources Any sources. - * @param {number} languageId The identifier of the language. - */ + export const getTranslation = ( { shabad, line, sources, recommendedSources, languageId }: GetTranslationParams ) => { const { sourceId } = shabad || line.shabad - if ( !( sources && sources[ sourceId ] ) ) return null + if ( !( sources?.[ sourceId ] ) ) return null const { id: translationId } = sources[ sourceId ].translationSources[ languageId ] || recommendedSources[ sourceId ].translationSources[ languageId ] @@ -140,9 +109,6 @@ export const getTranslations = ( { languageIds, line, ...rest }: GetTranslations }, {} as Translations ) } -/** - * Returns the corresponding transliteration functions, mapped by language id. - */ export const getTransliterators = ( languageIds: number[] ) => ( languageIds || [] ) .filter( ( id ) => id && TRANSLITERATORS[ id ] ) .reduce( ( translations, languageId ) => ( { diff --git a/apps/frontend/src/helpers/options.ts b/apps/frontend/src/helpers/options.ts index 7b9d85da..25295149 100644 --- a/apps/frontend/src/helpers/options.ts +++ b/apps/frontend/src/helpers/options.ts @@ -3,6 +3,7 @@ import { faClosedCaptioning as farClosedCaptioning, faKeyboard, faPauseCircle, + IconDefinition, } from '@fortawesome/free-regular-svg-icons' import { faAlignCenter, @@ -52,49 +53,75 @@ import { } from '@fortawesome/free-solid-svg-icons' import type { RecommendedSources } from '@presenter/contract' -import { BACKEND_URL } from './consts' +import { API_URL } from './consts' import { LANGUAGES } from './data' import SHORTCUTS from './keyMap' -/** - * Options for settings. - */ +type CommonOption = { + name: string, + icon: IconDefinition, +} + +type DropdownOption = CommonOption & { + type: 'dropdown', + values: { name: string, value: T }[], +} + +type SliderOption = CommonOption & { + type: 'slider', + min: number, + max: number, + step: number, +} + +type ColorPickerOption = CommonOption & { + type: 'colorPicker', +} + +type TextInputOption = CommonOption & { + type: 'textInput', +} -// Unique symbols for each option type -export const OPTION_TYPES = { - dropdown: Symbol( 'Dropdown' ), - toggle: Symbol( 'Toggle' ), - slider: Symbol( 'Slider' ), - colorPicker: Symbol( 'Color Picker' ), - urlDropdown: Symbol( 'URL Dropdown' ), - textInput: Symbol( 'Text Input' ), +type ToggleOption = CommonOption & { + type: 'toggle', } -export const PRIVACY_TYPES = { - local: Symbol( 'Local' ), - private: Symbol( 'Private Locally' ), - global: Symbol( 'Server Global' ), +type UrlDropdownOption = CommonOption & { + type: 'urlDropdown', + values: string[], + url: string, } -// Option names and possible values -export const OPTIONS = { - controllerZoom: { name: 'Controller Zoom', icon: faSearchPlus, type: OPTION_TYPES.slider, min: 0.1, max: 2.5, step: 0.1, privacy: PRIVACY_TYPES.local }, - presenterFontSize: { name: 'Font Size', icon: faFont, type: OPTION_TYPES.slider, min: 3, max: 13, step: 0.1, privacy: PRIVACY_TYPES.local }, - relativeGurmukhiFontSize: { name: 'Relative Gurmukhi Font Size', icon: faPercentage, type: OPTION_TYPES.slider, min: 0.5, max: 1.5, step: 0.01, privacy: PRIVACY_TYPES.local }, - relativeEnglishFontSize: { name: 'Relative Latin Font Size', icon: faPercentage, type: OPTION_TYPES.slider, min: 0.5, max: 1.5, step: 0.01, privacy: PRIVACY_TYPES.local }, - relativePunjabiFontSize: { name: 'Relative Punjabi Font Size', icon: faPercentage, type: OPTION_TYPES.slider, min: 0.5, max: 1.5, step: 0.01, privacy: PRIVACY_TYPES.local }, - relativeHindiFontSize: { name: 'Relative Hindi Font Size', icon: faPercentage, type: OPTION_TYPES.slider, min: 0.5, max: 1.5, step: 0.01, privacy: PRIVACY_TYPES.local }, - relativeUrduFontSize: { name: 'Relative Urdu Font Size', icon: faPercentage, type: OPTION_TYPES.slider, min: 0.5, max: 1.5, step: 0.01, privacy: PRIVACY_TYPES.local }, - centerText: { name: 'Center Align', icon: faAlignCenter, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - justifyText: { name: 'Justify Multiple Lines', icon: faAlignJustify, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - inlineTransliteration: { name: 'Inline Transliterations', icon: faCompressAlt, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - inlineColumnGuides: { name: 'Inline Column Guides', icon: faCompressAlt, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - splitOnVishraam: { name: 'Primary Pause Wrap Gurbani', icon: faParagraph, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, +type Option = DropdownOption + | SliderOption + | ColorPickerOption + | TextInputOption + | ToggleOption + | UrlDropdownOption + +type ClientOption = Option & { + isProtected?: boolean, +} + +type ServerOption = Option + +export const CLIENT_OPTIONS = { + controllerZoom: { name: 'Controller Zoom', icon: faSearchPlus, type: 'slider', min: 0.1, max: 2.5, step: 0.1 }, + presenterFontSize: { name: 'Font Size', icon: faFont, type: 'slider', min: 3, max: 13, step: 0.1 }, + relativeGurmukhiFontSize: { name: 'Relative Gurmukhi Font Size', icon: faPercentage, type: 'slider', min: 0.5, max: 1.5, step: 0.01 }, + relativeEnglishFontSize: { name: 'Relative Latin Font Size', icon: faPercentage, type: 'slider', min: 0.5, max: 1.5, step: 0.01 }, + relativePunjabiFontSize: { name: 'Relative Punjabi Font Size', icon: faPercentage, type: 'slider', min: 0.5, max: 1.5, step: 0.01 }, + relativeHindiFontSize: { name: 'Relative Hindi Font Size', icon: faPercentage, type: 'slider', min: 0.5, max: 1.5, step: 0.01 }, + relativeUrduFontSize: { name: 'Relative Urdu Font Size', icon: faPercentage, type: 'slider', min: 0.5, max: 1.5, step: 0.01 }, + centerText: { name: 'Center Align', icon: faAlignCenter, type: 'toggle' }, + justifyText: { name: 'Justify Multiple Lines', icon: faAlignJustify, type: 'toggle' }, + inlineTransliteration: { name: 'Inline Transliterations', icon: faCompressAlt, type: 'toggle' }, + inlineColumnGuides: { name: 'Inline Column Guides', icon: faCompressAlt, type: 'toggle' }, + splitOnVishraam: { name: 'Primary Pause Wrap Gurbani', icon: faParagraph, type: 'toggle' }, spacing: { name: 'Current Line Spacing', - type: OPTION_TYPES.dropdown, + type: 'dropdown', icon: faTextHeight, - privacy: PRIVACY_TYPES.local, values: [ { name: 'Space Between', value: 'space-between' }, { name: 'Space Around', value: 'space-around' }, @@ -104,47 +131,36 @@ export const OPTIONS = { { name: 'Bottom', value: 'flex-end' }, ], }, - previousLines: { name: 'Previous Lines', icon: faAlignJustify, type: OPTION_TYPES.slider, max: 5, step: 1, privacy: PRIVACY_TYPES.local }, - nextLines: { name: 'Next Lines', icon: faAlignJustify, type: OPTION_TYPES.slider, max: 5, step: 1, privacy: PRIVACY_TYPES.local }, - larivaarGurbani: { name: 'Larivaar', icon: faTextWidth, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - larivaarAssist: { name: 'Larivaar Assist', icon: faMarker, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - syllabicWeights: { name: 'Syllabic Weights', icon: faBalanceScale, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - syllableCount: { name: 'Syllable Count', icon: faCalculator, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - englishTranslation: { name: 'English Translation', icon: faClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - spanishTranslation: { name: 'Spanish Translation', icon: faClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - punjabiTranslation: { name: 'Punjabi Translation', icon: faClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - englishTransliteration: { name: 'English Transliteration', icon: farClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - hindiTransliteration: { name: 'Hindi Transliteration', icon: farClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - urduTransliteration: { name: 'Urdu Transliteration', icon: farClosedCaptioning, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - lineEnding: { name: 'Hide Line Ending', icon: faRemoveFormat, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - themeName: { name: 'Theme Name', icon: faPalette, type: OPTION_TYPES.dropdown, values: [], privacy: PRIVACY_TYPES.local }, - simpleGraphics: { name: 'Remove Visual Effects', icon: faLowVision, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - backgroundImage: { name: 'Background Image', icon: faImage, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - highlightCurrentLine: { name: 'Current Line Background', icon: faFillDrip, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - dimNextAndPrevLines: { name: 'Next and Previous Lines Background', icon: faFillDrip, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - vishraamHeavy: { name: 'Primary Pause', icon: faPauseCircle, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - vishraamMedium: { name: 'Secondary Pause', icon: faPauseCircle, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - vishraamLight: { name: 'Tertiary Pause', icon: faPauseCircle, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - vishraamCharacters: { name: 'Show Symbols', icon: faSubscript, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - vishraamColors: { name: 'Show Colors', icon: faFill, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - displayAnalytics: { name: 'Display Usage Analytics', icon: faChartPie, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, - private: { name: 'Private Settings', icon: faLock, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.private }, - launchOnStartup: { name: 'Launch On Startup', icon: faDoorOpen, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - multipleDisplays: { name: 'Launch on All Displays', icon: faDesktop, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - fullscreenOnLaunch: { name: 'Launch In Fullscreen', icon: faExpandArrowsAlt, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - serverAnalytics: { name: 'Server Usage Analytics', icon: faChartPie, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - automaticUpdates: { name: 'Automatic Updates', icon: faSync, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - betaOptIn: { name: 'Beta Updates', icon: faFlask, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - connectionEvents: { name: 'Connections', icon: faPlug, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - disconnectionEvents: { name: 'Disconnections', icon: faPowerOff, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - downloadEvents: { name: 'Update Download', icon: faDownload, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - downloadedEvents: { name: 'Update Download Complete', icon: faServer, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.global }, - showResultCitations: { name: 'Show Citations', icon: faTags, type: OPTION_TYPES.toggle, privacy: PRIVACY_TYPES.local }, + previousLines: { name: 'Previous Lines', icon: faAlignJustify, type: 'slider', min: 0, max: 5, step: 1 }, + nextLines: { name: 'Next Lines', icon: faAlignJustify, type: 'slider', min: 0, max: 5, step: 1 }, + larivaarGurbani: { name: 'Larivaar', icon: faTextWidth, type: 'toggle' }, + larivaarAssist: { name: 'Larivaar Assist', icon: faMarker, type: 'toggle' }, + syllabicWeights: { name: 'Syllabic Weights', icon: faBalanceScale, type: 'toggle' }, + syllableCount: { name: 'Syllable Count', icon: faCalculator, type: 'toggle' }, + englishTranslation: { name: 'English Translation', icon: faClosedCaptioning, type: 'toggle' }, + spanishTranslation: { name: 'Spanish Translation', icon: faClosedCaptioning, type: 'toggle' }, + punjabiTranslation: { name: 'Punjabi Translation', icon: faClosedCaptioning, type: 'toggle' }, + englishTransliteration: { name: 'English Transliteration', icon: farClosedCaptioning, type: 'toggle' }, + hindiTransliteration: { name: 'Hindi Transliteration', icon: farClosedCaptioning, type: 'toggle' }, + urduTransliteration: { name: 'Urdu Transliteration', icon: farClosedCaptioning, type: 'toggle' }, + lineEnding: { name: 'Hide Line Ending', icon: faRemoveFormat, type: 'toggle' }, + themeName: { name: 'Theme Name', icon: faPalette, type: 'urlDropdown', values: [], url: `${API_URL}/themes/presenter` }, + simpleGraphics: { name: 'Remove Visual Effects', icon: faLowVision, type: 'toggle' }, + backgroundImage: { name: 'Background Image', icon: faImage, type: 'toggle' }, + highlightCurrentLine: { name: 'Current Line Background', icon: faFillDrip, type: 'toggle' }, + dimNextAndPrevLines: { name: 'Next and Previous Lines Background', icon: faFillDrip, type: 'toggle' }, + vishraamHeavy: { name: 'Primary Pause', icon: faPauseCircle, type: 'toggle' }, + vishraamMedium: { name: 'Secondary Pause', icon: faPauseCircle, type: 'toggle' }, + vishraamLight: { name: 'Tertiary Pause', icon: faPauseCircle, type: 'toggle' }, + vishraamCharacters: { name: 'Show Symbols', icon: faSubscript, type: 'toggle' }, + vishraamColors: { name: 'Show Colors', icon: faFill, type: 'toggle' }, + displayAnalytics: { name: 'Display Usage Analytics', icon: faChartPie, type: 'toggle' }, + private: { name: 'Private Settings', icon: faLock, type: 'toggle', isProtected: true }, + showResultCitations: { name: 'Show Citations', icon: faTags, type: 'toggle' }, resultTranslationLanguage: { name: 'Translation', icon: faClosedCaptioning, - type: OPTION_TYPES.dropdown, - privacy: PRIVACY_TYPES.local, + type: 'dropdown', values: [ { name: 'None', value: false }, { name: 'English', value: LANGUAGES.english }, @@ -155,8 +171,7 @@ export const OPTIONS = { resultTransliterationLanguage: { name: 'Transliteration', icon: farClosedCaptioning, - type: OPTION_TYPES.dropdown, - privacy: PRIVACY_TYPES.local, + type: 'dropdown', values: [ { name: 'None', value: false }, { name: 'English', value: LANGUAGES.english }, @@ -164,9 +179,22 @@ export const OPTIONS = { { name: 'Urdu', value: LANGUAGES.urdu }, ], }, - overlayName: { name: 'Overlay Name', icon: faPalette, type: OPTION_TYPES.urlDropdown, values: [], url: `${BACKEND_URL}/overlay/themes`, privacy: PRIVACY_TYPES.global }, - zoomApiToken: { name: 'Zoom API Token', icon: faShareSquare, type: OPTION_TYPES.textInput, privacy: PRIVACY_TYPES.global }, -} + overlayName: { name: 'Overlay Name', icon: faPalette, type: 'urlDropdown', values: [], url: `${API_URL}/themes/overlay` }, +} satisfies Record + +export const SERVER_OPTIONS = { + connectionEvents: { name: 'Connections', icon: faPlug, type: 'toggle' }, + disconnectionEvents: { name: 'Disconnections', icon: faPowerOff, type: 'toggle' }, + downloadEvents: { name: 'Update Download', icon: faDownload, type: 'toggle' }, + downloadedEvents: { name: 'Update Download Complete', icon: faServer, type: 'toggle' }, + launchOnStartup: { name: 'Launch On Startup', icon: faDoorOpen, type: 'toggle' }, + multipleDisplays: { name: 'Launch on All Displays', icon: faDesktop, type: 'toggle' }, + fullscreenOnLaunch: { name: 'Launch In Fullscreen', icon: faExpandArrowsAlt, type: 'toggle' }, + serverAnalytics: { name: 'Server Usage Analytics', icon: faChartPie, type: 'toggle' }, + automaticUpdates: { name: 'Automatic Updates', icon: faSync, type: 'toggle' }, + betaOptIn: { name: 'Beta Updates', icon: faFlask, type: 'toggle' }, + zoomApiToken: { name: 'Zoom API Token', icon: faShareSquare, type: 'textInput' }, +} satisfies Record // Possible options groups export const OPTION_GROUPS = { @@ -199,7 +227,7 @@ export const OPTION_GROUPS = { security: { name: 'Security', icon: faShieldAlt, - privacy: PRIVACY_TYPES.private, + isProtected: true, }, }, activities: { @@ -265,12 +293,10 @@ type FlatOptionGroups = { icon: typeof faShieldAlt, privacy: symbol, }, - search?: { name: string, icon: typeof faSearch, }, - notifications?: { name: string, icon: typeof faBell, @@ -283,7 +309,6 @@ type FlatOptionGroups = { name: string, icon: typeof faInfo, }, - overlay?: { name: string, icon: typeof faWindowMaximize, @@ -329,7 +354,7 @@ export const DEFAULT_OPTIONS = { inlineTransliteration: false, inlineColumnGuides: false, splitOnVishraam: true, - spacing: OPTIONS.spacing.values[ 2 ].value, + spacing: CLIENT_OPTIONS.spacing.values[ 2 ].value, }, theme: { themeName: 'Day', @@ -355,8 +380,8 @@ export const DEFAULT_OPTIONS = { private: false, }, search: { - resultTranslationLanguage: OPTIONS.resultTranslationLanguage.values[ 0 ].value, - resultTransliterationLanguage: OPTIONS.resultTransliterationLanguage.values[ 0 ].value, + resultTranslationLanguage: CLIENT_OPTIONS.resultTranslationLanguage.values[ 0 ].value, + resultTransliterationLanguage: CLIENT_OPTIONS.resultTransliterationLanguage.values[ 0 ].value, showResultCitations: false, lineEnding: true, }, @@ -405,7 +430,7 @@ export const DEFAULT_OPTIONS = { }, } -export type ClientSettings = typeof DEFAULT_OPTIONS.local -export type GlobalSettings = typeof DEFAULT_OPTIONS.global +export type ClientSettings = typeof CLIENT_OPTIONS +export type ServerSettings = typeof SERVER_OPTIONS -export type SettingsState = { [host: string]: ClientSettings } & { global: GlobalSettings } +export type SettingsState = Record & { global: ServerSettings } diff --git a/apps/frontend/src/helpers/utils.ts b/apps/frontend/src/helpers/utils.ts index aa962502..c9bf7020 100644 --- a/apps/frontend/src/helpers/utils.ts +++ b/apps/frontend/src/helpers/utils.ts @@ -1,65 +1,28 @@ -/** - * Utility functions. - * @ignore - */ - import deepmerge from 'deepmerge' import { debounce } from 'lodash' -import queryString from 'qs' import { findDOMNode } from 'react-dom' import scrollIntoView from 'scroll-into-view' -import { isMac, STATES } from './consts' +import { isMac } from './consts' -/** - * Merges the source object into the destination, replacing arrays. - * @param {Object} source The source object. - * @param {Object} destination The destination object. - */ export const merge = ( source: Partial, destination: Partial ) => deepmerge( source, destination, { arrayMerge: ( _, source ) => source }, ) -/** - * Scrolls an element into the center, given a ref. - * @param ref The reference to the element to scroll. - * @param options Any options for the scroll function. - */ // eslint-disable-next-line react/no-find-dom-node export const scrollIntoCenter = ( ref: any, options?: __ScrollIntoView.Settings ) => scrollIntoView( findDOMNode( ref ) as any, { time: 200, ...options } ) -type UrlState = Record -/** - * Returns the current query state of the URL, based on the defined states. - * @param search The search component of the window location. - * @returns {Object} Key-value pairs of the state and values. - */ -export const getUrlState = ( search: string ) => { - const params = queryString.parse( search, { ignoreQueryPrefix: true } ) - - return Object - .entries( STATES ) - .reduce( ( acc, [ key, name ] ) => ( params[ name ] ? { - ...acc, - [ key ]: params[ name ], - } : acc ), {} as UrlState ) -} - export const debounceHotKey = ( fn: () => void ) => debounce( fn, 300, { leading: true } ) export const mapPlatformKey = ( key: string ) => ( isMac ? key.replace( 'ctrl', 'cmd' ) : key ) -export type KeyMap = { [key: string]: string[] } +export type KeyMap = Record -/** - * Maps ctrl to cmd in keyMap if on Mac. - * @param {*} keyMap An object of all the keys and mapped values. - */ export const mapPlatformKeys = ( keyMap: KeyMap ) => ( isMac ? Object.entries( keyMap ).reduce( ( keyMap, [ name, sequences ] ) => ( { ...keyMap, @@ -69,7 +32,7 @@ export const mapPlatformKeys = ( keyMap: KeyMap ) => ( isMac ) export const filterFalsyValues = ( - valuesToFilter: Array + valuesToFilter: T[] ) => valuesToFilter.filter( ( item ) => item ) export const filterFalsyObjectValues = ( diff --git a/apps/frontend/src/hooks/navigate.ts b/apps/frontend/src/hooks/navigate.ts new file mode 100644 index 00000000..ecb6ecb3 --- /dev/null +++ b/apps/frontend/src/hooks/navigate.ts @@ -0,0 +1,13 @@ +import { ToOptions, useRouter } from '@tanstack/react-router' + +export const useNavigateUtils = () => { + const router = useRouter() + + const open = ( location: ToOptions ) => { + const { href } = router.buildLocation( location ) + + window.open( href, '_blank' ) + } + + return { open } +} diff --git a/apps/frontend/src/hooks/use-location-history.tsx b/apps/frontend/src/hooks/use-location-history.tsx new file mode 100644 index 00000000..dcb48490 --- /dev/null +++ b/apps/frontend/src/hooks/use-location-history.tsx @@ -0,0 +1,33 @@ +import { useRouter } from '@tanstack/react-router' +import { createContext, ReactNode, useContext, useEffect, useState } from 'react' + +type CreateUseLocationHistoryOptions = { + filter?: ( href: string ) => boolean, +} + +const createUseLocationHistory = ( + initial: string, + { filter = () => true } : CreateUseLocationHistoryOptions = {} +) => { + const HistoryContext = createContext( '/presenter/controller/search' ) + + const Provider = ( { children }: { children: ReactNode } ) => { + const router = useRouter() + + const [ location, setLocation ] = useState( initial ) + + useEffect( () => router.subscribe( 'onBeforeNavigate', ( { toLocation: { href } } ) => { + if ( !filter( href ) ) return + + setLocation( href ) + } ), [ router ] ) + + return {children} + } + + const useLocationHistory = () => useContext( HistoryContext ) + + return { Provider, useLocationHistory } +} + +export default createUseLocationHistory diff --git a/apps/frontend/src/index.tsx b/apps/frontend/src/index.tsx index 52989089..b43887f6 100644 --- a/apps/frontend/src/index.tsx +++ b/apps/frontend/src/index.tsx @@ -1,19 +1,45 @@ -import { StrictMode } from 'react' +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { lazy, StrictMode, Suspense } from 'react' import { createRoot } from 'react-dom/client' -import App from './App' -import analytics from './services/analytics' +import Empty from './components/Empty' +import { isDev } from './helpers/consts' +import { routeTree } from './routeTree.gen' import controller from './services/controller' const settings = controller.readSettings() -if ( settings?.security?.displayAnalytics ) { - void analytics.initialise() - analytics.updateSettings( controller.readSettings( true ) ) +// if ( settings?.security?.displayAnalytics ) { +// void analytics.initialise() +// analytics.updateSettings( controller.readSettings( true ) ) +// } + +const router = createRouter( { routeTree } ) + +declare module '@tanstack/react-router' { + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Register { + router: typeof router, + } } +const TanStackRouterDevtools = isDev + ? lazy( + // eslint-disable-next-line import/no-extraneous-dependencies + () => import( '@tanstack/router-devtools' ).then( ( res ) => ( { default: res.TanStackRouterDevtools } ) ) + ) + : Empty + +const DevTools = () => ( + + + +) + createRoot( document.getElementById( 'root' )! ).render( - + + ) diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts new file mode 100644 index 00000000..d9a076d2 --- /dev/null +++ b/apps/frontend/src/routeTree.gen.ts @@ -0,0 +1,422 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file is auto-generated by TanStack Router + +import { createFileRoute } from '@tanstack/react-router' + +// Import Routes + +import { Route as rootRoute } from './app/__root' +import { Route as SettingsRouteImport } from './app/settings/route' +import { Route as PresenterRouteImport } from './app/presenter/route' +import { Route as IndexImport } from './app/index' +import { Route as SettingsIndexImport } from './app/settings/index' +import { Route as PresenterControllerRouteImport } from './app/presenter/controller/route' +import { Route as PresenterControllerIndexImport } from './app/presenter/controller/index' +import { Route as SettingsServerCategoryImport } from './app/settings/server/$category' +import { Route as SettingsClientCategoryImport } from './app/settings/client/$category' +import { Route as SettingsToolsOverlayIndexImport } from './app/settings/tools/overlay/index' +import { Route as SettingsToolsClosedCaptionsIndexImport } from './app/settings/tools/closedCaptions/index' +import { Route as SettingsServerAboutIndexImport } from './app/settings/server/about/index' +import { Route as SettingsClientSourcesIndexImport } from './app/settings/client/sources/index' +import { Route as SettingsClientHotkeysIndexImport } from './app/settings/client/hotkeys/index' +import { Route as PresenterControllerSearchIndexImport } from './app/presenter/controller/search/index' +import { Route as PresenterControllerNavigatorIndexImport } from './app/presenter/controller/navigator/index' +import { Route as PresenterControllerHistoryIndexImport } from './app/presenter/controller/history/index' +import { Route as PresenterControllerBookmarksIndexImport } from './app/presenter/controller/bookmarks/index' + +// Create Virtual Routes + +const ScreenReaderIndexLazyImport = createFileRoute('/screen-reader/')() +const OverlayIndexLazyImport = createFileRoute('/overlay/')() + +// Create/Update Routes + +const SettingsRouteRoute = SettingsRouteImport.update({ + path: '/settings', + getParentRoute: () => rootRoute, +} as any) + +const PresenterRouteRoute = PresenterRouteImport.update({ + path: '/presenter', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./app/presenter/route.lazy').then((d) => d.Route)) + +const IndexRoute = IndexImport.update({ + path: '/', + getParentRoute: () => rootRoute, +} as any) + +const ScreenReaderIndexLazyRoute = ScreenReaderIndexLazyImport.update({ + path: '/screen-reader/', + getParentRoute: () => rootRoute, +} as any).lazy(() => + import('./app/screen-reader/index.lazy').then((d) => d.Route), +) + +const OverlayIndexLazyRoute = OverlayIndexLazyImport.update({ + path: '/overlay/', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./app/overlay/index.lazy').then((d) => d.Route)) + +const SettingsIndexRoute = SettingsIndexImport.update({ + path: '/', + getParentRoute: () => SettingsRouteRoute, +} as any) + +const PresenterControllerRouteRoute = PresenterControllerRouteImport.update({ + path: '/controller', + getParentRoute: () => PresenterRouteRoute, +} as any) + +const PresenterControllerIndexRoute = PresenterControllerIndexImport.update({ + path: '/', + getParentRoute: () => PresenterControllerRouteRoute, +} as any) + +const SettingsServerCategoryRoute = SettingsServerCategoryImport.update({ + path: '/server/$category', + getParentRoute: () => SettingsRouteRoute, +} as any) + +const SettingsClientCategoryRoute = SettingsClientCategoryImport.update({ + path: '/client/$category', + getParentRoute: () => SettingsRouteRoute, +} as any) + +const SettingsToolsOverlayIndexRoute = SettingsToolsOverlayIndexImport.update({ + path: '/tools/overlay/', + getParentRoute: () => SettingsRouteRoute, +} as any) + +const SettingsToolsClosedCaptionsIndexRoute = + SettingsToolsClosedCaptionsIndexImport.update({ + path: '/tools/closedCaptions/', + getParentRoute: () => SettingsRouteRoute, + } as any) + +const SettingsServerAboutIndexRoute = SettingsServerAboutIndexImport.update({ + path: '/server/about/', + getParentRoute: () => SettingsRouteRoute, +} as any) + +const SettingsClientSourcesIndexRoute = SettingsClientSourcesIndexImport.update( + { + path: '/client/sources/', + getParentRoute: () => SettingsRouteRoute, + } as any, +) + +const SettingsClientHotkeysIndexRoute = SettingsClientHotkeysIndexImport.update( + { + path: '/client/hotkeys/', + getParentRoute: () => SettingsRouteRoute, + } as any, +) + +const PresenterControllerSearchIndexRoute = + PresenterControllerSearchIndexImport.update({ + path: '/search/', + getParentRoute: () => PresenterControllerRouteRoute, + } as any) + +const PresenterControllerNavigatorIndexRoute = + PresenterControllerNavigatorIndexImport.update({ + path: '/navigator/', + getParentRoute: () => PresenterControllerRouteRoute, + } as any) + +const PresenterControllerHistoryIndexRoute = + PresenterControllerHistoryIndexImport.update({ + path: '/history/', + getParentRoute: () => PresenterControllerRouteRoute, + } as any) + +const PresenterControllerBookmarksIndexRoute = + PresenterControllerBookmarksIndexImport.update({ + path: '/bookmarks/', + getParentRoute: () => PresenterControllerRouteRoute, + } as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/presenter': { + id: '/presenter' + path: '/presenter' + fullPath: '/presenter' + preLoaderRoute: typeof PresenterRouteImport + parentRoute: typeof rootRoute + } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRoute + } + '/presenter/controller': { + id: '/presenter/controller' + path: '/controller' + fullPath: '/presenter/controller' + preLoaderRoute: typeof PresenterControllerRouteImport + parentRoute: typeof PresenterRouteImport + } + '/settings/': { + id: '/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof SettingsIndexImport + parentRoute: typeof SettingsRouteImport + } + '/overlay/': { + id: '/overlay/' + path: '/overlay' + fullPath: '/overlay' + preLoaderRoute: typeof OverlayIndexLazyImport + parentRoute: typeof rootRoute + } + '/screen-reader/': { + id: '/screen-reader/' + path: '/screen-reader' + fullPath: '/screen-reader' + preLoaderRoute: typeof ScreenReaderIndexLazyImport + parentRoute: typeof rootRoute + } + '/settings/client/$category': { + id: '/settings/client/$category' + path: '/client/$category' + fullPath: '/settings/client/$category' + preLoaderRoute: typeof SettingsClientCategoryImport + parentRoute: typeof SettingsRouteImport + } + '/settings/server/$category': { + id: '/settings/server/$category' + path: '/server/$category' + fullPath: '/settings/server/$category' + preLoaderRoute: typeof SettingsServerCategoryImport + parentRoute: typeof SettingsRouteImport + } + '/presenter/controller/': { + id: '/presenter/controller/' + path: '/' + fullPath: '/presenter/controller/' + preLoaderRoute: typeof PresenterControllerIndexImport + parentRoute: typeof PresenterControllerRouteImport + } + '/presenter/controller/bookmarks/': { + id: '/presenter/controller/bookmarks/' + path: '/bookmarks' + fullPath: '/presenter/controller/bookmarks' + preLoaderRoute: typeof PresenterControllerBookmarksIndexImport + parentRoute: typeof PresenterControllerRouteImport + } + '/presenter/controller/history/': { + id: '/presenter/controller/history/' + path: '/history' + fullPath: '/presenter/controller/history' + preLoaderRoute: typeof PresenterControllerHistoryIndexImport + parentRoute: typeof PresenterControllerRouteImport + } + '/presenter/controller/navigator/': { + id: '/presenter/controller/navigator/' + path: '/navigator' + fullPath: '/presenter/controller/navigator' + preLoaderRoute: typeof PresenterControllerNavigatorIndexImport + parentRoute: typeof PresenterControllerRouteImport + } + '/presenter/controller/search/': { + id: '/presenter/controller/search/' + path: '/search' + fullPath: '/presenter/controller/search' + preLoaderRoute: typeof PresenterControllerSearchIndexImport + parentRoute: typeof PresenterControllerRouteImport + } + '/settings/client/hotkeys/': { + id: '/settings/client/hotkeys/' + path: '/client/hotkeys' + fullPath: '/settings/client/hotkeys' + preLoaderRoute: typeof SettingsClientHotkeysIndexImport + parentRoute: typeof SettingsRouteImport + } + '/settings/client/sources/': { + id: '/settings/client/sources/' + path: '/client/sources' + fullPath: '/settings/client/sources' + preLoaderRoute: typeof SettingsClientSourcesIndexImport + parentRoute: typeof SettingsRouteImport + } + '/settings/server/about/': { + id: '/settings/server/about/' + path: '/server/about' + fullPath: '/settings/server/about' + preLoaderRoute: typeof SettingsServerAboutIndexImport + parentRoute: typeof SettingsRouteImport + } + '/settings/tools/closedCaptions/': { + id: '/settings/tools/closedCaptions/' + path: '/tools/closedCaptions' + fullPath: '/settings/tools/closedCaptions' + preLoaderRoute: typeof SettingsToolsClosedCaptionsIndexImport + parentRoute: typeof SettingsRouteImport + } + '/settings/tools/overlay/': { + id: '/settings/tools/overlay/' + path: '/tools/overlay' + fullPath: '/settings/tools/overlay' + preLoaderRoute: typeof SettingsToolsOverlayIndexImport + parentRoute: typeof SettingsRouteImport + } + } +} + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren({ + IndexRoute, + PresenterRouteRoute: PresenterRouteRoute.addChildren({ + PresenterControllerRouteRoute: PresenterControllerRouteRoute.addChildren({ + PresenterControllerIndexRoute, + PresenterControllerBookmarksIndexRoute, + PresenterControllerHistoryIndexRoute, + PresenterControllerNavigatorIndexRoute, + PresenterControllerSearchIndexRoute, + }), + }), + SettingsRouteRoute: SettingsRouteRoute.addChildren({ + SettingsIndexRoute, + SettingsClientCategoryRoute, + SettingsServerCategoryRoute, + SettingsClientHotkeysIndexRoute, + SettingsClientSourcesIndexRoute, + SettingsServerAboutIndexRoute, + SettingsToolsClosedCaptionsIndexRoute, + SettingsToolsOverlayIndexRoute, + }), + OverlayIndexLazyRoute, + ScreenReaderIndexLazyRoute, +}) + +/* prettier-ignore-end */ + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/presenter", + "/settings", + "/overlay/", + "/screen-reader/" + ] + }, + "/": { + "filePath": "index.tsx" + }, + "/presenter": { + "filePath": "presenter/route.tsx", + "children": [ + "/presenter/controller" + ] + }, + "/settings": { + "filePath": "settings/route.tsx", + "children": [ + "/settings/", + "/settings/client/$category", + "/settings/server/$category", + "/settings/client/hotkeys/", + "/settings/client/sources/", + "/settings/server/about/", + "/settings/tools/closedCaptions/", + "/settings/tools/overlay/" + ] + }, + "/presenter/controller": { + "filePath": "presenter/controller/route.tsx", + "parent": "/presenter", + "children": [ + "/presenter/controller/", + "/presenter/controller/bookmarks/", + "/presenter/controller/history/", + "/presenter/controller/navigator/", + "/presenter/controller/search/" + ] + }, + "/settings/": { + "filePath": "settings/index.tsx", + "parent": "/settings" + }, + "/overlay/": { + "filePath": "overlay/index.lazy.tsx" + }, + "/screen-reader/": { + "filePath": "screen-reader/index.lazy.tsx" + }, + "/settings/client/$category": { + "filePath": "settings/client/$category.tsx", + "parent": "/settings" + }, + "/settings/server/$category": { + "filePath": "settings/server/$category.tsx", + "parent": "/settings" + }, + "/presenter/controller/": { + "filePath": "presenter/controller/index.tsx", + "parent": "/presenter/controller" + }, + "/presenter/controller/bookmarks/": { + "filePath": "presenter/controller/bookmarks/index.tsx", + "parent": "/presenter/controller" + }, + "/presenter/controller/history/": { + "filePath": "presenter/controller/history/index.tsx", + "parent": "/presenter/controller" + }, + "/presenter/controller/navigator/": { + "filePath": "presenter/controller/navigator/index.tsx", + "parent": "/presenter/controller" + }, + "/presenter/controller/search/": { + "filePath": "presenter/controller/search/index.tsx", + "parent": "/presenter/controller" + }, + "/settings/client/hotkeys/": { + "filePath": "settings/client/hotkeys/index.tsx", + "parent": "/settings" + }, + "/settings/client/sources/": { + "filePath": "settings/client/sources/index.tsx", + "parent": "/settings" + }, + "/settings/server/about/": { + "filePath": "settings/server/about/index.tsx", + "parent": "/settings" + }, + "/settings/tools/closedCaptions/": { + "filePath": "settings/tools/closedCaptions/index.tsx", + "parent": "/settings" + }, + "/settings/tools/overlay/": { + "filePath": "settings/tools/overlay/index.tsx", + "parent": "/settings" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/apps/frontend/src/services/analytics.ts b/apps/frontend/src/services/analytics.ts deleted file mode 100644 index 6513d808..00000000 --- a/apps/frontend/src/services/analytics.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Analytics, including error reporting. - * @ignore - */ - -/* eslint-disable class-methods-use-this */ -import { About } from '@presenter/contract' -import * as Sentry from '@sentry/browser' - -import { BACKEND_URL, isDev, SENTRY_DSN, SENTRY_PROJECT } from '~/helpers/consts' - -/** - * Analytics class for tracking events and providing error reporting. - */ -class Analytics { - /** - * Initialises the analytics class. - * Loads Sentry. - */ - async initialise() { - if ( isDev ) return - - await this.initSentry() - } - - /** - * Initialises Sentry error reporting. - * ! Cannot be disabled without a restart. - */ - async initSentry() { - console.log( 'Enabling Sentry error reporting' ) - - // Set the sentry release - const { version } = await fetch( `${BACKEND_URL}/about` ).then( ( res ) => res.json() ) as About - const release = `${SENTRY_PROJECT}@${version}` - console.log( `Using sentry release ${release}` ) - - Sentry.init( { dsn: SENTRY_DSN, release } ) - } - - updateSettings( settings: any ) { - Sentry.configureScope( ( scope ) => { - scope.setExtra( 'settings', settings ) - } ) - } -} - -export default new Analytics() diff --git a/apps/frontend/src/services/controller.ts b/apps/frontend/src/services/controller.ts index 56ee81da..60c49fec 100644 --- a/apps/frontend/src/services/controller.ts +++ b/apps/frontend/src/services/controller.ts @@ -1,8 +1,3 @@ -/** - * Simple EventEmitter-based controller. - * @ignore - */ - import EventEmitter from 'eventemitter3' import ReconnectingWebSocket from 'reconnecting-websocket' @@ -11,7 +6,6 @@ import { isDev, isElectron, WS_URL } from '~/helpers/consts' import { findLineIndex } from '~/helpers/line' import { ClientSettings, DEFAULT_OPTIONS, SettingsState } from '~/helpers/options' import { merge } from '~/helpers/utils' -import analytics from '~/services/analytics' type ShabadOptions = { shabadId?: string, @@ -45,11 +39,6 @@ class Controller extends EventEmitter { this.on( 'ready', this.onReady ) } - /** - * Sends a payload with a name to the server. - * @param event The event name. - * @param payload The JSON data to send. - */ sendJSON = ( event: string, payload?: any ) => { const sendJSON = () => this.#socket.send( JSON.stringify( { event, payload } ) ) @@ -57,19 +46,11 @@ class Controller extends EventEmitter { else this.once( 'connected', sendJSON ) } - /** - * Called when the WebSocket is connected. - * @private - */ onOpen = () => { console.log( 'Connected to server' ) this.emit( 'connected' ) } - /** - * Called when the WebSocket is ready. - * @private - */ onReady = () => { this.once( 'settings:all', ( { local = {}, ...rest } ) => { // Transmit our local settings if the server does not have a copy @@ -87,10 +68,6 @@ class Controller extends EventEmitter { } ) } - /** - * Called when the WebSocket is disconnected. - * @private - */ onClose = () => { this.off( 'settings:all', this.onSettings ) console.log( 'Disconnected from server' ) @@ -98,10 +75,7 @@ class Controller extends EventEmitter { } /** - * Called when the WebSocket receives a message. - * @param data The data sent by the server. - */ - onMessage = ( { data }: { data: any } ) => { + essage = ( { data }: { data: any } ) => { const { event, payload } = JSON.parse( data ) this.emit( event, payload ) } @@ -119,107 +93,20 @@ class Controller extends EventEmitter { */ search = ( query: string, type: string, options = {} ) => this.sendJSON( `search:${type}`, { ...options, query } ) - /** - * Convenience method for setting the line. - * @param lineId The line id to change the display to. - */ line = ( lineId: string ) => this.sendJSON( 'lines:current', { lineId } ) - /** - * Convenience method for setting the main line. - * @param lineId The line id to change the display to. - */ mainLine = ( lineId: string ) => this.sendJSON( 'lines:main', lineId ) nextJumpLine = ( lineId: string ) => this.sendJSON( 'lines:next', lineId ) - /** - * Convenience method for setting the current shabad. - * @param shabadId The shabad ID to change the server to. - * @param lineId The line id to change the display to. - */ - shabad = ( { - shabadId, - shabadOrderId = null, - lineId = null, - lineOrderId = null, - }: ShabadOptions ) => this.sendJSON( 'shabads:current', { - shabadId, - shabadOrderId, - lineId, - lineOrderId, - } ) - - previousShabad = ( orderId: number, setLine = true ) => this.shabad( { - shabadOrderId: orderId - 1, - lineOrderId: setLine ? 1e20 : null, - } ) - - nextShabad = ( orderId: number, setLine = true ) => this.shabad( { - shabadOrderId: orderId + 1, - lineOrderId: setLine ? 0 : null, - } ) - - autoToggleShabad = ( { nextLineId, mainLineId, lineId, shabad: { lines } }: any ) => { - if ( !mainLineId || !nextLineId || !lines ) return - - // Jump to main line and work out the new next line - if ( lineId !== mainLineId ) { - this.line( mainLineId ) - - if ( !lineId ) return - - const currentLineIndex = findLineIndex( lines, lineId ) - - // Set new next line to be the next line, bounded by the last line - let nextLineIndex = Math.min( - currentLineIndex + 1, - lines.length - 1, - ) - - // Skip the main line if required, bounded by the last line - nextLineIndex = Math.min( - nextLineIndex + ( lines[ nextLineIndex ].id === mainLineId ? 1 : 0 ), - lines.length - 1, - ) - - const { id: newNextLineId } = lines[ nextLineIndex ] - - this.nextJumpLine( newNextLineId ) - } else this.line( nextLineId ) - } - - autoToggleBani = ( params: any ) => { - const nextLineId = getNextJumpLine( params ) - if ( !nextLineId ) return - - this.line( nextLineId ) - } - - /** - * Convenience method for clearing the line. - */ clear = () => this.sendJSON( 'lines:current', { lineId: null } ) - /** - * Clears the current history for the session. - */ clearHistory = () => this.sendJSON( 'history:clear' ) - /** - * Requests the latest list of banis from the server. - */ getBanis = () => this.sendJSON( 'banis:list' ) - /** - * Sets the current Bani ID. - * @param baniId The ID of the Bani to change to. - */ bani = ( { baniId, lineId = null }: any ) => this.sendJSON( 'banis:current', { baniId, lineId } ) - /** - * Reads the settings from local storage, and combines with default settings. - */ // eslint-disable-next-line class-methods-use-this readSettings = ( onlyOverrides = false ): Partial => { try { @@ -234,15 +121,9 @@ class Controller extends EventEmitter { saveLocalSettings = ( settings: Partial = {}, combine = true ) => { const local = combine ? merge( this.readSettings( true ), settings ) : settings - analytics.updateSettings( local ) localStorage.setItem( 'settings', JSON.stringify( local ) ) } - /** - * Stores any setting changes locally and submits changes to server. - * @param changed The changed settings. - * @param host The optional host to apply the settings to. Default of `local`. - */ setSettings = ( changed = {}, host = 'local', combine = true ) => { let settings = {} if ( host === 'local' ) { diff --git a/apps/frontend/tsr.config.json b/apps/frontend/tsr.config.json new file mode 100644 index 00000000..9aaf82af --- /dev/null +++ b/apps/frontend/tsr.config.json @@ -0,0 +1,3 @@ +{ + "routesDirectory": "./src/app" +} diff --git a/package-lock.json b/package-lock.json index 0ef2b4a4..0d8a78b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -164,6 +164,8 @@ "@mui/material": "^5.16.6", "@presenter/themes": "*", "@sentry/browser": "^6.19.7", + "@tanstack/react-router": "^1.49.2", + "@tanstack/router-zod-adapter": "^1.51.6", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.1", "detect-browser": "^5.3.0", @@ -173,21 +175,21 @@ "lodash": "^4.17.21", "memoizee": "^0.4.17", "notistack": "^3.0.1", - "qs": "^6.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hotkeys": "^2.0.0", "react-idle-timer": "^5.7.2", - "react-router-dom": "^6.26.0", "react-transition-group": "^4.4.5", "reconnecting-websocket": "^4.4.0", - "scroll-into-view": "^1.16.0" + "scroll-into-view": "^1.16.0", + "zod": "^3.8.2-alpha.6" }, "devDependencies": { "@presenter/contract": "*", + "@tanstack/router-devtools": "^1.49.2", + "@tanstack/router-plugin": "^1.49.3", "@types/lodash": "^4.17.7", "@types/memoizee": "^0.4.11", - "@types/qs": "^6.9.15", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/scroll-into-view": "^1.16.0", @@ -196,6 +198,15 @@ "vite": "^5.3.5" } }, + "apps/frontend/node_modules/zod": { + "version": "3.8.2-alpha.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.8.2-alpha.6.tgz", + "integrity": "sha512-xQX/TxngBiiVGp7TJccvWocjasipANxbl5L/HzhgZlTS0V0ddKiIgr1KIAl8EDd/srVQP3N/eeUHbrOeeqn4LA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -535,6 +546,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", + "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.7.tgz", @@ -2791,15 +2834,6 @@ "resolved": "packages/tsconfig", "link": true }, - "node_modules/@remix-run/router": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.0.tgz", - "integrity": "sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.20.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", @@ -3250,6 +3284,177 @@ "node": ">=6" } }, + "node_modules/@tanstack/history": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.49.0.tgz", + "integrity": "sha512-Lo3kNzpAMq6CPwkRIQ+bA72QiQGdIrHgghPlS/KoUwMxdp2Znk8S9FQToTswQ53FqGvvIxR5stk+UBlVM2d61Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.49.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.49.2.tgz", + "integrity": "sha512-slyyrDfhSIMsl1anEWg/L7lcBas8PWobJYFJni/PfFh4bGQNF8HDGOIeyY4W4xnYQkUcEV0XVo3f8woIbWqsrA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.49.0", + "@tanstack/react-store": "^0.5.5", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.5.5.tgz", + "integrity": "sha512-1orYXGatBqXCYKuroFwV8Ll/6aDa5E3pU6RR4h7RvRk7TmxF1+zLCsWALZaeijXkySNMGmvawSbUXRypivg2XA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.5.5", + "use-sync-external-store": "^1.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/router-devtools": { + "version": "1.49.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.49.2.tgz", + "integrity": "sha512-SLH68uLI/lq/AJBx0lNkMNewqXJUZR9yUI9SY7DD6Il6DhKE4tjAic4ZpN4uFrggWZCELEcMBwJoMRLRF7535g==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.14" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.49.2", + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.49.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.49.3.tgz", + "integrity": "sha512-2AkCpg14XngUMHQBFjBGLPgPzJyGAOLX7vA1chBxd0ghOdnXiqXv5x2Md4ZZ7q10VkHWdhv9krU3mkyRtEXCfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier": "^3.3.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.49.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.49.3.tgz", + "integrity": "sha512-8p9TVbQMLScyQi5hhhKu49GC+W5k6cL7YkepEeJPd4E22AYKr0XC3Ih0D7igPm7sm9b4vAdtziPMG1DuZIercw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/plugin-syntax-typescript": "^7.24.7", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "@tanstack/router-generator": "^1.49.3", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/babel__template": "^7.4.4", + "@types/babel__traverse": "^7.20.6", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^3.6.0", + "unplugin": "^1.12.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.0", + "vite": ">=5.0.0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-zod-adapter": { + "version": "1.51.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-zod-adapter/-/router-zod-adapter-1.51.6.tgz", + "integrity": "sha512-kqFbN5W5PqvkruNZmuVnZDyqek1yLihYhgCGS0rDm+HnYuj+kYvc4vl3UUfDLYhfkNNnZRBqBuGM40dn+avHHg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": ">=1.43.2", + "zod": ">=3" + } + }, + "node_modules/@tanstack/store": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.5.5.tgz", + "integrity": "sha512-EOSrgdDAJExbvRZEQ/Xhh9iZchXpMN+ga1Bnk8Nmygzs8TfiE6hbzThF+Pr2G19uHL6+DTDTHhJ8VQiOd7l4tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4888,6 +5093,19 @@ "deep-equal": "^2.0.5" } }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.6.tgz", + "integrity": "sha512-JxFi9qyRJpN0LjEbbjbN8g0ux71Qppn9R8Qe3k6QzHg2CaKsbUQtbn307LQGiDLGjV6JCtEFqfxzVig9MyDCHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -14335,6 +14553,22 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -14679,38 +14913,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-router": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.0.tgz", - "integrity": "sha512-wVQq0/iFYd3iZ9H2l3N3k4PL8EEHcb0XlU2Na8nEwmiXgIUElEH6gaJDtUQxJ+JFzmIXaQjfdpcGWaM6IoQGxg==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.19.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.0.tgz", - "integrity": "sha512-RRGUIiDtLrkX3uYcFiCIxKFWMcWQGMojpYZfcstc63A1+sSnVgILGIm9gNUA6na3Fm1QuPGSBQH2EMbAZOnMsQ==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.19.0", - "react-router": "6.26.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -17578,6 +17780,18 @@ "node": ">=0.12" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -18250,6 +18464,22 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.12.2.tgz", + "integrity": "sha512-bEqQxeC7rxtxPZ3M5V4Djcc4lQqKPgGe3mAWZvxcSmX5jhGxll19NliaRzQSQPrk4xJZSGniK3puLWpRuZN7VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.12.1", + "chokidar": "^3.6.0", + "webpack-sources": "^3.2.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", @@ -18458,6 +18688,15 @@ "node": ">=0.10.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/utf-8-validate": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", @@ -18800,6 +19039,23 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -19323,6 +19579,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/contract": { "name": "@presenter/contract", "version": "0.0.0",