[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 log copy

FoxxMD (Jul 6, 2026, 6:03 PM UTC) 7151d96b b2b20187

+85 -18
+47 -2
src/client/components/ChakraClipboard.tsx
··· 1 - import { ComponentProps, useMemo } from 'react'; 2 - import { Clipboard, IconButton } from "@chakra-ui/react" 1 + import { ComponentProps, useMemo, useCallback, useEffect, useState } from 'react'; 2 + import { Clipboard, IconButton, useClipboard } from "@chakra-ui/react" 3 3 import {safeStringify} from '../../core/StringUtils'; 4 + import { CheckIcon, CopyIcon } from './icons/ChakraIcons'; 4 5 5 6 6 7 export const ChakraClip = (props: Omit<ComponentProps<typeof Clipboard.Root>, 'children' | 'value'> & {value: any}) => { ··· 22 23 </IconButton> 23 24 </Clipboard.Trigger> 24 25 </Clipboard.Root> 26 + ) 27 + } 28 + 29 + const createCopyVal = (value: any) => { 30 + if(typeof value === 'string') { 31 + return value; 32 + } 33 + return safeStringify(value); 34 + } 35 + 36 + 37 + /** 38 + * Copies value from onCopy only when clicked 39 + */ 40 + export const ChakraClipDynamic = (props: Omit<ComponentProps<typeof Clipboard.Root>, 'children' | 'value'> & {onCopy: () => any}) => { 41 + const { 42 + onCopy, 43 + ...rest 44 + } = props; 45 + 46 + // idk what's going on here exactly but just using clip.setValue does not set clipboard, it requires two clicks to get actual value 47 + // so instead using some useState abuse to re-render component and wait to copy until everything matches 48 + // probably related to this https://github.com/chakra-ui/chakra-ui/issues/6759 49 + 50 + const [clipVal, setClipVal] = useState<string | undefined>(); 51 + const clip = useClipboard({timeout: 1000}); 52 + 53 + useEffect(() => { 54 + if(clipVal !== undefined && clip.value === clipVal) { 55 + //console.log('run copy'); 56 + clip.copy(); 57 + setClipVal(undefined); 58 + } 59 + },[clip.value, clipVal, setClipVal]); 60 + const invokeCopy = useCallback(() => { 61 + const val = createCopyVal(onCopy()); 62 + //console.log(val); 63 + setClipVal(val); 64 + clip.setValue(val); 65 + },[onCopy, clip.setValue]); 66 + return ( 67 + <IconButton variant="surface" size="xs" onClick={invokeCopy}> 68 + {!clip.copied ? <CopyIcon/> : <CheckIcon/>} 69 + </IconButton> 25 70 ) 26 71 }
+20 -13
src/client/components/LogsNext.tsx
··· 1 - import React, {PropsWithChildren, useState, useEffect} from 'react'; 1 + import React, {PropsWithChildren, useState, useEffect, useRef, useCallback} from 'react'; 2 2 import * as AnsiImport from "ansi-to-react"; 3 3 import { Text, Box, SegmentGroup, Separator, HStack, Stack, Span } from '@chakra-ui/react'; 4 4 import {FixedSizeList} from "fixed-size-list"; ··· 6 6 import { useQueryClient, QueryFunctionContext, useQuery, useMutation } from '@tanstack/react-query' 7 7 import { LogOutputConfig } from '../../core/Atomic'; 8 8 import ky from 'ky'; 9 - import { baseUrl } from '../utils'; 10 9 import { LogLevel } from '@foxxmd/logging'; 10 + import { tanQueries } from '../queries'; 11 + import { ChakraClip, ChakraClipDynamic } from './ChakraClipboard'; 11 12 12 13 // @ts-expect-error Ansi export is built incorrectly 13 14 const Ansi = AnsiImport.default.default as typeof AnsiImport.default; ··· 35 36 36 37 let list = createFixedList(50); 37 38 38 - export const Logs = (props: {logs: Readonly<LogLineProps[]>}) => { 39 - return <Box fontFamily="source-code-pro, Menlo, Monaco, Consolas,'Courier New',monospace;"> 39 + export const Logs = (props: {logs: Readonly<LogLineProps[]>, ref?: React.Ref<HTMLDivElement>}) => { 40 + return <Box ref={props.ref} fontFamily="source-code-pro, Menlo, Monaco, Consolas,'Courier New',monospace;"> 40 41 {props.logs.map(x => <LogLine message={x.message}/>)} 41 42 </Box> 42 43 } ··· 55 56 const [logLimit, setLogLimit] = useState(limit); 56 57 const [logList, setLogList] = useState<MinLogInfo[]>([]); 57 58 59 + 58 60 const { isPending, isError, data, error } = useQuery({ 59 - queryKey: ['logs', { level: logLevel, limit: logLimit }], 60 - queryFn: queryFn, 61 + ...tanQueries.logs.list(logLevel, logLimit), 61 62 staleTime: Infinity 62 63 }); 63 64 ··· 123 124 } 124 125 }, [data, limit,setLogList]); 125 126 127 + const logRef = useRef<HTMLDivElement>(null); 128 + 129 + const getLogCopyText = useCallback(() =>{ 130 + const content = logRef.current.innerText; 131 + return content.replaceAll(/\n\[/g, '['); 132 + },[logRef]); 133 + 126 134 return (<Stack> 127 - <HStack gap="5"><Span>Level: {levelGroup}</Span><Separator orientation="vertical" height="4"/><Span>Limit: {limitGroup}</Span></HStack> 128 - <Logs logs={logList}/> 135 + <HStack gap="5"> 136 + <Span>Level: {levelGroup}</Span> 137 + <Separator orientation="vertical" height="4"/><Span marginEnd="auto">Limit: {limitGroup}</Span> 138 + <ChakraClipDynamic onCopy={getLogCopyText}/> 139 + </HStack> 140 + <Logs ref={logRef} logs={logList}/> 129 141 </Stack>); 130 142 131 - } 132 - 133 - type LogsQueryKey = ['logs', {level: string, limit: number}]; 134 - const queryFn = async (context: QueryFunctionContext<LogsQueryKey>) => { 135 - return await ky.get(`logs`, { baseUrl: baseUrl }).json() as {data: {line: string, time: number, levelLabel: string, level: number}[]}; 136 143 }
+6 -2
src/client/components/icons/ChakraIcons.tsx
··· 20 20 LuEye, 21 21 LuEyeClosed, 22 22 LuCalendar, 23 - LuRefreshCw 23 + LuRefreshCw, 24 + LuCopy 24 25 } from "react-icons/lu" 25 26 import { VscDebugRestart } from 'react-icons/vsc'; 26 27 import { MdOutlineFiberNew } from "react-icons/md"; ··· 159 160 160 161 export const InsertedIcon = makeChakraIcon(MdOutlineFiberNew); 161 162 162 - export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp); 163 + export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp); 164 + 165 + export const CopyIcon = makeChakraIcon(LuCopy); 166 + export const CopyIconButton = makeIconButton(LuCopy);
+12 -1
src/client/queries/index.ts
··· 59 59 queryKey: ['components', componentId, 'play', platformId], 60 60 queryFn: (ctx) => ky.get(`components/${componentId}/players/${platformId}`, { baseUrl }).json<SourcePlayerJson>() 61 61 }) 62 + }); 63 + 64 + const logs = createQueryKeys('logs', { 65 + list: (level: string, limit: number) => ({ 66 + queryKey: ['logs', {level, limit}], 67 + queryFn: (ctx) => { 68 + return ky.get(`logs`, { 69 + baseUrl: baseUrl 70 + }).json<{data: {line: string, time: number, levelLabel: string, level: number}[]}>(); 71 + } 72 + }) 62 73 }) 63 74 64 - export const tanQueries = mergeQueryKeys(components, activities, players); 75 + export const tanQueries = mergeQueryKeys(components, activities, players, logs); 65 76 66 77 export const useQueryState = (queryKey: Readonly<unknown[]>) => { 67 78 const queryClient = useQueryClient()