feat(ui): Implement side nav

This commit is contained in:
FoxxMD 2026-06-16 15:33:55 +00:00
parent d1ef09ba0d
commit b751c6f5f9
11 changed files with 413 additions and 17 deletions

View file

@ -12,6 +12,7 @@ export default defineMain({
// "@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
'storybook-addon-remix-react-router'
// "@storybook/addon-onboarding"
],
features: {

72
package-lock.json generated
View file

@ -181,6 +181,7 @@
"shiki": "^4.0.2",
"sinon": "^21.0.2",
"storybook": "^10.4.3",
"storybook-addon-remix-react-router": "^6.1.0",
"tailwindcss": "^4.2.2",
"tinyexec": "^1.1.1",
"ts-essentials": "^10.2.0",
@ -2413,6 +2414,33 @@
"react": ">=16"
}
},
"node_modules/@mjackson/form-data-parser": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz",
"integrity": "sha512-zDQ0sFfXqn2bJaZ/ypXfGUe0lUjCzXybBHYEoyWaO2w1dZ0nOM9nRER8tVVv3a8ZIgO/zF6p2I5ieWJAUOzt3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mjackson/multipart-parser": "^0.6.1"
}
},
"node_modules/@mjackson/headers": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@mjackson/headers/-/headers-0.5.1.tgz",
"integrity": "sha512-sJpFgecPT/zJvwk3GRNVWNs8EkwaJoUNU2D0VMlp+gDJs6cuSTm1q/aCZi3ZtuV6CgDEQ4l2ZjUG3A9JrQlbNA==",
"dev": true,
"license": "MIT"
},
"node_modules/@mjackson/multipart-parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.6.3.tgz",
"integrity": "sha512-aQhySnM6OpAYMMG+m7LEygYye99hB1md/Cy1AFE0yD5hfNW+X4JDu7oNVY9Gc6IW8PZ45D1rjFLDIUdnkXmwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mjackson/headers": "^0.5.0"
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.41.9",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
@ -15025,6 +15053,16 @@
"react": "*"
}
},
"node_modules/react-inspector": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz",
"integrity": "sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -16150,6 +16188,40 @@
}
}
},
"node_modules/storybook-addon-remix-react-router": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/storybook-addon-remix-react-router/-/storybook-addon-remix-react-router-6.1.0.tgz",
"integrity": "sha512-p0LmPEGRF2/6Y6WJFhLZLhKEkHHrqNgzIDfHw1JMBGHxGWcZbEZtcHm3rrMw7jCzqsW4eHEr9OriOmZCetcKwg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@mjackson/form-data-parser": "^0.4.0",
"compare-versions": "^6.0.0",
"react-inspector": "9.0.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
"react-router": "^7.0.2",
"storybook": "^10.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/storybook-addon-remix-react-router/node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
"integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
"dev": true,
"license": "MIT"
},
"node_modules/storybook/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",

View file

@ -220,6 +220,7 @@
"shiki": "^4.0.2",
"sinon": "^21.0.2",
"storybook": "^10.4.3",
"storybook-addon-remix-react-router": "^6.1.0",
"tailwindcss": "^4.2.2",
"tinyexec": "^1.1.1",
"ts-essentials": "^10.2.0",

View file

