[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): Implement side nav

FoxxMD (Jun 16, 2026, 3:33 PM UTC) b751c6f5 d1ef09ba

+413 -17
+1
.storybook/main.ts
··· 12 12 // "@storybook/addon-vitest", 13 13 "@storybook/addon-a11y", 14 14 "@storybook/addon-docs", 15 + 'storybook-addon-remix-react-router' 15 16 // "@storybook/addon-onboarding" 16 17 ], 17 18 features: {
+72
package-lock.json
··· 181 181 "shiki": "^4.0.2", 182 182 "sinon": "^21.0.2", 183 183 "storybook": "^10.4.3", 184 + "storybook-addon-remix-react-router": "^6.1.0", 184 185 "tailwindcss": "^4.2.2", 185 186 "tinyexec": "^1.1.1", 186 187 "ts-essentials": "^10.2.0", ··· 2411 2412 "peerDependencies": { 2412 2413 "@types/react": ">=16", 2413 2414 "react": ">=16" 2415 + } 2416 + }, 2417 + "node_modules/@mjackson/form-data-parser": { 2418 + "version": "0.4.0", 2419 + "resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz", 2420 + "integrity": "sha512-zDQ0sFfXqn2bJaZ/ypXfGUe0lUjCzXybBHYEoyWaO2w1dZ0nOM9nRER8tVVv3a8ZIgO/zF6p2I5ieWJAUOzt3w==", 2421 + "dev": true, 2422 + "license": "MIT", 2423 + "dependencies": { 2424 + "@mjackson/multipart-parser": "^0.6.1" 2425 + } 2426 + }, 2427 + "node_modules/@mjackson/headers": { 2428 + "version": "0.5.1", 2429 + "resolved": "https://registry.npmjs.org/@mjackson/headers/-/headers-0.5.1.tgz", 2430 + "integrity": "sha512-sJpFgecPT/zJvwk3GRNVWNs8EkwaJoUNU2D0VMlp+gDJs6cuSTm1q/aCZi3ZtuV6CgDEQ4l2ZjUG3A9JrQlbNA==", 2431 + "dev": true, 2432 + "license": "MIT" 2433 + }, 2434 + "node_modules/@mjackson/multipart-parser": { 2435 + "version": "0.6.3", 2436 + "resolved": "https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.6.3.tgz", 2437 + "integrity": "sha512-aQhySnM6OpAYMMG+m7LEygYye99hB1md/Cy1AFE0yD5hfNW+X4JDu7oNVY9Gc6IW8PZ45D1rjFLDIUdnkXmwrA==", 2438 + "dev": true, 2439 + "license": "MIT", 2440 + "dependencies": { 2441 + "@mjackson/headers": "^0.5.0" 2414 2442 } 2415 2443 }, 2416 2444 "node_modules/@mswjs/interceptors": { ··· 15025 15053 "react": "*" 15026 15054 } 15027 15055 }, 15056 + "node_modules/react-inspector": { 15057 + "version": "9.0.0", 15058 + "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz", 15059 + "integrity": "sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==", 15060 + "dev": true, 15061 + "license": "MIT", 15062 + "peerDependencies": { 15063 + "react": "^18.0.0 || ^19.0.0" 15064 + } 15065 + }, 15028 15066 "node_modules/react-is": { 15029 15067 "version": "16.13.1", 15030 15068 "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", ··· 16149 16187 "optional": true 16150 16188 } 16151 16189 } 16190 + }, 16191 + "node_modules/storybook-addon-remix-react-router": { 16192 + "version": "6.1.0", 16193 + "resolved": "https://registry.npmjs.org/storybook-addon-remix-react-router/-/storybook-addon-remix-react-router-6.1.0.tgz", 16194 + "integrity": "sha512-p0LmPEGRF2/6Y6WJFhLZLhKEkHHrqNgzIDfHw1JMBGHxGWcZbEZtcHm3rrMw7jCzqsW4eHEr9OriOmZCetcKwg==", 16195 + "dev": true, 16196 + "hasInstallScript": true, 16197 + "license": "Apache-2.0", 16198 + "dependencies": { 16199 + "@mjackson/form-data-parser": "^0.4.0", 16200 + "compare-versions": "^6.0.0", 16201 + "react-inspector": "9.0.0" 16202 + }, 16203 + "peerDependencies": { 16204 + "react": ">=18.0.0", 16205 + "react-dom": ">=18.0.0", 16206 + "react-router": "^7.0.2", 16207 + "storybook": "^10.0.0" 16208 + }, 16209 + "peerDependenciesMeta": { 16210 + "react": { 16211 + "optional": true 16212 + }, 16213 + "react-dom": { 16214 + "optional": true 16215 + } 16216 + } 16217 + }, 16218 + "node_modules/storybook-addon-remix-react-router/node_modules/compare-versions": { 16219 + "version": "6.1.1", 16220 + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", 16221 + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", 16222 + "dev": true, 16223 + "license": "MIT" 16152 16224 }, 16153 16225 "node_modules/storybook/node_modules/@esbuild/aix-ppc64": { 16154 16226 "version": "0.27.7",
+1
package.json
··· 220 220 "shiki": "^4.0.2", 221 221 "sinon": "^21.0.2", 222 222 "storybook": "^10.4.3", 223 + "storybook-addon-remix-react-router": "^6.1.0", 223 224 "tailwindcss": "^4.2.2", 224 225 "tinyexec": "^1.1.1", 225 226 "ts-essentials": "^10.2.0",
+26 -12
src/client/AppNext.tsx
··· 2 2 import { 3 3 createBrowserRouter, 4 4 createHashRouter, RouteObject, 5 - RouterProvider, useLocation, 5 + RouterProvider, useLocation, Outlet 6 6 } from "react-router-dom"; 7 7 import {connect, ConnectedProps, Provider as ReduxProvider} from 'react-redux' 8 8 import './App.css'; 9 9 import CopyToClipboard from "./components/CopyToClipboard"; 10 - import ExternalLink from "./components/ExternalLink"; 11 10 import {store} from './store'; 12 - import Dashboard from "./dashboard/dashboard"; 13 - import RecentPage from "./recent/RecentPage"; 14 - import ScrobbledPage from "./scrobbled/ScrobbledPage"; 15 - import DeadPage from "./deadLetter/DeadPage"; 16 - import Version from "./Version"; 17 - import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; 11 + import { MSComponentListFetchable } from './components/msComponent/MSComponentList'; 18 12 import { Provider } from './components/Provider'; 19 13 import { Container, Box, Center } from '@chakra-ui/react'; 20 14 import { MsSseEvent } from '../core/Api'; 21 15 import { SSEProvider } from "@flamefrontend/sse-runtime-react"; 22 16 import { AppHeader } from './components/AppHeader'; 17 + import { NAV_LINKS, SideNavItems } from './components/SideNav'; 23 18 24 19 function NoMatch() { 25 20 const location = useLocation(); ··· 57 52 ); 58 53 } 59 54 60 - const routes: RouteObject[] = [ 55 + const Layout = () => { 56 + const location = useLocation(); 57 + return (<> 58 + <Box px="4" py="2" mb="4" pb="4" position="sticky" top="0" zIndex="1" bg="bg" borderBottomWidth="1px"><AppHeader fetchable/></Box> 59 + <Container display="flex"> 60 + <Box hideBelow="md" display="flex" flexDir="column" pr="2" gap="6" flexShrink="1"><SideNavItems items={NAV_LINKS} currentUrl={location.pathname}/></Box><Outlet/> 61 + </Container> 62 + </>); 63 + } 64 + 65 + const routesNested: RouteObject[] = [ 61 66 { 62 67 path: "/next", 63 - element: <Container maxWidth="4xl"><MSComponentListFetchable/></Container>, 68 + Component: Layout, 69 + children: [ { 70 + index: true, 71 + element: <Container p="0" maxWidth="4xl"><MSComponentListFetchable/></Container>, 72 + }, 73 + { 74 + path: "*", 75 + element: <NoMatch/> 76 + } 77 + ], 64 78 }, 65 79 { 66 80 path: "/docs", ··· 74 88 75 89 const genRouter = () => { 76 90 const useHashRouter = __USE_HASH_ROUTER__ === 'true'; 77 - return useHashRouter ? createHashRouter(routes) : createBrowserRouter(routes); 91 + return useHashRouter ? createHashRouter(routesNested) : createBrowserRouter(routesNested); 78 92 } 79 93 80 94 const router = genRouter(); ··· 87 101 function App() { 88 102 return ( 89 103 <Provider> 90 - <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> 104 + {/* <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> */} 91 105 <SSEProvider<MsSseEvent> options={sseProviderOptions}> 92 106 <RouterProvider router={router}/> 93 107 </SSEProvider>
+4 -2
src/client/components/AppHeader.tsx
··· 3 3 import { VersionNext } from "../Version"; 4 4 import { TextMuted } from "./TextMuted"; 5 5 import { DocsButton, GithubButton, HeartbeatButton, HeartbeatIcon, TerminalButton } from "./icons/ChakraIcons"; 6 + import { MobileSidebarNav } from "./MobileMenu"; 6 7 7 8 export const AppTitle = (props: { fetchable?: boolean } = {}) => { 8 9 const { ··· 11 12 12 13 return ( 13 14 <HStack gap="2"> 15 + <MobileSidebarNav/> 14 16 <LinkBox> 15 17 <HStack gap="2"> 16 18 <Image flex="0" maxWidth="30px" height="100%" width="100%" src="/icon.svg"></Image> ··· 26 28 27 29 export const RightHeaderActions = (props: any) => { 28 30 return <HStack gap="2"> 29 - <LinkBox> 31 + {/* <LinkBox> 30 32 <LinkOverlay target="__blank" href="https://status.multi-scrobbler.app"> 31 33 <HeartbeatButton /> 32 34 </LinkOverlay> ··· 40 42 <LinkOverlay target="__blank" href="https://github.com/FoxxMD/multi-scrobbler"> 41 43 <GithubButton /> 42 44 </LinkOverlay> 43 - </LinkBox> 45 + </LinkBox> */} 44 46 </HStack> 45 47 } 46 48
+67
src/client/components/MobileMenu.tsx
··· 1 + import { 2 + IconButton, 3 + Portal, 4 + Drawer, 5 + BreakpointName, 6 + } from "@chakra-ui/react" 7 + import { useState, useEffect, ComponentProps } from 'react'; 8 + import { useLocation } from 'react-router'; 9 + import { MenuButton, MenuIcon, XIcon } from "./icons/ChakraIcons"; 10 + import { NAV_LINKS, SideNavItems } from "./SideNav"; 11 + 12 + const MobileMenuButton = MenuButton; 13 + 14 + export const MobileSidebarNav = (props: { hideFrom?: BreakpointName | false } = {}) => { 15 + const { 16 + hideFrom = 'md' 17 + } = props; 18 + const [isOpen, setIsOpen] = useState(false); 19 + 20 + let location = useLocation(); 21 + 22 + const closeMenu = () => setIsOpen(false) 23 + 24 + const menuButtonProps: ComponentProps<typeof MobileMenuButton> = { 25 + variant: 'ghost' 26 + }; 27 + if (hideFrom !== false) { 28 + menuButtonProps.hideFrom = hideFrom; 29 + } 30 + 31 + useEffect(() => { 32 + setIsOpen(false); 33 + }, [location, setIsOpen]) 34 + 35 + return ( 36 + <> 37 + <Drawer.Root 38 + open={isOpen} 39 + placement="start" 40 + onPointerDownOutside={closeMenu} 41 + onEscapeKeyDown={closeMenu} 42 + onOpenChange={(e) => setIsOpen(e.open)} 43 + > 44 + <Drawer.Trigger asChild> 45 + <MobileMenuButton aria-label="Open menu" {...menuButtonProps}> 46 + <MenuIcon /> 47 + </MobileMenuButton> 48 + </Drawer.Trigger> 49 + <Portal> 50 + <Drawer.Backdrop /> 51 + <Drawer.Positioner> 52 + <Drawer.Content borderTopRadius="md" maxH="var(--content-height)"> 53 + <Drawer.CloseTrigger asChild> 54 + <IconButton size="sm" variant="ghost"> 55 + <XIcon /> 56 + </IconButton> 57 + </Drawer.CloseTrigger> 58 + <Drawer.Body display="flex" flexDir="column" gap="6" py="5" flex="1"> 59 + <SideNavItems items={NAV_LINKS} /> 60 + </Drawer.Body> 61 + </Drawer.Content> 62 + </Drawer.Positioner> 63 + </Portal> 64 + </Drawer.Root> 65 + </> 66 + ) 67 + }
+138
src/client/components/SideNav.tsx
··· 1 + import { 2 + Stack, 3 + HStack, 4 + Link, 5 + LinkProps, 6 + StackProps, 7 + BadgeProps, 8 + Badge 9 + } from "@chakra-ui/react" 10 + import { ExternalLinkIcon } from "./icons/ChakraIcons" 11 + 12 + interface SideNavItem { 13 + title: React.ReactNode 14 + url: LinkProps["href"] | undefined 15 + external?: boolean 16 + status?: string 17 + } 18 + 19 + interface SideNavProps { 20 + currentUrl?: string 21 + title: React.ReactNode 22 + id: string 23 + status?: string 24 + items: Array<SideNavItem> 25 + } 26 + 27 + 28 + const SideNavItem = (props: StackProps) => { 29 + return ( 30 + <HStack 31 + py="1.5" 32 + ps="4" 33 + pe="3" 34 + rounded="sm" 35 + color="fg.muted" 36 + _hover={{ 37 + layerStyle: "fill.subtle", 38 + }} 39 + _currentPage={{ 40 + //colorPalette: "teal", 41 + fontWeight: "medium", 42 + layerStyle: "fill.subtle", 43 + }} 44 + {...props} 45 + /> 46 + ) 47 + } 48 + 49 + export const SideNav = (props: SideNavProps) => { 50 + const { title, items, currentUrl, status } = props 51 + return ( 52 + <Stack gap="2"> 53 + {title && ( 54 + <HStack ps="4" fontWeight="semibold"> 55 + {title} 56 + {status && <StatusBadge>{status}</StatusBadge>} 57 + </HStack> 58 + )} 59 + <Stack gap="1px"> 60 + {items.map((item, index) => ( 61 + <SideNavItem key={index} asChild> 62 + {item.external ? ( 63 + <Link 64 + href={item.url as string} 65 + target="_blank" 66 + rel="noopener" 67 + aria-current={item.url === currentUrl ? "page" : undefined} 68 + > 69 + {item.title} 70 + <ExternalLinkIcon /> 71 + {item.status && <StatusBadge>{item.status}</StatusBadge>} 72 + </Link> 73 + ) : ( 74 + <Link 75 + href={item.url!} 76 + aria-current={item.url === currentUrl ? "page" : undefined} 77 + > 78 + {item.title} 79 + {item.status && <StatusBadge>{item.status}</StatusBadge>} 80 + </Link> 81 + )} 82 + </SideNavItem> 83 + ))} 84 + </Stack> 85 + </Stack> 86 + ) 87 + } 88 + 89 + export const SideNavItems = (props: {items: SideNavProps[], currentUrl?: string}) => { 90 + return (<> 91 + {props.items.map((x) => <SideNav key={x.id} currentUrl={props.currentUrl} {...x}/>)} 92 + </>) 93 + } 94 + 95 + export const StatusBadge = (props: BadgeProps) => ( 96 + <Badge 97 + size="xs" 98 + textStyle="xs" 99 + variant="solid" 100 + colorPalette="teal" 101 + textTransform="capitalize" 102 + {...props} 103 + /> 104 + ) 105 + 106 + export const NAV_LINKS: SideNavProps[] = [ 107 + { 108 + title: 'Main', 109 + id: 'Main', 110 + items: [ 111 + { 112 + title: 'Dashboard', 113 + url: '/next/' 114 + } 115 + ] 116 + }, 117 + { 118 + title: 'Links', 119 + id: 'Links', 120 + items: [ 121 + { 122 + title: 'Services Monitor', 123 + url: 'https://status.multi-scrobbler.app', 124 + external: true 125 + }, 126 + { 127 + title: 'Docs', 128 + url: 'https://docs.multi-scrobbler.app/', 129 + external: true 130 + }, 131 + { 132 + title: 'Code', 133 + url: 'https://github.com/FoxxMD/multi-scrobbler', 134 + external: true 135 + } 136 + ] 137 + } 138 + ]
+18 -2
src/client/components/icons/ChakraIcons.tsx
··· 1 - import { LuChevronRight, LuActivity, LuGithub, LuTerminal } from "react-icons/lu" 1 + import { LuChevronRight, LuActivity, LuGithub, LuTerminal, LuAlignJustify, LuX, LuExternalLink } from "react-icons/lu" 2 2 import { SiGoogledocs } from "react-icons/si"; 3 3 import { IconButton } from "@chakra-ui/react" 4 4 import { ComponentProps } from 'react'; 5 + import { IconType } from "react-icons/lib"; 6 + 7 + export const makeIconButton = (Icon: IconType) => (props: ComponentProps<typeof IconButton>) => ( 8 + <IconButton variant="surface" size="xs" {...props}> 9 + <Icon /> 10 + </IconButton> 11 + ); 5 12 6 13 export const ChevronRight = LuChevronRight; 7 14 export const ChevronRightButton = (props: ComponentProps<typeof IconButton>) => ( ··· 36 43 <IconButton variant="surface" size="xs" {...props}> 37 44 <TerminalIcon /> 38 45 </IconButton> 39 - ); 46 + ); 47 + 48 + export const MenuIcon = LuAlignJustify; 49 + export const MenuButton = makeIconButton(MenuIcon); 50 + 51 + export const XIcon = LuX; 52 + export const XButton = makeIconButton(XIcon); 53 + 54 + export const ExternalLinkIcon = LuExternalLink; 55 + export const ExternalLinkButton = makeIconButton(ExternalLinkIcon);
-1
src/client/index-next.tsx
··· 1 1 import React from 'react'; 2 2 import ReactDOM from 'react-dom/client'; 3 - import './index.css'; 4 3 import App from './AppNext.js'; 5 4 import dayjs from 'dayjs'; 6 5 import utc from 'dayjs/plugin/utc.js';
+53
src/stories/MobileNavbar.stories.tsx
··· 1 + import preview from "../../.storybook/preview.js"; 2 + import React from 'react'; 3 + 4 + import { Container, Box } from '@chakra-ui/react'; 5 + import { MobileSidebarNav } from "../client/components/MobileMenu"; 6 + import {Provider} from "../client/components/Provider"; 7 + import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router'; 8 + 9 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 10 + const meta = preview.meta({ 11 + title: 'Examples/MobileSidebar', 12 + component: MobileSidebarNav, 13 + parameters: { 14 + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 15 + layout: 'padded', 16 + reactRouter: reactRouterParameters({ 17 + location: { 18 + path: '/' 19 + }, 20 + routing: { 21 + path: '/', 22 + useStoryElement: true 23 + } 24 + }), 25 + }, 26 + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 27 + tags: ['autodocs'], 28 + // More on argTypes: https://storybook.js.org/docs/api/argtypes 29 + args: { 30 + hideFrom: false 31 + }, 32 + // argTypes: { 33 + // componentType: { 34 + // control: { type: 'select' }, 35 + // options: ['source', 'client'], 36 + // } 37 + // }, 38 + render: function Render(args) { 39 + //return (<MobileSidebarNav {...args} />) 40 + return (<Provider><Container maxWidth="lg"><Box height="500px"><MobileSidebarNav {...args} /></Box></Container></Provider>) 41 + }, 42 + decorators: [ 43 + // (Story) => { 44 + // return (<Provider><Container maxWidth="lg"><Box><Story/></Box></Container></Provider>) 45 + 46 + // }, 47 + withRouter, 48 + ] 49 + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args 50 + }); 51 + 52 + export const MobileMenu = meta.story({ 53 + });
+33
src/stories/NavbarItems.stories.tsx
··· 1 + import preview from "../../.storybook/preview.js"; 2 + import React from 'react'; 3 + 4 + import { Container, Box } from '@chakra-ui/react'; 5 + import { SideNavItems, NAV_LINKS } from "../client/components/SideNav"; 6 + import {Provider} from "../client/components/Provider"; 7 + import { generateClientApiJson, generateSourceApiJson, generateSourcePlayerJson } from "../core/tests/utils/apiFixtures.js"; 8 + 9 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 10 + const meta = preview.meta({ 11 + title: 'Examples/NavbarItems', 12 + component: SideNavItems, 13 + parameters: { 14 + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 15 + layout: 'padded', 16 + }, 17 + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 18 + tags: ['autodocs'], 19 + // More on argTypes: https://storybook.js.org/docs/api/argtypes 20 + args: { 21 + items: NAV_LINKS, 22 + }, 23 + render: function Render(args) { 24 + return (<SideNavItems {...args} />) 25 + }, 26 + decorators: [ 27 + (Story) => (<Provider><Container maxWidth="lg"><Box display="flex" flexDir="column" gap="6" flex="1"><Story/></Box></Container></Provider>), 28 + ] 29 + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args 30 + }); 31 + 32 + export const SideNav = meta.story({ 33 + });