diff --git a/package-lock.json b/package-lock.json
index 5ffee117..e7a97bf2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -130,6 +130,7 @@
"@storybook/react-vite": "^10.4.3",
"@tanstack/form-core": "^2.0.0-alpha.2",
"@tanstack/react-form": "^2.0.0-alpha.2",
+ "@tanstack/react-pacer": "^0.24.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.14.3",
@@ -5670,6 +5671,23 @@
"node": ">=10"
}
},
+ "node_modules/@tanstack/devtools-event-client": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.5.0.tgz",
+ "integrity": "sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "intent": "bin/intent.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"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",
@@ -5688,6 +5706,24 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/pacer": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.23.0.tgz",
+ "integrity": "sha512-sUA+uAebh8qeXNiaoPLsS6pawnX5zknhrWMkXNuqx18AOJeDPo5gzx3VlpABl80BiARleUEQeVpWZjElbmkrdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/devtools-event-client": "^0.5.0",
+ "@tanstack/store": "^0.11.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "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",
@@ -5745,6 +5781,28 @@
"react": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@tanstack/react-pacer": {
+ "version": "0.24.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.24.0.tgz",
+ "integrity": "sha512-9prCIQjDKF8Pk+ytDzAjmi2uvWaY/LlXkNTjkgMZMyA371YGfcajzpkGYg/jlea3IQp7LHwGpwvKFOBxfMAKRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/pacer": "0.23.0",
+ "@tanstack/react-store": "^0.11.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
diff --git a/package.json b/package.json
index 59349e63..6ebb5643 100644
--- a/package.json
+++ b/package.json
@@ -172,6 +172,7 @@
"@storybook/react-vite": "^10.4.3",
"@tanstack/form-core": "^2.0.0-alpha.2",
"@tanstack/react-form": "^2.0.0-alpha.2",
+ "@tanstack/react-pacer": "^0.24.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.14.3",
diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx
index b74a9d10..e041067e 100644
--- a/src/client/components/icons/ChakraIcons.tsx
+++ b/src/client/components/icons/ChakraIcons.tsx
@@ -43,6 +43,7 @@ import type {IconBaseProps, IconType} from "react-icons/lib";
// below are from https://selfh.st/icons/
import LZ from "./custom/listenbrainz.svg?react";
import Musicbrainz from "./custom/musicbrainz.svg?react";
+import Rocksky from "./custom/rocksky.svg?react";
import {
SiSpotify,
@@ -233,6 +234,8 @@ export const getMusicServiceIcon = (service?: string): IconType => {
return SiPlex;
case 'listenbrainz':
return LZ as unknown as IconType;
+ case 'rocksky':
+ return Rocksky as unknown as IconType;
default:
return LuExternalLink;
}
diff --git a/src/client/components/icons/custom/rocksky.svg b/src/client/components/icons/custom/rocksky.svg
new file mode 100644
index 00000000..0eb9ab33
--- /dev/null
+++ b/src/client/components/icons/custom/rocksky.svg
@@ -0,0 +1,85 @@
+
\ No newline at end of file
diff --git a/src/client/components/play/ArtistSearch.tsx b/src/client/components/play/ArtistSearch.tsx
new file mode 100644
index 00000000..647a9b37
--- /dev/null
+++ b/src/client/components/play/ArtistSearch.tsx
@@ -0,0 +1,141 @@
+import { Box, Combobox, useListCollection, Stack, Text, Avatar, Portal, HStack, Span, Spinner, Separator, Icon } from "@chakra-ui/react"
+import { MSErrorBoundary } from '../ErrorBoundary.js';
+import { useDebouncedState } from '@tanstack/react-pacer'
+import { useQuery } from '@tanstack/react-query';
+import { tanQueries } from "../../queries/index.js";
+import type { ArtistSearchResult } from "../../../core/Api.js";
+import { MusicbrainzInfoIcon } from "../musicServices/Musicbrainz.js";
+import { useEffect, useState } from "react";
+import { getMusicServiceIconElement } from "../icons/ChakraIcons.js";
+
+export const ArtistSearchResultItem = (props: { data: ArtistSearchResult }) => (
+
+ {props.data.image !== undefined ? (
+
+
+
+
+ ) : undefined}
+
+
+
+ {props.data.name} {props.data.mbid !== undefined ? : null}
+
+
+
+
+ {props.data.score !== undefined ? <>Score {props.data.score}> : undefined}
+ From {getMusicServiceIconElement(props.data.service)}
+
+
+
+
+)
+
+export interface ArtistSearchProps {
+ initial?: string
+ onChange: (val: ArtistSearchResult) => void
+}
+
+export const ArtistSearch = (props: ArtistSearchProps) => {
+
+ const {
+ initial = '',
+ onChange = (val) => console.log(val, 'Selected value for prop')
+ } = props;
+
+ const [rawInput, setRawInput] = useState(undefined);
+ const [debouncedQuery, setDebouncedQuery] = useDebouncedState(initial, { wait: 500 });
+
+ const query = useQuery({
+ enabled: debouncedQuery !== '',
+ ...tanQueries.metadata.artists(debouncedQuery)
+ });
+
+ const { collection, set } = useListCollection({
+ initialItems: query.data?.data ?? [],
+ itemToString: (item) => item.name,
+ itemToValue: (item) => item.id,
+ });
+
+ useEffect(() => {
+ if(query.isSuccess) {
+ set(query.data.data);
+ }
+ },[query, set])
+
+ return (
+
+
+ {
+ if(e.key === 'Enter') {
+ if(rawInput !== undefined) {
+ console.log('enter and onChange rawInput');
+ onChange({id: 'nonce', service: 'user', name: rawInput});
+ } else {
+ console.log('enter noop');
+ }
+ }
+ }}
+ collection={collection}
+ onInteractOutside={(e) => {
+ if(rawInput !== undefined) {
+ console.log('outside interact and onChange rawInput');
+ onChange({id: 'nonce', service: 'user', name: rawInput});
+ } else {
+ console.log('outside interact noop');
+ }
+ }}
+ onValueChange={(val) => {
+ console.log(val, 'value change');
+ onChange(val.items[0]);
+ setRawInput(undefined);
+ }}
+ onSelect={(val) => {
+ console.log(val, 'select')
+ }}
+ onInputValueChange={(e) => {
+ setDebouncedQuery(e.inputValue);
+ setRawInput(e.inputValue)
+ }}
+ width="320px"
+ >
+ {/* Select framework */}
+
+
+
+
+
+
+
+
+
+
+ {query.isLoading ? (
+
+
+ Loading...
+
+ ) : query.isError ? (
+
+ Error fetching
+
+ ) : (
+ collection.items?.map((item, i) => (
+
+
+
+
+ ))
+ )}
+ No items found
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/client/components/play/PlayEdit.tsx b/src/client/components/play/PlayEdit.tsx
new file mode 100644
index 00000000..4151eca4
--- /dev/null
+++ b/src/client/components/play/PlayEdit.tsx
@@ -0,0 +1,71 @@
+import { HStack, Tabs, Box, Float } from "@chakra-ui/react"
+import { playEditStrictCreateSchema, type JsonPlayObject, type PlayObjectMinimal } from '../../../core/Atomic.js';
+import { MSErrorBoundary } from '../ErrorBoundary.js';
+import { useForm, formOptions } from '@tanstack/react-form';
+
+
+export interface PlayEditProps {
+ initialPlay?: JsonPlayObject | PlayObjectMinimal
+ initialTab?: 'edit' | 'search'
+ context?: 'create' | 'edit'
+ onSubmit: (vals: any) => void
+}
+
+export const PlayEdit = (props: PlayEditProps) => {
+
+ const {
+ initialPlay: {
+ data = {},
+ meta = {}
+ } = {}
+ } = props;
+
+ const opts = formOptions.strictSchema(playEditStrictCreateSchema, {
+ defaultValues: {
+ data: {
+ track: '',
+ artists: [],
+ ...data
+ },
+ meta: {
+ ...meta
+ }
+ },
+ validators: [{
+ run: playEditStrictCreateSchema,
+ triggers: [],
+ }],
+ });
+
+ const form = useForm({
+ ...opts,
+ onSubmit: ({ schemaOutputs }) => {
+ props.onSubmit(schemaOutputs[0]);
+ //props.setOpen(false);
+ }
+ });
+
+
+ return (
+
+
+
+
+
+
+
+
+
+ {props.context === 'create' ? 'Create' : 'Edit'}
+ Search
+
+
+
+
+ Search here
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/client/queries/index.ts b/src/client/queries/index.ts
index 723a4102..d9b0b17f 100644
--- a/src/client/queries/index.ts
+++ b/src/client/queries/index.ts
@@ -4,7 +4,7 @@ import { useEffect, useState, useMemo } from 'react';
import ky from 'ky';
import qs from 'qs';
import { baseUrl } from "../utils";
-import type {ComponentsApiJson, PaginatedResponse, PlayApiCommonDetailed, PlayStateUI, QueryPlaysOptsJson} from "../../core/Api";
+import type {ArtistSearchResultResponse, ComponentsApiJson, PaginatedResponse, PlayApiCommonDetailed, PlayStateUI, QueryPlaysOptsJson} from "../../core/Api";
import { type SourcePlayerJson } from "../../core/Atomic";
import { queryPlayOptsRefreshableToJson } from "../utils/ComponentUtils";
@@ -74,7 +74,19 @@ const logs = createQueryKeys('logs', {
})
})
-export const tanQueries = mergeQueryKeys(components, activities, players, logs);
+const metadata = createQueryKeys('metadata', {
+ artists: (query: string) => ({
+ queryKey: ['metadata', 'artists', query],
+ queryFn: (ctx) => ky.get(`artists`, {
+ baseUrl,
+ searchParams: {
+ q: query
+ }
+ }).json()
+ })
+});
+
+export const tanQueries = mergeQueryKeys(components, activities, players, logs, metadata);
export const useQueryState = (queryKey: Readonly) => {
const queryClient = useQueryClient()
diff --git a/src/core/Api.ts b/src/core/Api.ts
index 460bb89b..be21469a 100644
--- a/src/core/Api.ts
+++ b/src/core/Api.ts
@@ -1,5 +1,5 @@
import type { CompareOpKey, ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.ts"
-import type { ClientType, ComponentAuthType, DeepReplaceValue, MonitoringStatus, QueueContext } from "./Atomic.ts"
+import { artistCreditSchema, type ClientType, type ComponentAuthType, type DeepReplaceValue, type MonitoringStatus, type QueueContext } from "./Atomic.ts"
import type { SourceType } from "./Atomic.ts"
import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, QueueName, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.ts"
import type { Dayjs } from "dayjs"
@@ -239,4 +239,18 @@ export const playStateBodySchema = z.object({
reason: z.string().optional()
});
-export type PlayStateBody = z.infer;
\ No newline at end of file
+export type PlayStateBody = z.infer;
+
+export const artistSearchResultSchema = z.object({
+ ...artistCreditSchema.shape,
+ id: z.string(),
+ image: z.string().optional(),
+ service: z.string(),
+ score: z.int().nonnegative().optional(),
+});
+export type ArtistSearchResult = z.infer;
+
+export const artistSearchResultResponseSchema = z.object({
+ data: artistSearchResultSchema.array()
+});
+export type ArtistSearchResultResponse = z.infer;
\ No newline at end of file
diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts
index b94d75aa..f915fb3e 100644
--- a/src/core/Atomic.ts
+++ b/src/core/Atomic.ts
@@ -94,6 +94,8 @@ export interface ListenRangeData extends ListenRangeDataAmb {
end: PlayProgress
}
+export const mbidSchema = z.stringFormat('mbid',/[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}/);
+
/** https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview */
export interface BrainzMeta {
/**
@@ -130,11 +132,27 @@ export interface BrainzMeta {
track?: string
}
+export const brainzMetaSchema = z.object({
+ artists: mbidSchema.array().optional(),
+ albumArtists: mbidSchema.array().optional(),
+ album: mbidSchema.optional(),
+ recording: mbidSchema.optional(),
+ releaseGroup: mbidSchema.optional(),
+ trackNumber: z.int().positive().optional(),
+ track: mbidSchema.optional()
+})
+
export interface ArtistCredit {
name: string
mbid?: string
}
+export const artistCreditSchema = z.object({
+ name: z.string(),
+ mbid: mbidSchema.optional(),
+ spotifyId: z.string().optional()
+})
+
export interface SpotifyMeta {
artist?: string[]
albumArtist?: string[]
@@ -142,11 +160,23 @@ export interface SpotifyMeta {
track?: string
}
+export const spotifyMeta = z.object({
+ artists: z.string().array().optional(),
+ albumArtist: z.string().array().optional(),
+ album: z.string().optional(),
+ track: z.string().optional()
+})
+
export interface TrackMeta {
brainz?: BrainzMeta
spotify?: SpotifyMeta
}
+export const trackMetaSchema = z.object({
+ brainz: brainzMetaSchema.optional(),
+ spotify: spotifyMeta.optional()
+})
+
export interface TrackMetaIsrc extends TrackMeta {
isrc?: string
}
@@ -170,6 +200,22 @@ export interface TrackData {
isrc?: string
}
+export const playTrackDataSchema = z.object({
+ track: z.string().optional(),
+ artists: artistCreditSchema.array().optional(),
+ albumArtists: artistCreditSchema.array().optional(),
+ album: z.string().optional(),
+ duration: z.int().nonnegative().optional(),
+ isrc: z.string().optional(),
+ meta: trackMetaSchema.optional()
+})
+
+export const playTrackStrictDataSchema = z.object({
+ ...playTrackDataSchema.shape,
+ track: z.string().nonempty(),
+ artists: artistCreditSchema.array().min(1)
+})
+
export interface PlayData extends TrackData {
/**
* The date the track was played at
@@ -182,12 +228,40 @@ export interface PlayData extends TrackData {
repeat?: boolean
}
+export const playDataSchema = z.object({
+ ...playTrackDataSchema.shape,
+ playDate: z.string().optional(),
+ listenedFor: z.int().nonnegative().optional()
+});
+
+export const playDataStrictSchema = z.object({
+ ...playTrackStrictDataSchema.shape,
+ playDate: z.string().optional(),
+ listenedFor: z.int().nonnegative().optional()
+});
+
+
export interface ArtMeta {
album?: string
track?: string
artist?: string
}
+export const artMetaSchema = z.object({
+ album: z.string().optional(),
+ track: z.string().optional(),
+ artist: z.string().optional()
+});
+
+export const playMetaUrlSchema = z.looseObject({
+ web: z.string().optional(),
+ origin: z.string().optional()
+})
+
+export const parsedFromSchema = z.enum(['backlog','now playing','player','history','ingress'])
+export type PARSED_FROM_TYPE = z.infer;// 'backlog' | 'now playing' | 'player' | 'history' | 'ingress';
+export const PARSED_FROM = parsedFromSchema.enum;
+
export type PlayMeta = Merge, T>;
export interface PlayMetaBase {
@@ -280,6 +354,15 @@ export interface PlayMetaBase {
//[key: string]: any
}
+export const playMetaSchema = z.looseObject({
+ source: z.string().optional(),
+ musicService: z.string().optional(),
+ trackId: z.string().optional(),
+ parsedFrom: parsedFromSchema.optional(),
+ url: playMetaUrlSchema.optional(),
+ art: artMetaSchema.optional()
+})
+
export interface LifecycleInput {
type: string, input: (object | string)
}
@@ -386,6 +469,17 @@ export interface ObjectPlayData extends PlayData {
playDateCompleted?: Dayjs
}
+
+export const playEditCreateSchema = z.object({
+ data: playDataSchema,
+ meta: playMetaSchema
+});
+
+export const playEditStrictCreateSchema = z.object({
+ data: playDataStrictSchema,
+ meta: playMetaSchema
+});
+
export const logLevelStandaloneSchema = z.enum(['debug','error','verbose','info','silly','silent','log','trace','warn','fatal'])
export type LogLevelStandalone = z.infer;
@@ -487,15 +581,6 @@ export const SOURCE_SOT = {
} as const satisfies Record
export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history','ingress'];
-export type PARSED_FROM_TYPE = 'backlog' | 'now playing' | 'player' | 'history' | 'ingress';
-export const PARSED_FROM = {
- backlog : 'backlog',
- nowPlaying: 'now playing',
- ingress: 'ingress',
- player: 'player',
- history: 'history'
-} as const satisfies Record
-
export interface URLData {
url: URL
normal: string
diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts
index c3df356f..e78c86c2 100644
--- a/src/core/tests/utils/apiFixtures.ts
+++ b/src/core/tests/utils/apiFixtures.ts
@@ -1,7 +1,7 @@
import { faker } from "@faker-js/faker";
-import type {ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentHistoricalApi, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi} from "../../Api.ts";
-import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes } from "../../Atomic.ts";
-import { generatePlay, normalizePlays } from "./PlayTestUtils.ts";
+import type {ArtistSearchResult, ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentHistoricalApi, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi} from "../../Api.ts";
+import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes, type MBID } from "../../Atomic.ts";
+import { generateArtist, generateArtistCredit, generatePlay, normalizePlays } from "./PlayTestUtils.ts";
import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts";
import { asJsonPlayObject } from "../../PlayMarshalUtils.ts";
import { generatePlayUid } from "../../StringUtils.ts";
@@ -15,6 +15,7 @@ import { REPORTED_PLAYER_STATUSES } from '../../Atomic.ts';
import { generateArray } from "../../DataUtils.ts";
import type {ErrorIsh} from "../../ErrorUtils.ts";
import { serializeError } from "serialize-error";
+import { stripIndents } from "common-tags";
export const generatePlayApiCommon = (commonData: Partial & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters): PlayApiCommon => {
let play: JsonPlayObject | PlayObject;
@@ -517,4 +518,35 @@ export const generateNormalWSUrl = () => {
() => `${faker.word.noun({length :1})}:${faker.helpers.arrayElement([443,80,faker.internet.port()])}`
])
return g();
+}
+
+export const generateArtistSearchResult = (partial: Partial = {}, opts: {mbidVal?: boolean | MBID} = {}): ArtistSearchResult => ({
+ ...generateArtistCredit(partial.name, opts.mbidVal),
+ id: faker.string.alphanumeric(7),
+ score: faker.number.int({min: 10, max: 100}),
+ service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']),
+ image: placeholderImage([300])
+})
+
+export const generateArtistSearchResults = (opts: {query?: string, count?: number} = {}): ArtistSearchResult[] => {
+ const results: ArtistSearchResult[] = [];
+ if(opts.count === 0) {
+ return results;
+ }
+ const c = opts.count ?? faker.number.int({min: 2, max: 5});
+ if(opts.query !== undefined) {
+ results.push(generateArtistSearchResult({name: opts.query}))
+ }
+ while(results.length !== c) {
+ results.push(generateArtistSearchResult())
+ }
+ results.sort((a,b) => b.score - a.score);
+ return results;
+}
+
+const placeholderImage = (size: [number, number?], foregroundColor?: string, backgroundColor?: string) => {
+ const fore = foregroundColor ?? 'black';
+ const back = backgroundColor ?? faker.color.rgb().slice(1)
+ return stripIndents`
+https://placehold.co/${size[0]}${size[1] !== undefined ? `x${size[1]}` : ''}/${fore}/${back}`
}
\ No newline at end of file
diff --git a/src/core/vendor/listenbrainz/interfaces.ts b/src/core/vendor/listenbrainz/interfaces.ts
index c5ad7070..c19a5c45 100644
--- a/src/core/vendor/listenbrainz/interfaces.ts
+++ b/src/core/vendor/listenbrainz/interfaces.ts
@@ -1,5 +1,7 @@
import * as z from "zod";
+
+
/*
* https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview
*/
diff --git a/src/stories/plays/ArtistResult.stories.tsx b/src/stories/plays/ArtistResult.stories.tsx
new file mode 100644
index 00000000..f11c736f
--- /dev/null
+++ b/src/stories/plays/ArtistResult.stories.tsx
@@ -0,0 +1,34 @@
+import preview from "../../../.storybook/preview.js";
+import React from 'react';
+
+import { ArtistSearchResultItem } from "../../client/components/play/ArtistSearch.js";
+import { Provider } from "../../client/components/Provider.js";
+import { Container } from '@chakra-ui/react';
+import { generateArtistSearchResult } from "../../core/tests/utils/apiFixtures.js";
+
+type PropsAndCustomArgs = React.ComponentProps & {
+ initial?: string
+};
+// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
+const meta = preview.type<{ args: PropsAndCustomArgs }>().meta({
+ title: 'Plays/Search/Artist Search',
+ component: ArtistSearchResultItem,
+ parameters: {
+ // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
+ layout: 'padded',
+ },
+ // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (),
+ ],
+ args: {
+ data: generateArtistSearchResult(),
+ },
+ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
+});
+
+// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
+export const ArtistResultStory = meta.story({
+ render: (args) =>
+});
\ No newline at end of file
diff --git a/src/stories/plays/ArtistSearch.stories.tsx b/src/stories/plays/ArtistSearch.stories.tsx
new file mode 100644
index 00000000..bf0da8f8
--- /dev/null
+++ b/src/stories/plays/ArtistSearch.stories.tsx
@@ -0,0 +1,47 @@
+import preview from "../../../.storybook/preview.js";
+import React from 'react';
+
+import { ArtistSearch } from "../../client/components/play/ArtistSearch.js";
+import { Provider } from "../../client/components/Provider.js";
+import { Container } from '@chakra-ui/react';
+import { generateArtistCredits, generateJsonPlay, withBrainz } from "../../core/tests/utils/PlayTestUtils.js"
+import clone from "clone";
+import { asJsonPlayObject } from '../../core/PlayMarshalUtils.js';
+import type { JsonPlayObject } from "../../core/Atomic.js";
+import { http, HttpResponse } from 'msw';
+import { generateArtistSearchResults } from "../../core/tests/utils/apiFixtures.js";
+
+type PropsAndCustomArgs = React.ComponentProps & {
+ initial?: string
+};
+// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
+const meta = preview.type<{ args: PropsAndCustomArgs }>().meta({
+ title: 'Plays/Search/Artist Search',
+ component: ArtistSearch,
+ parameters: {
+ // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
+ layout: 'padded',
+ msw: {
+ handlers: [
+ http.get<{ query: string }>('/api/artists', async ({ params }) => {
+ return HttpResponse.json({data: generateArtistSearchResults()});
+ }),
+ ],
+ },
+ },
+ // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (),
+ ],
+ args: {
+ initial: undefined,
+ onSubmit: (val) => console.log(val)
+ },
+ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
+});
+
+// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
+export const ArtistSearchStory = meta.story({
+ render: (args) =>
+});
\ No newline at end of file
diff --git a/src/stories/plays/PlayEdit.stories.tsx b/src/stories/plays/PlayEdit.stories.tsx
new file mode 100644
index 00000000..53283891
--- /dev/null
+++ b/src/stories/plays/PlayEdit.stories.tsx
@@ -0,0 +1,59 @@
+import preview from "../../../.storybook/preview.js";
+import React from 'react';
+
+import { PlayEdit } from "../../client/components/play/PlayEdit";
+import {Provider} from "../../client/components/Provider.js";
+import { Container } from '@chakra-ui/react';
+import { generateArtistCredits, generateJsonPlay, withBrainz } from "../../core/tests/utils/PlayTestUtils.js"
+import clone from "clone";
+import { asJsonPlayObject } from '../../core/PlayMarshalUtils.js';
+import type {JsonPlayObject} from "../../core/Atomic.js";
+
+type PropsAndCustomArgs = React.ComponentProps & {
+ includeAlbumArtists?: boolean;
+ initialPlay?: boolean
+ brainz?: boolean
+ play: JsonPlayObject
+};
+// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
+const meta = preview.type<{args: PropsAndCustomArgs}>().meta({
+ title: 'Plays/Play Edit',
+ component: PlayEdit,
+ parameters: {
+ // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
+ layout: 'padded',
+ },
+ // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
+ tags: ['autodocs'],
+decorators: [
+ (Story) => (),
+ ],
+args: {
+ play: generateJsonPlay(),
+ includeAlbumArtists: false,
+ initialPlay: false,
+ brainz: false
+ },
+ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
+});
+
+// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
+export const PlayEditStory = meta.story({
+ render: function Render(args) {
+
+ if(args.initialPlay === false) {
+ return ;
+ }
+
+ if(args.includeAlbumArtists && (args.play.data.albumArtists === undefined || args.play.data.albumArtists.length === 0)) {
+ const aa = generateArtistCredits(undefined, 2, {mbidVal: true});
+ args.play.data.albumArtists = aa;
+ }
+
+ if(args.brainz) {
+ // @ts-ignore
+ args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','recording','track','artist']}));
+ }
+ return ()
+ }
+});
\ No newline at end of file