diff --git a/package-lock.json b/package-lock.json index c8c1ebbf..0a596ea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -133,6 +133,8 @@ "@storybook/addon-docs": "^10.4.3", "@storybook/react-vite": "^10.4.3", "@tailwindcss/vite": "^4.2.2", + "@tanstack/form-core": "^2.0.0-alpha.2", + "@tanstack/react-form": "^2.0.0-alpha.2", "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-virtual": "^3.14.3", @@ -6413,6 +6415,38 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/form-core": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-2.0.0-alpha.2.tgz", + "integrity": "sha512-u/2s1rJ1pSdFgU/oKz2zt/Wdb3XQK9LY1IIL2pLMu0AVYK5f8Ue5UwaCjcUxEAI8g6XJpbmEuy2wLIwHZbZnwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/pacer-lite": "^0.2.2", + "@tanstack/store": "^0.11.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.2.tgz", + "integrity": "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", @@ -6435,6 +6469,27 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-2.0.0-alpha.2.tgz", + "integrity": "sha512-rIm6kRhpK9Fbb5p3xiwKKofOKHXAK3K28NeR4sM5T0h8CU0TpCLMz27SAHY2xjvG2iSgx8n4mLVEyTxw7iHJdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "2.0.0-alpha.2", + "@tanstack/react-store": "^0.11.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.101.0", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", @@ -6470,6 +6525,25 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.14.3", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz", @@ -6488,6 +6562,17 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", diff --git a/package.json b/package.json index 34cbd668..007a0390 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,8 @@ "@storybook/addon-docs": "^10.4.3", "@storybook/react-vite": "^10.4.3", "@tailwindcss/vite": "^4.2.2", + "@tanstack/form-core": "^2.0.0-alpha.2", + "@tanstack/react-form": "^2.0.0-alpha.2", "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-virtual": "^3.14.3", diff --git a/src/backend/common/database/drizzle/repositories/PlayRepository.ts b/src/backend/common/database/drizzle/repositories/PlayRepository.ts index ace3dd4c..7a6615c2 100644 --- a/src/backend/common/database/drizzle/repositories/PlayRepository.ts +++ b/src/backend/common/database/drizzle/repositories/PlayRepository.ts @@ -14,7 +14,7 @@ import type {ErrorLike, SourceType} from "../../../../../core/Atomic.ts"; import type {FindMany, FindWhere, FindWith, PlayInputNew, PlayNew, PlaySelect, PlaySelectWithQueueStates, PlayWith, QueueStateSelect, WhereClause} from "../drizzleTypes.ts"; import { type DbConcrete, runTransaction } from "../drizzleUtils.ts"; import { generateInputEntity, generatePlayEntity, hydratePlaySelect, stateChangeToPlayEvent, transformToPlayEvent, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts"; -import { playEvents, playInputs, plays, relations, type TSchema } from "../schema/schema.ts"; +import { playEvents, playInputs, plays, queueStates, relations, type TSchema } from "../schema/schema.ts"; import { buildDateCompare, type CompareDateOp, type ComponentConstrainedRepoOpts, DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts"; import type {PaginatedResponse} from "../../../../../core/Api.ts"; import type {PaginatedQueryResponse} from "../../../../../core/Api.ts"; @@ -39,7 +39,7 @@ export interface PlayWhereOpts { text?: string[] } -export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues' | 'events'; +export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues' | 'events' | 'children'; export interface QueryPlaysOpts extends PlayWhereOpts { sort?: 'seenAt' | 'playedAt' order?: 'asc' | 'desc' @@ -103,6 +103,16 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { return res as unknown as PlayWith | undefined; } + findByUidWith = async (uid: string, args: WithPlayRelation[]): Promise | undefined> => { + const res = await this.db.query.plays.findFirst({ + where: { + uid + }, + with: buildPlayWith(args) + }); + return res as unknown as PlayWith | undefined; + } + createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}): Promise[]> => { const { @@ -742,12 +752,13 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { }) } - public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[] } & ComponentConstrainedRepoOpts = {}): Promise[]> => { + public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[], notId?: number } & ComponentConstrainedRepoOpts = {}): Promise[]> => { const { componentId = this.componentId, bufferTime, states, - with: qWith + with: qWith, + notId } = opts; const query: FindMany<'plays'> = {}; @@ -761,6 +772,11 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { in: states } } + if(notId !== undefined) { + where.id = { + NOT: notId + } + } query.where = where; return ((await this.db.query.plays.findMany({ @@ -805,6 +821,25 @@ group by componentId,compacted;`); } return res; } + + async deleteById(id: number): Promise { + await runTransaction(this.db, async () => { + await this.db.delete(playInputs).where(eq(playInputs.playId, id)); + await this.db.delete(playEvents).where(eq(playEvents.playId, id)); + await this.db.delete(queueStates).where(eq(queueStates.playId, id)); + await this.db.update(plays).set({parentId: null}).where(eq(plays.parentId, id)); + await this.db.delete(plays).where(eq(plays.id, id)); + }); + } + async deleteByIds(ids: number[]): Promise { + await runTransaction(this.db, async () => { + await this.db.delete(playInputs).where(inArray(playInputs.playId, ids)); + await this.db.delete(playEvents).where(inArray(playEvents.playId, ids)); + await this.db.delete(queueStates).where(inArray(queueStates.playId, ids)); + await this.db.update(plays).set({parentId: null}).where(inArray(plays.parentId, ids)); + await this.db.delete(plays).where(inArray(plays.id, ids)); + }); + } } export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean, useDuration?: boolean} = {}): CompareDateOp => { @@ -870,6 +905,9 @@ export const buildPlayWith = (args: WithPlayRelation[] | undefined): FindWith<'p case 'events': qWith.events = true; break; + case 'children': + qWith.children = true; + break; default: throw new Error(`Unknown relation ${w}`); } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 1eec892e..096633d6 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -15,7 +15,8 @@ import { SOURCE_SOT, QUEUE_STATUS_FAILED, isPlayObject, - DEAD_LETTER_RETRIES_DEFAULT + DEAD_LETTER_RETRIES_DEFAULT, + PARSED_FROM } from "../../core/Atomic.ts"; import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts"; import AbstractComponent from "../common/AbstractComponent.ts"; @@ -721,7 +722,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i this.scrobbleSOTRanges = groupPlaysToTimeRanges(queued.concat(dead), this.scrobbleSOTRanges, {staleNowBuffer: this.config.options?.refreshStaleAfter}); } - async getSOTScrobblesForPlay(play: PlayObject): Promise { + async getSOTScrobblesForPlay(play: PlayObject, opts: {useCache?: boolean} = {}): Promise { + const {useCache = true} = opts; let range: PaginatedTimeRangeOptions = this.scrobbleSOTRanges.find(x => x.from <= play.data.playDate.unix() && x.to > Math.min(dayjs().subtract(this.config.options?.refreshStaleAfter ?? REFRESH_STALE_DEFAULT, 's').unix(), play.data.playDate.unix())); if(range === undefined) { this.logger.warn(`No Scrobble SOT range found! Should have been handled before this. Creating a new one for ${buildTrackString(play)}`); @@ -738,7 +740,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i range.from = range.from - DEFAULT_NEW_PADDING.asSeconds(); range.to = Math.min(dayjs().unix(), range.to + 30) // 30 seconds after "to", or now } - const cachedPlaysRes = await this.cache.cacheClientScrobbles.get(this.getScrobbleCacheKey(range.from, range.to)); + const cachedPlaysRes = useCache ? await this.cache.cacheClientScrobbles.get(this.getScrobbleCacheKey(range.from, range.to)) : undefined; if(cachedPlaysRes instanceof Error) { throw new SimpleError('Cannot get historical plays due to cached error', {cause: cachedPlaysRes, shortStack: true}); } @@ -746,7 +748,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return cachedPlaysRes; } try { - const plays = await this.getScrobblesForTimeRange(range); + const plays = (await this.getScrobblesForTimeRange(range)).map(x => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}})); plays.sort(sortByOldestPlayDate); await this.cache.cacheClientScrobbles.set(this.getScrobbleCacheKey(range.from, range.to), plays, (this.config.options?.refreshStaleAfter ?? REFRESH_STALE_DEFAULT) * 1000); return plays; @@ -786,14 +788,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i if(this.transformRules.compare?.existing === undefined) { // if no existing transform then we can run cheap db match - const cheapExisting = await this.playRepo.checkExisting(playObj, {states: ['scrobbled']}); + const cheapExisting = await this.playRepo.checkExisting(playObj, {states: ['scrobbled'], notId: playObjPre.id}); if(cheapExisting !== undefined) { const s: ScrobbledPlayObject = {play: cheapExisting.play, scrobble: cheapExisting.play.scrobble?.mergedScrobble}; return [s, [s]]; } } - const closeTemporalPlays = await this.playRepo.getTemporallyClosePlays(playObj, {states: ['scrobbled']}); + const closeTemporalPlays = await this.playRepo.getTemporallyClosePlays(playObj, {states: ['scrobbled'], notId: playObjPre.id}); const dtInvariantMatches = (await pMap(closeTemporalPlays.map(x => x.play), this.staggerMappers.existing(async x => (await this.transformPlay(x, TRANSFORM_HOOK.existing))), {concurrency: 3})) .filter(x => playObjDataMatch(playObj, x)); @@ -1223,7 +1225,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i if (dupeCheck && this.upstreamRefresh.refreshEnabled) { await this.handleQueuedScrobbleRanges(); try { - historicalPlays = await this.getSOTScrobblesForPlay(playEntity.play); + historicalPlays = await this.getSOTScrobblesForPlay(playEntity.play, {useCache: !(isRetry || !useCache)}); } catch (e) { if (e.message === 'Cannot get historical plays due to cached error') { @@ -1247,7 +1249,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i let isDupe = false; if(dupeCheck) { - const { summary, ...matchResult } = await this.existingScrobble(playEntity.play, historicalPlays); + const { summary, ...matchResult } = await this.existingScrobble({...playEntity.play, id: playEntity.id, uid: playEntity.uid}, historicalPlays); events.push(dupeCheckToPlayEvent({ summary, ...matchResult })); isDupe = matchResult.match; } @@ -1787,6 +1789,19 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return await this.playRepo.findByUid(uid, { with: withQuery as WithPlayRelation[] }) as unknown as PlayApiCommonDetailed; } + public async deletePlay(play: PlayWith<'children'>, children?: boolean): Promise { + if(children) { + await this.playRepo.deleteByIds([play.id, ...(play.children ?? []).map(x => x.id)]); + this.emitEvent('playDelete', {uid: play.uid}); + for(const p of play.children) { + this.emitEvent('playDelete', {uid: p.uid, componentId: p.componentId}); + } + } else { + await this.playRepo.deleteById(play.id); + this.emitEvent('playDelete', {uid: play.uid, componentId: play.componentId}); + } + } + public emitEvent = (eventName: string, payload: object) => { this.emitter.emit(eventName, { data: payload, diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index e07cb6ae..07560749 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -424,6 +424,27 @@ export const setupApi = (app: Express, router: ReturnType { + const { + component, + query: { + children + }, + params: { + playUid + } + } = req; + + const play = await component.playRepo.findByUidWith<'children'>(playUid, ['children']); + if(play === undefined) { + return res.sendStatus(404); + } + + await component.deletePlay(play); + + return res.sendStatus(200); + }); + router.post('/components/:componentVal/plays/:playUid/queue', {middleware: [componentAwareMiddle], bodySchema: queueContextSchema.optional()}, async (req, res, next) => { const { component, diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 89abc1f5..e29ccf14 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -54,6 +54,7 @@ import { PLAY_EVENT_TYPE, type PlayEvent } from '../../core/PlayEvent.ts'; import { dupeCheckToPlayEvent, entityIsPlayEntity, queueStateToPlayEvent, stateChangeToPlayEvent, transformToPlayEvent } from '../common/database/drizzle/entityUtils.ts'; import type { PlayProcessingResult } from '../common/infrastructure/PlayProcessing.ts'; import { PlayProcessingError } from '../common/errors/PlayProcessingError.ts'; +import { runTransaction } from '../common/database/drizzle/drizzleUtils.ts'; export interface RecentlyPlayedOptions { limit?: number @@ -573,7 +574,11 @@ export default abstract class AbstractSource extends AbstractComponent implement } async existingDiscovered(play: PlayObject): Promise { - const list: PlayObject[] = await this.getRecentPlays(true); + let list: PlayObject[] = await this.getRecentPlays(true); + if(play.id !== undefined) { + // don't want to return the same play (by id) when checking by source + list = list.filter(x => x.id === undefined || (x.id !== play.id)); + } return await this.existingDiscoveredPlay(play, list); // if(matchResults.match) { // return matchResults.closestMatchedPlay; @@ -1190,7 +1195,7 @@ export default abstract class AbstractSource extends AbstractComponent implement events.push(dupeCheckToPlayEvent({ match: true, reason: `Matched hash on existing Play ${cheapExisting.uid} with close temporality` })); existing = { ...cheapExisting.play, id: cheapExisting.id, uid: cheapExisting.uid }; } else { - const matchRes = await this.existingDiscovered(preCompared); + const matchRes = await this.existingDiscovered({...preCompared, id: playEntity.id, uid: playEntity.uid}); events.push(dupeCheckToPlayEvent(matchRes)); if (matchRes.match) { existing = matchRes.closestMatchedPlay; @@ -1417,6 +1422,19 @@ export default abstract class AbstractSource extends AbstractComponent implement return await this.playRepo.findByUid(uid, { with: withQuery as WithPlayRelation[] }) as unknown as PlayApiCommonDetailed; } + public async deletePlay(play: PlayWith<'children'>, children?: boolean): Promise { + if(children) { + await this.playRepo.deleteByIds([play.id, ...(play.children ?? []).map(x => x.id)]); + this.emitEvent('playDelete', {uid: play.uid}); + for(const p of play.children) { + this.emitEvent('playDelete', {uid: p.uid, componentId: p.componentId}); + } + } else { + await this.playRepo.deleteById(play.id); + this.emitEvent('playDelete', {uid: play.uid, componentId: play.componentId}); + } + } + public emitEvent = (eventName: string, payload: object = {}) => { this.emitter.emit(eventName, { type: this.type, diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 172d47b6..2c0eebe1 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -1,11 +1,11 @@ -import { Accordion, Alert, Dialog, 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 { 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, type ComponentType, type QueueContext, type Second } from "../../core/Atomic"; +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"; @@ -20,6 +20,8 @@ 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 @@ -305,41 +307,136 @@ export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => return } -const RetryWithDialog = (props: {open: boolean, setOpen: (open: boolean) => void}) => - ( void, onSubmit: (vals: QueueContext) => void }) => { + const form = useForm({ + ...opts, + onSubmit: ({ schemaOutputs }) => { + console.log(schemaOutputs[0]); + props.onSubmit(schemaOutputs[0]); + props.setOpen(false); + } + }); + return ( props.setOpen(e.open)} + > + + + + + + + + + Retry With... + +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }}> + + + + Retry the Play using the options below: + + ()} + /> + ()} + /> + ()} + /> + + + + + + +
+
+
+
+
+ ) +} + +const DeleteDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (withChildren?: boolean) => void, source?: boolean }) => { + const [children, setChildren] = useState(false); + return ( props.setOpen(e.open)} - > + > - - - - - - - - Retry With Options... - - - - Retry the Play using the options below: - - - - - - - - + + + + + + + + Confirm Deletion + + + + + Are you sure you want to delete this Play? + + {props.source === true && setChildren(!!val.checked)}> + + + and delete Play in Clients + } + + + + + + + + - - ) +
+ ) +} -const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Pick = {}) => ( +const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Omit = {}) => ( {name ?? capitalize(value)} ) @@ -356,14 +453,15 @@ const primaryActionProps: ComponentProps = { size: 'xs' } -export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) => { +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[] = []; + 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)}); @@ -371,10 +469,13 @@ export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) = mutationKey: ['playAction', props.activity.uid], mutationFn: (data: {action: string, context?: QueueContext}) => { if(data.action === 'queue') { - return ky.post(`/api/components/${props.activity.componentId}/plays/${props.activity.uid}/state`,{ + 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}`); + } const realAction = data.action === 'cancel' ? 'queue' : data.action; return ky.delete(`/api/components/${props.activity.componentId}/plays/${props.activity.uid}/${realAction}`); } @@ -401,8 +502,11 @@ export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) = case 'finish': mutate({action: 'dead'}); break; + case 'delete': + setDeleteOpen(true); + break; } - },[mutate,clipboard, setRetryOpen]); + },[mutate,clipboard, setRetryOpen, setDeleteOpen]); const { activity: { @@ -413,18 +517,18 @@ export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) = switch(props.activity.state) { case 'queued': primaryAction = mutate({action: 'cancel'})}/>; - menuItems = [,]; + menuItems = [,]; break; case 'failed': primaryAction = mutate({action: 'queue'})}/>; - menuItems = [, ,]; + menuItems = [,,]; if(!hasDeadQueue) { menuItems.unshift() } break default: primaryAction = mutate({action: 'queue'})}/>; - menuItems = [, ,]; + menuItems = [,,]; break } @@ -455,7 +559,8 @@ export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) = } return ( - + mutate({action: 'queue', context: vals})}/> + mutate({action: 'delete'})} source={props.componentType === 'source'}/> @@ -467,7 +572,7 @@ export const ActivityStateActionsFetchable = (props: ActivityDetailFetchableProp const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity}); if(!isPending && !isError) { - return ; + return ; } } diff --git a/src/client/components/ActivityTimeline.tsx b/src/client/components/ActivityTimeline.tsx index 24c6017b..f1edba7a 100644 --- a/src/client/components/ActivityTimeline.tsx +++ b/src/client/components/ActivityTimeline.tsx @@ -166,12 +166,23 @@ const TransformsItem = (props: Pick & { match: PlayMatchResult }) => { +const ScrobbleMatchItem = (props: Pick & { match: PlayMatchResult, componentName?: string }) => { const { match, + match: { + closestMatchedPlay: { + meta + } = {} + } = {}, + componentName = 'service', collapsibleOpen } = props; + let fromSource: React.JSX.Element | undefined; + if(match.match && meta !== undefined) { + fromSource = from {meta.parsedFrom === 'history' ? capitalizeWords(componentName) : 'MS Database'} + } + return ( @@ -186,7 +197,7 @@ const ScrobbleMatchItem = (props: Pick Found {match.match ? a duplicate Scrobble : 'no duplicate Scrobbles'}} + indicator={Found {match.match ? a duplicate Scrobble : 'no duplicate Scrobbles'}{fromSource}} defaultOpen={collapsibleOpen} disableUntil="md" unmountOnExit @@ -440,7 +451,7 @@ export const ActivityTimeline = (props: ActivityTimelineProps) => { timelineElements.push(); } break; case 'dupeCheck': { - timelineElements.push(); + timelineElements.push(); } break; case 'scrobbleResult': timelineElements.push(); diff --git a/src/client/components/form/formComponents.tsx b/src/client/components/form/formComponents.tsx new file mode 100644 index 00000000..a9cab013 --- /dev/null +++ b/src/client/components/form/formComponents.tsx @@ -0,0 +1,28 @@ +import type { FieldWithValue } from '@tanstack/react-form' +import { Checkbox, Field } from '@chakra-ui/react'; +import type { ReactNode } from 'react'; + +export const FormCheckbox = ({ + field, + label, + disabled +}: { + field: FieldWithValue + label: string | ReactNode + disabled?: boolean +}) => 0} disabled={disabled}> + field.handleChange(!!details.checked)} + onBlur={field.handleBlur} + > + + + {label} + + {field.errors.map((error) => ( + + {error.message} + + ))} + ; \ No newline at end of file diff --git a/src/client/components/playActivity/ActivityList.tsx b/src/client/components/playActivity/ActivityList.tsx index 859ebfdb..baa0eb6d 100644 --- a/src/client/components/playActivity/ActivityList.tsx +++ b/src/client/components/playActivity/ActivityList.tsx @@ -126,7 +126,31 @@ export const ListContainerFetchable = (props: { componentId: number, componentTy return insertInfinitePlay(componentData.data, old); }); } - } + } break; + case 'playDelete': + { + queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData, unknown>) => { + const componentData = payload.data as MsSseEventPayload>; + // playDelete events don't happen outside of ui the except for retention cleanup + // and its unlikely the user would have fetched data during a retention cleanup event + // + // so its likey this data will actually be updated so we optimistically build the new data even though we don't know + // for sure that any page actually has our uid + const newQueryData: InfiniteData, unknown> = { + pages: [], + pageParams: { ...old.pageParams } + }; + for(const p of old.pages) { + const newPageData = p.data.filter(x => x.uid !== componentData.data.uid); + if(newPageData.length !== p.data.length) { + newQueryData.pages.push({...p, data: newPageData}); + } else { + newQueryData.pages.push(p); + } + } + return newQueryData; + }); + } } } }); diff --git a/src/core/MSCoreEventMap.ts b/src/core/MSCoreEventMap.ts index a63c3ffd..308af844 100644 --- a/src/core/MSCoreEventMap.ts +++ b/src/core/MSCoreEventMap.ts @@ -5,6 +5,7 @@ import type { EmittedMSEvent, JsonPlayObject, SourcePlayerObj } from "./Atomic.t export interface MSCoreEvents { playInsert: [EmittedMSEvent] playUpdate: [EmittedMSEvent, 'uid'>>] + playDelete: [EmittedMSEvent>] playerUpdate: [EmittedMSEvent>] playerDelete: [EmittedMSEvent<{platformId: string},{options: {scrobbleTo: string[]}}>] scrobble: [EmittedMSEvent<{play: JsonPlayObject}>]