diff --git a/src/client/AppNext.tsx b/src/client/AppNext.tsx index e8848843..a75f5ae1 100644 --- a/src/client/AppNext.tsx +++ b/src/client/AppNext.tsx @@ -16,6 +16,7 @@ import { ComponentDetailedRoutable } from './components/msComponent/MSComponentD import { MSComponentListFetchable } from './components/msComponent/MSComponentList'; import { Provider } from './components/Provider'; import { SettingsContainer } from './components/settings/settings'; +import { Toaster } from './components/Toaster'; function NoMatch() { const location = useLocation(); @@ -115,6 +116,7 @@ export const sseProviderOptions = { function App() { return ( + {/* */} options={sseProviderOptions}> diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 44c9f325..2dd0466f 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -1,11 +1,11 @@ -import { Accordion, Alert, Box, Code, Collapsible, Flex, HStack, Separator, Skeleton, SkeletonText, Span, Stack, useAccordionItemContext, type BadgeProps } from '@chakra-ui/react'; +import { Accordion, Alert, Box, Group, Portal, Code, Collapsible, Flex, HStack, Separator, Menu, Skeleton, SkeletonText, Span, Stack, useAccordionItemContext, type BadgeProps, type MenuItemProps, type MenuSelectionDetails, useClipboard } from '@chakra-ui/react'; import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react"; -import { useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; -import React, { Fragment, useEffect, useState } from "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 { DEAD_QUEUE, type ComponentType, type Second } from "../../core/Atomic"; +import { INGRESS_QUEUE, type ComponentType, type Second } from "../../core/Atomic"; import { tanQueries, useQueryWatcher } from "../queries"; import { activityTimelineHasIssue } from "../utils/ComponentUtils"; import { ActivityTimeline } from "./ActivityTimeline"; @@ -13,10 +13,13 @@ import { EphemeralElement, PlayStateBadge } from "./Badges"; import { ShortDateDisplay } from "./DateDisplay"; import { ErrorAlert } from "./ErrorAlert"; import { ExpandCollapse } from "./ExpandCollapse"; -import { DebugCopy, ExclamationCircleIcon, ExclamationTriangleIcon, InsertedIcon, RetryButton, UpdatedIcon } from "./icons/ChakraIcons"; +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" type UseActivityQueryOptions = { msQuery?: QueryPlaysOptsJson @@ -306,26 +309,104 @@ export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => return } +const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Pick = {}) => { + return ({name ?? capitalize(value)}); +} + +const MenuItemRetry = playStateMenuItem(RetryIcon, 'retry'); +const MenuItemRetryWith = playStateMenuItem(RetryIcon, 'retry', '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 = { + margin: "1px", + variant: "subtle", + size: 'xs' +} + export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) => { - let suffix: React.JSX.Element | null; + 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 clipboard = useClipboard({value: JSON.stringify(props.activity)}); + + const {mutate, isPending, variables, isSuccess} = useMutation({ + mutationKey: ['playAction', props.activity.uid], + mutationFn: () => ky.post(`/api/components/${props.activity.componentId}/state`,{ + json: {reason: 'User initiated from UI'} + }) + }); + + const menuCb = useCallback((select: MenuSelectionDetails) => { + if(select.value === 'debug') { + clipboard.copy(); + toaster.create({ + title: 'Copied debug data to clipboard', + type: 'success' + }); + } + //mutate(); + },[mutate, props.activity]); + const { activity: { queueStates = [] } = {} } = props; - if(props.activity.state === 'failed') { - suffix = ; - badgeProps.paddingRight = 0; + const hasDeadQueue = queueStates.some(x => x.queueName === INGRESS_QUEUE && x.retries > 0); + //badgeProps.paddingRight = 0; + switch(props.activity.state) { + case 'queued': + primaryAction = ; + menuItems = [,]; + break; + case 'failed': + primaryAction = ; + menuItems = [, ,]; + if(!hasDeadQueue) { + menuItems.unshift() + } + break + default: + primaryAction = ; + menuItems = [, ,]; + break + } - const hasDeadQueue = queueStates.some(x => x.queueName === DEAD_QUEUE && x.queueStatus === 'queued'); + if(menuItems.length > 0) { + menuElm = ( + + + {primaryAction} + + + + + + + + {menuItems} + + + + + ); + suffix = menuElm; + } else if(primaryAction !== undefined) { + suffix = primaryAction; + } + if(suffix !== undefined || primaryAction !== undefined) { + badgeProps.paddingRight = 0; + } return ( - - - - + ) diff --git a/src/client/components/Toaster.tsx b/src/client/components/Toaster.tsx new file mode 100644 index 00000000..ae5ca908 --- /dev/null +++ b/src/client/components/Toaster.tsx @@ -0,0 +1,41 @@ +"use client" + +import { + Toaster as ChakraToaster, + Portal, + Spinner, + Stack, + Toast, + createToaster, +} from "@chakra-ui/react" + +export const toaster = createToaster({ + placement: "bottom-end", + pauseOnPageIdle: true, +}) + +export const Toaster = () => ( + + + {(toast) => ( + + {toast.type === "loading" ? ( + + ) : ( + + )} + + {toast.title && {toast.title}} + {toast.description && ( + {toast.description} + )} + + {toast.action && ( + {toast.action.label} + )} + {toast.closable && } + + )} + + + ) diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx index 97e18b82..01e5515d 100644 --- a/src/client/components/icons/ChakraIcons.tsx +++ b/src/client/components/icons/ChakraIcons.tsx @@ -29,6 +29,8 @@ import { LuLockOpen } from "react-icons/lu" import { VscDebugRestart } from 'react-icons/vsc'; +import { HiMiniStop } from "react-icons/hi2"; +import { FaTrashCan, FaFlagCheckered } from "react-icons/fa6"; import { MdOutlineFiberNew } from "react-icons/md"; import { RiZzzFill } from "react-icons/ri"; import { SiGoogledocs } from "react-icons/si"; @@ -243,4 +245,16 @@ export const getMusicServiceIconElement = (service: string): ReactNode => { export const getMusicServiceChakraIcon = (service: string) => { const ServiceIcon = getMusicServiceIcon(service); return (props: IconProps = {}) => ; -} \ No newline at end of file +} + +export const StopIconRaw = HiMiniStop; +export const StopIcon = makeChakraIcon(StopIconRaw); +export const StopButton = makeIconButton(StopIconRaw); + +export const TrashIconRaw = FaTrashCan; +export const TrashIcon = makeChakraIcon(TrashIconRaw); +export const TrashIconButton = makeIconButton(TrashIconRaw); + +export const FinishIconRaw = FaFlagCheckered; +export const FinishIcon = makeChakraIcon(FinishIconRaw); +export const FinishButton = makeIconButton(FinishIconRaw); \ No newline at end of file diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index d038cf5e..cd6c8e66 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -146,6 +146,7 @@ export const generateComponentCommonApiJson = (data: Partial countNonLive: 0, state, players, + errors: [], status: faker.helpers.arrayElement(statusSamples), monitoringStatus, queued,