@ -2,24 +2,19 @@ import React from 'react';
import {
createBrowserRouter,
createHashRouter, RouteObject,
RouterProvider, useLocation,
RouterProvider, useLocation, Outlet
} from "react-router-dom";
import {connect, ConnectedProps, Provider as ReduxProvider} from 'react-redux'
import './App.css';
import CopyToClipboard from "./components/CopyToClipboard";
import ExternalLink from "./components/ExternalLink";
import {store} from './store';
import Dashboard from "./dashboard/dashboard";
import RecentPage from "./recent/RecentPage";
import ScrobbledPage from "./scrobbled/ScrobbledPage";
import DeadPage from "./deadLetter/DeadPage";
import Version from "./Version";
import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList';
import { MSComponentListFetchable } from './components/msComponent/MSComponentList';
import { Provider } from './components/Provider';
import { Container, Box, Center } from '@chakra-ui/react';
import { MsSseEvent } from '../core/Api';
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
import { AppHeader } from './components/AppHeader';
import { NAV_LINKS, SideNavItems } from './components/SideNav';
function NoMatch() {
const location = useLocation();
@ -57,10 +52,29 @@ function MissingDocs() {
);
}
const routes: RouteObject[] = [
const Layout = () => {
const location = useLocation();
return (<>
<Box px="4" py="2" mb="4" pb="4" position="sticky" top="0" zIndex="1" bg="bg" borderBottomWidth="1px"><AppHeader fetchable/></Box>
<Container display="flex">
<Box hideBelow="md" display="flex" flexDir="column" pr="2" gap="6" flexShrink="1"><SideNavItems items={NAV_LINKS} currentUrl={location.pathname}/></Box><Outlet/>
</Container>
</>);
}
const routesNested: RouteObject[] = [
{
path: "/next",
element: <Container maxWidth="4xl"><MSComponentListFetchable/></Container>,
Component: Layout,
children: [ {
index: true,
element: <Container p="0" maxWidth="4xl"><MSComponentListFetchable/></Container>,
},
{
path: "*",
element: <NoMatch/>
}
],
},
{
path: "/docs",
@ -74,7 +88,7 @@ const routes: RouteObject[] = [
const genRouter = () => {
const useHashRouter = __USE_HASH_ROUTER__ === 'true';
return useHashRouter ? createHashRouter(routes) : createBrowserRouter(routes);
return useHashRouter ? createHashRouter(routesNested) : createBrowserRouter(routesNested);
}
const router = genRouter();
@ -87,7 +101,7 @@ const sseProviderOptions = {
function App() {
return (
<Provider>
<Box px="4" py="2" pb="4"><AppHeader fetchable/></Box>
{/* <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> */}
<SSEProvider<MsSseEvent> options={sseProviderOptions}>
<RouterProvider router={router}/>
</SSEProvider>

View file

@ -3,6 +3,7 @@ import { Image, Heading, HStack, Link, LinkOverlay, LinkBox, Span, Flex, Box, Se
import { VersionNext } from "../Version";
import { TextMuted } from "./TextMuted";
import { DocsButton, GithubButton, HeartbeatButton, HeartbeatIcon, TerminalButton } from "./icons/ChakraIcons";
import { MobileSidebarNav } from "./MobileMenu";
export const AppTitle = (props: { fetchable?: boolean } = {}) => {
const {
@ -11,6 +12,7 @@ export const AppTitle = (props: { fetchable?: boolean } = {}) => {
return (
<HStack gap="2">
<MobileSidebarNav/>
<LinkBox>
<HStack gap="2">
<Image flex="0" maxWidth="30px" height="100%" width="100%" src="/icon.svg"></Image>
@ -26,7 +28,7 @@ export const AppTitle = (props: { fetchable?: boolean } = {}) => {
export const RightHeaderActions = (props: any) => {
return <HStack gap="2">
<LinkBox>
{/* <LinkBox>
<LinkOverlay target="__blank" href="https://status.multi-scrobbler.app">
<HeartbeatButton />
</LinkOverlay>
@ -40,7 +42,7 @@ export const RightHeaderActions = (props: any) => {
<LinkOverlay target="__blank" href="https://github.com/FoxxMD/multi-scrobbler">
<GithubButton />
</LinkOverlay>
</LinkBox>
</LinkBox> */}
</HStack>
}

View file

@ -0,0 +1,67 @@
import {
IconButton,
Portal,
Drawer,
BreakpointName,
} from "@chakra-ui/react"
import { useState, useEffect, ComponentProps } from 'react';
import { useLocation } from 'react-router';
import { MenuButton, MenuIcon, XIcon } from "./icons/ChakraIcons";
import { NAV_LINKS, SideNavItems } from "./SideNav";
const MobileMenuButton = MenuButton;
export const MobileSidebarNav = (props: { hideFrom?: BreakpointName | false } = {}) => {
const {
hideFrom = 'md'
} = props;
const [isOpen, setIsOpen] = useState(false);
let location = useLocation();
const closeMenu = () => setIsOpen(false)
const menuButtonProps: ComponentProps<typeof MobileMenuButton> = {
variant: 'ghost'
};
if (hideFrom !== false) {
menuButtonProps.hideFrom = hideFrom;
}
useEffect(() => {
setIsOpen(false);
}, [location, setIsOpen])
return (
<>
<Drawer.Root
open={isOpen}
placement="start"
onPointerDownOutside={closeMenu}
onEscapeKeyDown={closeMenu}
onOpenChange={(e) => setIsOpen(e.open)}
>
<Drawer.Trigger asChild>
<MobileMenuButton aria-label="Open menu" {...menuButtonProps}>
<MenuIcon />
</MobileMenuButton>
</Drawer.Trigger>
<Portal>
<Drawer.Backdrop />
<Drawer.Positioner>
<Drawer.Content borderTopRadius="md" maxH="var(--content-height)">
<Drawer.CloseTrigger asChild>
<IconButton size="sm" variant="ghost">
<XIcon />
</IconButton>
</Drawer.CloseTrigger>
<Drawer.Body display="flex" flexDir="column" gap="6" py="5" flex="1">
<SideNavItems items={NAV_LINKS} />
</Drawer.Body>
</Drawer.Content>
</Drawer.Positioner>
</Portal>
</Drawer.Root>
</>
)
}

View file

@ -0,0 +1,138 @@
import {
Stack,
HStack,
Link,
LinkProps,
StackProps,
BadgeProps,
Badge
} from "@chakra-ui/react"
import { ExternalLinkIcon } from "./icons/ChakraIcons"
interface SideNavItem {
title: React.ReactNode
url: LinkProps["href"] | undefined
external?: boolean
status?: string
}
interface SideNavProps {
currentUrl?: string
title: React.ReactNode
id: string
status?: string
items: Array<SideNavItem>
}
const SideNavItem = (props: StackProps) => {
return (
<HStack
py="1.5"
ps="4"
pe="3"
rounded="sm"
color="fg.muted"
_hover={{
layerStyle: "fill.subtle",
}}
_currentPage={{
//colorPalette: "teal",
fontWeight: "medium",
layerStyle: "fill.subtle",
}}
{...props}
/>
)
}
export const SideNav = (props: SideNavProps) => {
const { title, items, currentUrl, status } = props
return (
<Stack gap="2">
{title && (
<HStack ps="4" fontWeight="semibold">
{title}
{status && <StatusBadge>{status}</StatusBadge>}
</HStack>
)}
<Stack gap="1px">
{items.map((item, index) => (
<SideNavItem key={index} asChild>
{item.external ? (
<Link
href={item.url as string}
target="_blank"
rel="noopener"
aria-current={item.url === currentUrl ? "page" : undefined}
>
{item.title}
<ExternalLinkIcon />
{item.status && <StatusBadge>{item.status}</StatusBadge>}
</Link>
) : (
<Link
href={item.url!}
aria-current={item.url === currentUrl ? "page" : undefined}
>
{item.title}
{item.status && <StatusBadge>{item.status}</StatusBadge>}
</Link>
)}
</SideNavItem>
))}
</Stack>
</Stack>
)
}
export const SideNavItems = (props: {items: SideNavProps[], currentUrl?: string}) => {
return (<>
{props.items.map((x) => <SideNav key={x.id} currentUrl={props.currentUrl} {...x}/>)}
</>)
}
export const StatusBadge = (props: BadgeProps) => (
<Badge
size="xs"
textStyle="xs"
variant="solid"
colorPalette="teal"
textTransform="capitalize"
{...props}
/>
)
export const NAV_LINKS: SideNavProps[] = [
{
title: 'Main',
id: 'Main',
items: [
{
title: 'Dashboard',
url: '/next/'
}
]
},
{
title: 'Links',
id: 'Links',
items: [
{
title: 'Services Monitor',
url: 'https://status.multi-scrobbler.app',
external: true
},
{
title: 'Docs',
url: 'https://docs.multi-scrobbler.app/',
external: true
},
{
title: 'Code',
url: 'https://github.com/FoxxMD/multi-scrobbler',
external: true
}
]
}
]

View file

@ -1,7 +1,14 @@
import { LuChevronRight, LuActivity, LuGithub, LuTerminal } from "react-icons/lu"
import { LuChevronRight, LuActivity, LuGithub, LuTerminal, LuAlignJustify, LuX, LuExternalLink } from "react-icons/lu"
import { SiGoogledocs } from "react-icons/si";
import { IconButton } from "@chakra-ui/react"
import { ComponentProps } from 'react';
import { IconType } from "react-icons/lib";
export const makeIconButton = (Icon: IconType) => (props: ComponentProps<typeof IconButton>) => (
<IconButton variant="surface" size="xs" {...props}>
<Icon />
</IconButton>
);
export const ChevronRight = LuChevronRight;
export const ChevronRightButton = (props: ComponentProps<typeof IconButton>) => (
@ -36,4 +43,13 @@ export const TerminalButton = (props: ComponentProps<typeof IconButton>) => (
<IconButton variant="surface" size="xs" {...props}>
<TerminalIcon />
</IconButton>
);
);
export const MenuIcon = LuAlignJustify;
export const MenuButton = makeIconButton(MenuIcon);
export const XIcon = LuX;
export const XButton = makeIconButton(XIcon);
export const ExternalLinkIcon = LuExternalLink;
export const ExternalLinkButton = makeIconButton(ExternalLinkIcon);

View file

@ -1,6 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './AppNext.js';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';

View file

@ -0,0 +1,53 @@
import preview from "../../.storybook/preview.js";
import React from 'react';
import { Container, Box } from '@chakra-ui/react';
import { MobileSidebarNav } from "../client/components/MobileMenu";
import {Provider} from "../client/components/Provider";
import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = preview.meta({
title: 'Examples/MobileSidebar',
component: MobileSidebarNav,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'padded',
reactRouter: reactRouterParameters({
location: {
path: '/'
},
routing: {
path: '/',
useStoryElement: true
}
}),
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
args: {
hideFrom: false
},
// argTypes: {
// componentType: {
// control: { type: 'select' },
// options: ['source', 'client'],
// }
// },
render: function Render(args) {
//return (<MobileSidebarNav {...args} />)
return (<Provider><Container maxWidth="lg"><Box height="500px"><MobileSidebarNav {...args} /></Box></Container></Provider>)
},
decorators: [
// (Story) => {
// return (<Provider><Container maxWidth="lg"><Box><Story/></Box></Container></Provider>)
// },
withRouter,
]
// 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
});
export const MobileMenu = meta.story({
});

View file

@ -0,0 +1,33 @@
import preview from "../../.storybook/preview.js";
import React from 'react';
import { Container, Box } from '@chakra-ui/react';
import { SideNavItems, NAV_LINKS } from "../client/components/SideNav";
import {Provider} from "../client/components/Provider";
import { generateClientApiJson, generateSourceApiJson, generateSourcePlayerJson } from "../core/tests/utils/apiFixtures.js";
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = preview.meta({
title: 'Examples/NavbarItems',
component: SideNavItems,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'padded',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
args: {
items: NAV_LINKS,
},
render: function Render(args) {
return (<SideNavItems {...args} />)
},
decorators: [
(Story) => (<Provider><Container maxWidth="lg"><Box display="flex" flexDir="column" gap="6" flex="1"><Story/></Box></Container></Provider>),
]
// 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
});
export const SideNav = meta.story({
});