Something went wrong. Try again.
[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
Something went wrong. Try again.
29 kB · 681 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681import { Accordion, Alert, Dialog, Checkbox, Box, Text, Group, Portal, Code, Collapsible, Flex, HStack, Separator, Menu, Skeleton, SkeletonText, Span, Stack, useAccordionItemContext, type BadgeProps, type MenuItemProps, type MenuSelectionDetails, useClipboard, CloseButton, Button } from '@chakra-ui/react';import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react";import { useMutation, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query';import React, { Fragment, useCallback, useEffect, useState, type ComponentProps } from "react";import { LuChevronRight } from "react-icons/lu";import type { MarkOptional } from "ts-essentials";import type { ComponentsApiJson, MsSseEvent, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson, SortPlaysByProps } from "../../core/Api";import { INGRESS_QUEUE, queueContextSchema, type ComponentType, type QueueContext, type Second } from "../../core/Atomic";import { tanQueries, useQueryWatcher } from "../queries";import { activityTimelineHasIssue } from "../utils/ComponentUtils";import { ActivityTimeline } from "./ActivityTimeline";import { EphemeralElement, PlayStateBadge } from "./Badges";import { ShortDateDisplay } from "./DateDisplay";import { ErrorAlert } from "./ErrorAlert";import { ExpandCollapse } from "./ExpandCollapse";import { DebugIcon, EllipsisButton, ExclamationCircleIcon, ExclamationTriangleIcon, FinishIconRaw, InsertedIcon, type PowerOffButton, RetryButton, RetryIcon, StopButton, StopIconRaw, TrashIconRaw, UpdatedIcon } from "./icons/ChakraIcons";import { PlayData } from "./PlayData";import { TextMuted } from "./TextMuted";import { capitalize } from '../../core/StringUtils';import ky from 'ky';import type { IconType } from 'react-icons/lib';import { toaster } from "./Toaster"import { useForm, formOptions} from '@tanstack/react-form'import { FormCheckbox } from './form/formComponents';
type UseActivityQueryOptions = { msQuery?: QueryPlaysOptsJson activity?: ActivitySummaryProps['activity'] refetchOnMount?: boolean | 'always'}export const useActivityQuery = (componentId: number, activityUid: string, options: UseActivityQueryOptions = {}) => { const { msQuery, activity: preloadedActivity, ...rest } = options; const queryClient = useQueryClient();
const { isPending, isError, data: activity, error } = useQuery({ ...tanQueries.activities.single(componentId, activityUid), ...rest, staleTime: Infinity, initialData: () => { if (msQuery === undefined && preloadedActivity === undefined) { return undefined; } if(preloadedActivity !== undefined) { return preloadedActivity; } const data = queryClient.getQueryData( tanQueries.activities.list(componentId, msQuery).queryKey ) as InfiniteData<PaginatedResponse<PlayApiCommonDetailed>> | undefined;
if (data !== undefined) { for (const p of data.pages) { const res = p.data.find(x => x.uid === activityUid); if (res !== undefined) { return res; } } return undefined; } }, // structuralSharing(oldData: PlayApiCommonDetailed, newData: PlayApiCommonDetailed) { // if(oldData !== undefined) { // console.debug(`Merging new data for Activity ${activityUid} in Component ${componentId}`); // return {...newData, isUpdated: true, updatedAt: dayjs().toISOString()}; // } // return newData; // }, });
const client = useSSEContext<MsSseEvent>(); useSSEEvent(client, 'playUpdate', (payload) => { if (payload.componentId === componentId && payload.data.uid === activityUid) { console.debug(`Recieved playUpdate for Activity ${activityUid} in Component ${componentId}, invalidating single query`); queryClient.invalidateQueries({ queryKey: tanQueries.activities.single(componentId, activityUid).queryKey, refetchType: 'all' }); } });
return { activity, isPending, isError, error };};export interface ActivityDetailProps { activity: PlayApiCommonDetailed componentType: ComponentType componentName?: string}
export interface ActivitySummaryProps extends SortPlaysByProps { activity: PlayApiCommonDetailed & {isNew?: boolean | Second} componentType: ComponentType}
export const ActivitySummary = (props: ActivitySummaryProps) => { const { activity: { play, isNew, updatedAt } = {}, activity, sortBy } = props; const [updated, setUpdated] = useState<{lastUpdated: string, updated: boolean}>({lastUpdated: updatedAt, updated: false}); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setUpdated((old) => { if(old.lastUpdated === activity.updatedAt) { return {lastUpdated: activity.updatedAt, updated: false}; } return {lastUpdated: activity.updatedAt, updated: true}; }); },[setUpdated, activity]); let ephemeralStatus: React.JSX.Element | undefined; if(isNew) { ephemeralStatus = <EphemeralElement key={updatedAt ?? 'now'} expires={isNew}><InsertedIcon size="xl" /></EphemeralElement>; } else if(updated.updated) { ephemeralStatus = <EphemeralElement key={updatedAt ?? 'now'} expires={true}><UpdatedIcon colorPalette="green" color="colorPalette.focusRing" size="sm" /></EphemeralElement>; } return ( <Flex direction="column" width="100%" truncate rowGap="0.5"> <Flex width="100%" truncate> <Span truncate marginEnd="auto"><HStack>{play.data.track}{ephemeralStatus}</HStack></Span> {/* <PlayStateBadge state={activity.state} /> */} </Flex> <TextMuted textAlign="left" truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted> <HStack gap="1"> <ShortDateDisplay date={sortBy === 'played' ? play.data.playDate : play.meta?.seenAt} prefix={sortBy === 'played' ? 'Played' : 'Seen'} /><Separator orientation="vertical" height="4" /> <TextMuted>{play.meta?.source}</TextMuted> {play.meta?.parsedFrom !== undefined && <><Separator orientation="vertical" height="4" /><TextMuted>{capitalize(play.meta?.parsedFrom)}</TextMuted></>} </HStack> </Flex> );}
export const ActivitySummaryFetchable = (props: MarkOptional<ActivitySummaryProps, 'activity'> & { componentId: number, activityUid: string, query: QueryPlaysOptsJson}) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.activityUid, {activity: props.activity});
if(isError) { return <ErrorAlert error={error}/> }
if(activity === undefined && isPending) { return <ActivitySummarySkeleton/>; }
return <ActivitySummary {...props} activity={activity}/>}
export const ActivityErrorSummary = (props: {activity: ActivityDetailProps['activity']}) => { const { activity: { play: { lifecycle = [], scrobble, } = {}, error, } = {} } = props; if(error !== undefined && error !== null) { return <ErrorAlert error={error} />; } const lifecycleError = lifecycle.find(x => x.error !== undefined && x.error !== null && Object.keys(x.error).length > 0); if(lifecycleError !== undefined) { return ( <Alert.Root status="error"> <Alert.Indicator /> <Alert.Content> <Alert.Title>Error occurred during Play Transform in <Span color="fg.muted">Stage </Span>{lifecycleError.stageType}-{lifecycleError.stageName}<Span color="fg.muted"> in Hook </Span>{lifecycleError.hook} <Span color="fg.muted">from</Span> {lifecycleError.source}</Alert.Title> <Alert.Description> <Stack gap="0.5"> <Code width="fit-content" my="2" variant="surface">{lifecycleError.error.message}</Code> <Box>Open the <strong>Timeline</strong> to find the error specifics.</Box> </Stack> </Alert.Description> </Alert.Content> </Alert.Root> ); } if(scrobble?.error !== undefined) { return ( <Alert.Root status="error"> <Alert.Indicator /> <Alert.Content> <Alert.Title>Error occurred during while trying to scrobble</Alert.Title> <Alert.Description> <Stack gap="0.5"> <Code width="fit-content" my="2" variant="surface">{scrobble.error.message}</Code> <Box>Open the <strong>Timeline</strong> to find the error specifics.</Box> </Stack> </Alert.Description> </Alert.Content> </Alert.Root> ); } if(scrobble?.warnings !== undefined) { return ( <Alert.Root status="warning"> <Alert.Indicator /> <Alert.Content> <Alert.Title>There were warnings while scrobbling</Alert.Title> <Alert.Description> <Stack gap="0.5"> <Box>Open the <strong>Timeline</strong> to find warning specifics.</Box> </Stack> </Alert.Description> </Alert.Content> </Alert.Root> ) } return null;}
export const ActivityDetails = (props: ActivityDetailProps) => { const { activity, componentType, componentName, activity: { error, input: { play: original, } = {} } } = props;
console.log(`Rendering ActivityDetails for ${activity.play.data.track}`);
const ExpandCollapseContext = () => { const item = useAccordionItemContext(); return <ExpandCollapse hideBelow="sm" display={useAccordionItemContext()?.expanded ? 'flex' : 'none'} onClick={(val) => setCollapsibleOpen(val)} /> }
const [collapsibleOpen, setCollapsibleOpen] = useState(undefined);
let timelineStatusIcon: React.JSX.Element | undefined; const timelineIssue = activityTimelineHasIssue(activity); if(timelineIssue === 'error') { timelineStatusIcon = <ExclamationCircleIcon size="sm" color="red.focusRing"/>; } else if(timelineIssue === 'warn') { timelineStatusIcon = <ExclamationTriangleIcon size="sm" color="yellow.focusRing"/>; }
return ( <Stack gap="2"> {/* <ActivityErrorSummary activity={props.activity}/> */} {error !== undefined && error !== null ? <ErrorAlert error={error}/> : null} <Accordion.Root width="full" variant="enclosed" collapsible multiple> <Accordion.Item value="info"> <Accordion.ItemTrigger> <Accordion.ItemIndicator /> Play Data </Accordion.ItemTrigger> <Accordion.ItemContent> <Accordion.ItemBody> <PlayData compareDefault='Final' play={original ?? activity.play} final={activity.play} /> </Accordion.ItemBody> </Accordion.ItemContent> </Accordion.Item> <Accordion.Item value="timeline"> <Flex justify="flex-start"> <Accordion.ItemTrigger> <Accordion.ItemIndicator /> Timeline {timelineStatusIcon} </Accordion.ItemTrigger> <Stack style={{ paddingBlock: "var(--accordion-padding-y)", paddingInline: "var(--accordion-padding-x)" }} justify="flex-start" alignItems="flex-end"> </Stack> <ExpandCollapseContext/> </Flex> <Accordion.ItemContent> <Accordion.ItemBody> <ActivityTimeline activity={activity} collapsibleOpen={collapsibleOpen} componentType={componentType} componentName={componentName} /> </Accordion.ItemBody> </Accordion.ItemContent> </Accordion.Item> </Accordion.Root> </Stack> )}
export interface ActivityDetailFetchableProps { uid: string componentId: number componentType: ComponentType activity?: ActivitySummaryProps['activity']}
export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity, refetchOnMount: 'always'});
const { data } = useQueryWatcher<ComponentsApiJson>(tanQueries.components.single(props.componentId).queryKey);
if(isPending) { return <Fragment><Skeleton height="100px"/><Skeleton height="100px"/></Fragment> }
if(isError) { return <ErrorAlert error={error}/> }
return <ActivityDetails componentType={props.componentType} componentName={data?.type} key={props.uid} activity={activity}/>}
const opts = formOptions.strictSchema(queueContextSchema, { defaultValues: { transform: true, dupeCheck: true, useCache: true }, validators: [{ run: queueContextSchema, triggers: [], }],});
const RetryWithDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (vals: QueueContext) => void }) => { const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { console.log(schemaOutputs[0]); props.onSubmit(schemaOutputs[0]); props.setOpen(false); } }); return (<Dialog.Root role="alertdialog" closeOnInteractOutside unmountOnExit open={props.open} size="sm" onOpenChange={(e) => props.setOpen(e.open)} > <Portal> <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content> <Dialog.CloseTrigger asChild> <CloseButton /> </Dialog.CloseTrigger> <Dialog.Header> <Dialog.Title>Retry With...</Dialog.Title> </Dialog.Header> <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() form.handleSubmit() }}> <Dialog.Body> <Stack> <Text> Retry the Play using the options below: </Text> <form.Field name="transform" // @ts-expect-error need compiler strict: true children={(field) => (<FormCheckbox field={field} label="Transform?"/>)} /> <form.Field name="dupeCheck" // @ts-expect-error need compiler strict: true children={(field) => (<FormCheckbox field={field} label="Check for Duplicate?"/>)} /> <form.Field name="useCache" // @ts-expect-error need compiler strict: true children={(field) => (<FormCheckbox field={field} label="Use Cache?"/>)} /> </Stack> </Dialog.Body> <Dialog.Footer> <Button variant="outline" onClick={() => props.setOpen(false)}> Cancel </Button> <Button colorPalette="blue" type="submit">Retry</Button> </Dialog.Footer> </form> </Dialog.Content> </Dialog.Positioner> </Portal> </Dialog.Root> )}
const DeleteDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (withChildren?: boolean) => void, source?: boolean }) => { const [children, setChildren] = useState(false); return (<Dialog.Root role="alertdialog" closeOnInteractOutside unmountOnExit open={props.open} size="sm" onOpenChange={(e) => props.setOpen(e.open)} > <Portal> <Dialog.Backdrop /> <Dialog.Positioner> <Dialog.Content> <Dialog.CloseTrigger asChild> <CloseButton /> </Dialog.CloseTrigger> <Dialog.Header> <Dialog.Title>Confirm Deletion</Dialog.Title> </Dialog.Header> <Dialog.Body> <Stack> <Text> Are you sure you want to delete this Play? </Text> {props.source === true && <Checkbox.Root checked={children} onCheckedChange={(val) => setChildren(!!val.checked)}> <Checkbox.HiddenInput /> <Checkbox.Control /> <Checkbox.Label>and delete Play in Clients</Checkbox.Label> </Checkbox.Root>} </Stack> </Dialog.Body> <Dialog.Footer> <Button variant="outline" onClick={() => props.setOpen(false)}> Cancel </Button> <Button colorPalette="red" onClick={() => { props.onSubmit(children); props.setOpen(false); }}>Delete</Button> </Dialog.Footer> </Dialog.Content> </Dialog.Positioner> </Portal> </Dialog.Root> )}
const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Omit<MenuItemProps, 'value'> = {}) => ( <Menu.Item key={value} value={value} {...props}><Box flex="1">{name ?? capitalize(value)}</Box><Icon /></Menu.Item>)
const MenuItemRetry = playStateMenuItem(RetryIcon, 'retry');const MenuItemRetryWith = playStateMenuItem(RetryIcon, 'retryWith', 'Retry With...');const MenuItemCancel = playStateMenuItem(StopIconRaw, 'cancel');const MenuItemTrash = playStateMenuItem(TrashIconRaw, 'delete');const MenuItemDebug = playStateMenuItem(DebugIcon, 'debug');const MenuItemFinish = playStateMenuItem(FinishIconRaw, 'finish', 'Mark Completed');
const primaryActionProps: ComponentProps<typeof PowerOffButton> = { margin: "1px", variant: "subtle", size: 'xs'}
export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed, componentType: ComponentType}) => { let suffix: React.JSX.Element | undefined; let primaryAction: React.JSX.Element | undefined; let menuElm: React.JSX.Element | undefined; let menuItems: React.JSX.Element[]; const badgeProps: BadgeProps = {};
const [retryOpen, setRetryOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const clipboard = useClipboard({value: JSON.stringify(props.activity)});
const {mutate, isPending, variables, isSuccess} = useMutation({ mutationKey: ['playAction', props.activity.uid], mutationFn: (data: {action: string, context?: QueueContext, state?: string}) => { if(data.action === 'queue') { return ky.post(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`,{ json: data.context ?? {} }); } if(data.action === 'delete') { return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}`); } if(data.action === 'cancel') { return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`); } if(data.action === 'finish') { return ky.post(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/state`, { json: {state: data.state} }); } } });
const menuCb = useCallback((select: MenuSelectionDetails) => { switch(select.value) { case 'debug': clipboard.copy(); toaster.create({ title: 'Copied debug data to clipboard', type: 'success' }); break; case 'retry': mutate({action: 'queue'}); break; case 'retryWith': setRetryOpen(true); break; case 'cancel': mutate({action: 'cancel'}); break; case 'finish': mutate({action: 'finish', state: 'discarded'}); break; case 'delete': setDeleteOpen(true); break; } },[mutate,clipboard, setRetryOpen, setDeleteOpen]);
const { activity: { queueStates = [] } = {} } = props; const hasDeadQueue = queueStates.some(x => x.queueName === INGRESS_QUEUE && x.retries > 0); switch(props.activity.state) { case 'queued': primaryAction = <StopButton size={{base: '2xs', smTo2xl: 'xs'}} color="red.400" margin="1px" variant="subtle" onClick={() => mutate({action: 'cancel'})}/>; menuItems = [<MenuItemDebug/>,<MenuItemTrash disabled={isPending} color="fg.error" _hover={{ bg: "bg.error", color: "fg.error" }}/>]; break; case 'failed': primaryAction = <RetryButton size={{base: '2xs', smTo2xl: 'xs'}} margin="1px" variant="subtle" onClick={() => mutate({action: 'queue'})}/>; menuItems = [ <MenuItemDebug/>, <MenuItemRetryWith disabled={isPending} />, <MenuItemFinish disabled={isPending}/>, <MenuItemTrash disabled={isPending} color="fg.error" _hover={{ bg: "bg.error", color: "fg.error" }}/> ]; if(hasDeadQueue) { menuItems.unshift(<MenuItemCancel disabled={isPending}/>) //primaryAction = <StopButton size={{base: '2xs', smTo2xl: 'xs'}} color="red.400" margin="1px" variant="subtle" onClick={() => mutate({action: 'cancel'})}/>; } break default: primaryAction = <RetryButton size={{base: '2xs', smTo2xl: 'xs'}} margin="1px" variant="subtle" onClick={() => mutate({action: 'queue'})}/>; menuItems = [<MenuItemRetryWith disabled={isPending} />,<MenuItemDebug/>,<MenuItemTrash disabled={isPending} color="fg.error" _hover={{ bg: "bg.error", color: "fg.error" }}/>]; break
} if(menuItems.length > 0) { menuElm = ( <Menu.Root positioning={{ placement: "bottom-end" }} onSelect={menuCb}> <Group attached> {primaryAction} <Menu.Trigger asChild> <EllipsisButton disabled={isPending} {...primaryActionProps}/> </Menu.Trigger> </Group> <Portal> <Menu.Positioner> <Menu.Content> {menuItems} </Menu.Content> </Menu.Positioner> </Portal> </Menu.Root> ); suffix = menuElm; } else if(primaryAction !== undefined) { suffix = primaryAction; } if(suffix !== undefined || primaryAction !== undefined) { badgeProps.paddingRight = 0; } return ( <Stack> <RetryWithDialog open={retryOpen} setOpen={setRetryOpen} onSubmit={(vals) => mutate({action: 'queue', context: vals})}/> <DeleteDialog open={deleteOpen} setOpen={setDeleteOpen} onSubmit={() => mutate({action: 'delete'})} source={props.componentType === 'source'}/> <HStack> <PlayStateBadge {...badgeProps} minH="32px" alignItems="anchor-center" size={{base: 'xs', sm: 'lg', mdTo2xl: 'lg'}} hasDeadQueue={hasDeadQueue} state={props.activity.state} suffix={suffix} /> </HStack> </Stack> )}
export const ActivityStateActionsFetchable = (props: ActivityDetailFetchableProps) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity});
if(!isPending && !isError) { return <ActivityStateActions activity={activity} componentType={props.componentType}/>; }}
export const ActivityCollapsible = (props: ActivitySummaryProps & { key?: string, live?: boolean, componentId: number, query: QueryPlaysOptsJson }) => { const { activity: { play } = {}, activity, sortBy, live = false } = props; return ( <Collapsible.Root key={props.key} unmountOnExit
lazyMount _open={{ background: "var(--chakra-colors-bg-subtle)" }} style={{ borderColor: "var(--chakra-colors-border)", borderWidth: '1px', }} > <HStack style={{ paddingBlock: "var(--chakra-spacing-2)", paddingInline: "var(--chakra-spacing-4)" }}> <Collapsible.Trigger userSelect="text" w="full"
display="flex" gap="2" alignItems="center" truncate cursor="pointer"
> <Collapsible.Indicator transition="transform 0.2s" _open={{ transform: "rotate(90deg)" }} > <LuChevronRight /> </Collapsible.Indicator> <ActivitySummaryFetchable activityUid={activity.uid} {...props}/> </Collapsible.Trigger> <ActivityStateActionsFetchable activity={activity} componentId={props.componentId} componentType={props.componentType} uid={activity.uid}/> </HStack> <Collapsible.Content borderTopColor="gray.border" style={{ paddingBlock: "var(--chakra-spacing-4)", paddingInline: "var(--chakra-spacing-4)" }}> <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} uid={activity.uid} activity={activity} /> </Collapsible.Content> </Collapsible.Root> )}
export const ActivitySummarySkeleton = () => ( <Collapsible.Root key="skeleton" disabled> <Collapsible.Trigger userSelect="text" w="full" paddingY="3" display="flex" gap="2" alignItems="center" truncate cursor="pointer" style={{ paddingBlock: "var(--chakra-spacing-2)", paddingInline: "var(--chakra-spacing-4)" }} > <Collapsible.Indicator transition="transform 0.2s" _open={{ transform: "rotate(90deg)" }} > <LuChevronRight /> </Collapsible.Indicator> <Stack> <Skeleton height="2rem" width="20rem" /> <SkeletonText noOfLines={3} /> </Stack> </Collapsible.Trigger> <Collapsible.Content borderTopColor="gray.border" style={{ paddingBlock: "var(--chakra-spacing-4)", paddingInline: "var(--chakra-spacing-4)" }}> <SkeletonText noOfLines={2} /> </Collapsible.Content> </Collapsible.Root> )