feat(ui): Implement play actions

This commit is contained in:
FoxxMD
2026-08-25 13:15:22 +00:00
parent 0ad86ad692
commit 649280582e
5 changed files with 154 additions and 15 deletions
+2
View File
@@ -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 (
<Provider>
<Toaster/>
{/* <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> */}
<SSEProvider<MsSseEvent> options={sseProviderOptions}>
<RouterProvider router={router}/>
+95 -14
View File
@@ -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 <ActivityDetails componentType={props.componentType} componentName={data?.type} key={props.uid} activity={activity}/>
}
const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Pick<MenuItemProps, 'disabled'> = {}) => {
return (<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, '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<typeof PowerOffButton> = {
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 = <RetryButton size="xs" margin="1px" variant="subtle"/>;
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 = <StopButton size={{base: '2xs', smTo2xl: 'xs'}} color="red.400" margin="1px" variant="subtle"/>;
menuItems = [<MenuItemTrash disabled={isPending}/>,<MenuItemDebug/>];
break;
case 'failed':
primaryAction = <RetryButton size={{base: '2xs', smTo2xl: 'xs'}} margin="1px" variant="subtle"/>;
menuItems = [<MenuItemRetryWith disabled />, <MenuItemTrash disabled={isPending}/>,<MenuItemDebug/>];
if(!hasDeadQueue) {
menuItems.unshift(<MenuItemFinish disabled={isPending}/>)
}
break
default:
primaryAction = <RetryButton size={{base: '2xs', smTo2xl: 'xs'}} margin="1px" variant="subtle"/>;
menuItems = [<MenuItemRetryWith disabled />, <MenuItemTrash disabled={isPending}/>,<MenuItemDebug/>];
break
}
const hasDeadQueue = queueStates.some(x => x.queueName === DEAD_QUEUE && x.queueStatus === 'queued');
if(menuItems.length > 0) {
menuElm = (
<Menu.Root positioning={{ placement: "bottom-end" }} onSelect={menuCb}>
<Group attached>
{primaryAction}
<Menu.Trigger asChild>
<EllipsisButton hideBelow="sm" 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>
<HStack>
<PlayStateBadge {...badgeProps} minH="32px" alignItems="anchor-center" size="lg" hasDeadQueue={hasDeadQueue} state={props.activity.state} suffix={suffix} />
</HStack>
<HStack justifyContent="flex-end">
<DebugCopy variant="ghost" value={JSON.stringify(props.activity)}/>
<PlayStateBadge {...badgeProps} minH="32px" alignItems="anchor-center" size={{base: 'xs', sm: 'lg', mdTo2xl: 'lg'}} hasDeadQueue={hasDeadQueue} state={props.activity.state} suffix={suffix} />
</HStack>
</Stack>
)
+41
View File
@@ -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 = () => (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
+15 -1
View File
@@ -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 = {}) => <Icon {...props}><ServiceIcon/></Icon>;
}
}
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);
+1
View File
@@ -146,6 +146,7 @@ export const generateComponentCommonApiJson = (data: Partial<ComponentCommonApi>
countNonLive: 0,
state,
players,
errors: [],
status: faker.helpers.arrayElement(statusSamples),
monitoringStatus,
queued,