[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.

refactor(ui): Fix conditional hook for SSE and add stories for header elements

FoxxMD (Jul 12, 2026, 3:43 PM UTC) fbcd1e1b d48f2bd4

+118 -77
+29 -72
src/client/components/AppHeader.tsx
··· 1 - import { Box, Flex, FloatingPanel, Heading, HStack, IconButton, Image, LinkBox, LinkOverlay, Portal, Stack, Status, Switch, Text } from '@chakra-ui/react'; 2 - import { 3 - useWindowSize, 4 - } from '@react-hook/window-size'; 1 + import { Box, Flex, Heading, HStack, IconButton, Image, LinkBox, LinkOverlay, Stack, Status, Switch, Text } from '@chakra-ui/react';; 5 2 import React, { type ComponentProps, type PropsWithChildren } from "react"; 6 - import { LuGripHorizontal, LuMinus } from "react-icons/lu"; 7 3 import { Link as RouterLink } from 'react-router'; 8 4 import { VersionNext } from "../Version"; 9 5 import { MobileSidebarNav } from "./MobileMenu"; 10 6 import { TextMuted } from "./TextMuted"; 11 - import { TerminalButton, TerminalIcon, XButton } from "./icons/ChakraIcons"; 7 + import { TerminalIcon } from "./icons/ChakraIcons"; 12 8 13 9 import { useSSEContext, useSSEStatus } from "@flamefrontend/sse-runtime-react"; 14 10 import type {ErrorLike} from "../../core/Atomic"; 15 11 import { ErrorAlert } from "./ErrorAlert"; 16 - import { MSErrorBoundary } from "./ErrorBoundary"; 17 12 import { ExternaLinksMenu } from "./ExternaLinksMenu"; 18 - import { LogsFetchable } from "./LogsNext"; 13 + import { FloatingLogs } from "./LogsNext"; 19 14 import { ToggleTip } from "./ToggleTip"; 20 - import { Ripple } from "./icons/AnimatedIcons"; 21 15 22 16 export const AppTitle = (props: { fetchable?: boolean } = {}) => { 23 17 const { ··· 42 36 ) 43 37 } 44 38 45 - interface RightHeaderSwitchLogsProps { 46 - logsEnabled?: boolean 47 - setLogsEnabled?: (val: boolean) => void 48 - } 49 - 50 39 export const RightHeaderSwitchLogs = (props: { 51 40 logsEnabled?: boolean 52 41 setLogsEnabled?: (val: boolean) => void ··· 72 61 </HStack> 73 62 } 74 63 75 - export const SSEStatus = (props: {live?: boolean, status?: ReturnType<typeof useSSEStatus>['status'], error?: ErrorLike}) => { 64 + interface SSEStatusProps { 65 + status?: ReturnType<typeof useSSEStatus>['status'] 66 + error?: ErrorLike 67 + } 76 68 77 - let status = props.status ?? 'closed' 78 - let error: ErrorLike = props.error; 69 + export const SSEStatusElement = (props: SSEStatusProps) => { 79 70 80 - if(props.live) { 81 - const client = useSSEContext(); 82 - const { status: sseStatus, error: sseError } = useSSEStatus(client); 83 - status = sseStatus; 84 - error = sseError as ErrorLike; 85 - } 71 + const status = props.status ?? 'closed'; 86 72 let content: string | React.JSX.Element; 87 73 let color: ComponentProps<typeof Status.Indicator>['colorPalette']; 88 74 switch(status) { ··· 92 78 content = ( 93 79 <Stack> 94 80 <Text>Live events connection is <strong>{status}</strong></Text> 95 - <ErrorAlert error={error}/> 81 + <ErrorAlert error={props.error}/> 96 82 </Stack> 97 83 ); 98 84 break; ··· 109 95 } 110 96 111 97 return ( 112 - <ToggleTip content={content}> 98 + <ToggleTip content={<Box py="2">{content}</Box>}> 113 99 <IconButton variant="ghost" size="xs"> 114 100 <Status.Root> 115 101 <Status.Indicator colorPalette={color} style={color === 'green' ? {animation: 'icon-fade-half 3s infinite linear'} : undefined}/> ··· 119 105 ) 120 106 } 121 107 122 - export const RightHeaderFloatingLogs = (props: {streamable?: boolean}) => { 123 - const [width, height] = useWindowSize(); 108 + const SSEStatus = (props: {live?: boolean} & SSEStatusProps) => { 109 + if(props.live) { 110 + return <SSEStatusLive/>; 111 + } 124 112 125 - return ( 126 - <FloatingPanel.Root 127 - defaultPosition={{x: width * 0.03, y: height * 0.65}} 128 - defaultSize={{ width: width * 0.95, height: height * 0.3 }} 129 - persistRect 130 - closeOnEscape 131 - lazyMount 132 - > 133 - <FloatingPanel.Trigger asChild> 134 - <TerminalButton /> 135 - </FloatingPanel.Trigger> 136 - <Portal> 137 - <FloatingPanel.Positioner zIndex="1400"> 138 - <FloatingPanel.Content> 139 - <FloatingPanel.Header> 140 - <FloatingPanel.DragTrigger> 141 - <LuGripHorizontal /> 142 - <FloatingPanel.Title>Logs <Ripple/></FloatingPanel.Title> 143 - </FloatingPanel.DragTrigger> 144 - <FloatingPanel.Control> 145 - <FloatingPanel.StageTrigger stage="minimized" asChild> 146 - <IconButton variant="ghost" size="2xs"> 147 - <LuMinus /> 148 - </IconButton> 149 - </FloatingPanel.StageTrigger> 150 - <FloatingPanel.CloseTrigger asChild> 151 - <XButton variant="ghost" size="2xs" /> 152 - </FloatingPanel.CloseTrigger> 153 - </FloatingPanel.Control> 154 - </FloatingPanel.Header> 155 - <FloatingPanel.Body> 156 - <MSErrorBoundary><LogsFetchable streamable={props.streamable} /></MSErrorBoundary> 157 - </FloatingPanel.Body> 158 - <FloatingPanel.ResizeTriggers /> 159 - </FloatingPanel.Content> 160 - </FloatingPanel.Positioner> 161 - </Portal> 162 - </FloatingPanel.Root> 163 - ); 113 + return <SSEStatusElement {...props}/> 114 + } 115 + 116 + const SSEStatusLive = () => { 117 + const client = useSSEContext(); 118 + const { status: sseStatus, error: sseError } = useSSEStatus(client); 119 + return <SSEStatus status={sseStatus} error={sseError as ErrorLike}/> 164 120 } 165 121 166 - export const AppHeader = (props: PropsWithChildren<{ fetchable?: boolean }>) => { 167 - return ( 122 + 123 + export const AppHeader = (props: PropsWithChildren<{ fetchable?: boolean }>) => ( 168 124 <Flex justify="space-between"> 169 125 <AppTitle fetchable={props.fetchable} /> 170 126 <Flex justify="flex-start" gap="1" alignItems="flex-end"> 171 127 <ExternaLinksMenu hideBelow="sm"/> 172 - <Box marginRight="2"><SSEStatus live={props.fetchable}/></Box> 173 - <RightHeaderFloatingLogs streamable={props.fetchable}/> 128 + <Box marginRight="2"> 129 + <SSEStatus live={props.fetchable}/> 130 + </Box> 131 + <FloatingLogs streamable={props.fetchable}/> 174 132 </Flex> 175 133 </Flex> 176 - ) 177 - } 134 + )
+52 -1
src/client/components/LogsNext.tsx
··· 1 - import { Box, HStack, SegmentGroup, Separator, Span, Stack, Text } from '@chakra-ui/react'; 1 + import { Box, HStack, SegmentGroup, Separator, Span, Stack, Text, FloatingPanel, Portal, IconButton } from '@chakra-ui/react'; 2 + import { 3 + useWindowSize, 4 + } from '@react-hook/window-size'; 2 5 import { useSSE } from "@flamefrontend/sse-runtime-react"; 3 6 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 4 7 import * as AnsiImport from "ansi-to-react"; ··· 8 11 import type {LogLevelStandalone, LogOutputConfig} from '../../core/Atomic'; 9 12 import { tanQueries } from '../queries'; 10 13 import { ChakraClipDynamic } from './ChakraClipboard'; 14 + import { TerminalButton, XButton } from './icons/ChakraIcons'; 15 + import { LuGripHorizontal, LuMinus } from 'react-icons/lu'; 16 + import { Ripple } from './icons/AnimatedIcons'; 17 + import { MSErrorBoundary } from './ErrorBoundary'; 11 18 12 19 // @ts-expect-error Ansi export is built incorrectly 13 20 const Ansi = AnsiImport.default.default as typeof AnsiImport.default; ··· 139 146 <Logs ref={logRef} logs={logList}/> 140 147 </Stack>); 141 148 149 + } 150 + 151 + export const FloatingLogs = (props: {streamable?: boolean}) => { 152 + const [width, height] = useWindowSize(); 153 + 154 + return ( 155 + <FloatingPanel.Root 156 + defaultPosition={{x: width * 0.03, y: height * 0.65}} 157 + defaultSize={{ width: width * 0.95, height: height * 0.3 }} 158 + persistRect 159 + closeOnEscape 160 + lazyMount 161 + > 162 + <FloatingPanel.Trigger asChild> 163 + <TerminalButton /> 164 + </FloatingPanel.Trigger> 165 + <Portal> 166 + <FloatingPanel.Positioner zIndex="1400"> 167 + <FloatingPanel.Content> 168 + <FloatingPanel.Header> 169 + <FloatingPanel.DragTrigger> 170 + <LuGripHorizontal /> 171 + <FloatingPanel.Title>Logs <Ripple/></FloatingPanel.Title> 172 + </FloatingPanel.DragTrigger> 173 + <FloatingPanel.Control> 174 + <FloatingPanel.StageTrigger stage="minimized" asChild> 175 + <IconButton variant="ghost" size="2xs"> 176 + <LuMinus /> 177 + </IconButton> 178 + </FloatingPanel.StageTrigger> 179 + <FloatingPanel.CloseTrigger asChild> 180 + <XButton variant="ghost" size="2xs" /> 181 + </FloatingPanel.CloseTrigger> 182 + </FloatingPanel.Control> 183 + </FloatingPanel.Header> 184 + <FloatingPanel.Body> 185 + <MSErrorBoundary><LogsFetchable streamable={props.streamable} /></MSErrorBoundary> 186 + </FloatingPanel.Body> 187 + <FloatingPanel.ResizeTriggers /> 188 + </FloatingPanel.Content> 189 + </FloatingPanel.Positioner> 190 + </Portal> 191 + </FloatingPanel.Root> 192 + ); 142 193 }
+33
src/stories/header/SSEStatus.stories.tsx
··· 1 + import preview from "../../../.storybook/preview.js"; 2 + import React from 'react'; 3 + 4 + 5 + import { Container } from '@chakra-ui/react'; 6 + import {Provider} from "../../client/components/Provider.js";; 7 + import { SSEStatusElement } from "../../client/components/AppHeader.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: 'Header/SSE Status', 12 + component: SSEStatusElement, 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 + // streamable: false, 22 + // }, 23 + render: function Render(args) { 24 + return (<SSEStatusElement {...args} />) 25 + }, 26 + decorators: [ 27 + (Story) => (<Provider><Container fluid><Story/></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 LogWindow = meta.story({ 33 + });
+4 -4
src/stories/logs/LogsFetchable.stories.tsx src/stories/header/LogsFetchable.stories.tsx
··· 5 5 import { Container } from '@chakra-ui/react'; 6 6 import {Provider} from "../../client/components/Provider.js"; 7 7 import { logsApiResponse } from "../../core/tests/utils/apiFixtures.js"; 8 - import { RightHeaderFloatingLogs } from "../../client/components/AppHeader.js"; 8 + import { FloatingLogs } from "../../client/components/LogsNext.js"; 9 9 10 10 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 11 11 const meta = preview.meta({ 12 - title: 'Logs/Floating Panel', 13 - component: RightHeaderFloatingLogs, 12 + title: 'Header/Logs', 13 + component: FloatingLogs, 14 14 parameters: { 15 15 // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 16 16 layout: 'padded', ··· 29 29 streamable: false, 30 30 }, 31 31 render: function Render(args) { 32 - return (<RightHeaderFloatingLogs {...args} />) 32 + return (<FloatingLogs {...args} />) 33 33 }, 34 34 decorators: [ 35 35 (Story) => (<Provider><Container fluid><Story/></Container></Provider>),