import { HStack, Box, Field, Stack, Fieldset, Button, IconButton, DateInput, DatePicker, Portal, Text, Kbd, Span } from "@chakra-ui/react" import { playEditStrictCreateSchema, type PlayObjectMinimal, type TrackData } from '../../../core/Atomic.js'; import { MSErrorBoundary } from '../ErrorBoundary.js'; import { useForm, formOptions } from '@tanstack/react-form'; import { TrackSearch } from "./TrackSearch.js"; import { ArtistSearch } from "./ArtistSearch.js"; import { BracesIcon, CopyToRight, EllipsisChakraIcon, LettersIcon, ResetIconRaw, SearchIcon, TextIcon, TrashIconButton } from "../icons/ChakraIcons.js"; import { useState, type ReactNode } from "react"; import { AlbumSearch } from "./AlbumSearch.js"; import { DurationSepEditable } from "./DurationEditable.js"; import { InfoPopover, Tooltip } from "../ToggleTip.js"; import { hashObject } from "../../../core/StringUtils.js"; import { dedupAlbumArtists } from "../../../core/MusicMetadata.js"; import { LuCalendar } from "react-icons/lu"; import { parseAbsoluteToLocal, today, getLocalTimeZone, toZoned, DateFormatter } from '@internationalized/date'; import { TextMuted } from "../TextMuted.js"; import { CreditInput } from "./CreditInput.js"; export interface PlayEditProps { initialPlay?: PlayObjectMinimal context?: 'create' | 'edit', isSubmitting?: boolean, onSubmit?: (vals: PlayObjectMinimal) => void onCancel?: () => void } const IconWrapper = (props: {children: ReactNode}) => {props.children} const editPopoverContent = ( The fields of your Play data can be edited as plain text or with search . Switch between them with the button inside the field. Plain Text Mode In Plain Text mode you edit fields individually with the exact input you want. This includes any music service IDs. Search Mode In Search mode you search for rich results from providers, like Musicbrainz, and select a result to use for one or more fields and their IDs. Select a result to apply all shown parts (album, artists, meta ids, etc...) or use the menu to apply only some parts. Tip: You can switch back to plain text mode after selecting a result to fine-tune the data manually. Search Mode Types Contextmode will search for results using your text and all other filled fields + ids as hints/restrictions. Textmode will search for results using only the text you provide. ); const logSubmit: PlayEditProps['onSubmit'] = (val) => console.log(val, 'Play Edit Submit'); const cancelNoop = () => console.log('Clicked cancel'); const tz = getLocalTimeZone(); // segment order and separators come from the formatter locale, en-ZA is the english locale that formats as YYYY/MM/DD const formatter = new DateFormatter("en-ZA", { day: "2-digit", month: '2-digit', year: 'numeric', hour: "numeric", minute: "numeric", second: "numeric", hour12: false, }) // structural type so it accepts a field of any value type without spelling out ReactFieldApi's generics const ResetButton = ({ field, onClickAdditional }: { field: { meta: { isDefaultValue: boolean }, reset: () => void }, onClickAdditional?: () => void }) => { if (!field.meta.isDefaultValue) { return { field.reset(); if (onClickAdditional !== undefined) { onClickAdditional(); } }}> } return undefined; } /** Apply a search selection to form data. Album artists stay an array because ArrayField can't render undefined, an empty list is removed on submit. */ const mergeData = >(data: T, change: Partial) => { const merged = { ...data, ...change }; return { ...merged, albumArtists: merged.albumArtists ?? [] }; } export const PlayEdit = (props: PlayEditProps) => { const { context = 'edit', initialPlay: { data = {}, meta = {} } = {}, isSubmitting = false, onSubmit = logSubmit, onCancel = cancelNoop } = props; const opts = formOptions.strictSchema(playEditStrictCreateSchema, { defaultValues: { data: { track: { name: '' }, artists: [], ...data, // ArrayField needs an array, an empty list is removed on submit albumArtists: data.albumArtists ?? [] }, meta: { ...meta } }, validators: [{ run: playEditStrictCreateSchema, triggers: [], }], }); // ArrayField only re-renders on length/_arrayVersion change, so remount artists and album artists when a search replaces them // also remounts manual inputs so an edit in progress does not outlive the data it was for const [artistsVersion, setArtistsVersion] = useState(0); // credits edited with search instead of manually, by field name and then row (always 0 for track and album) const [searching, setSearching] = useState>({}); const setSearchMode = (name: string, row: number, search: boolean) => setSearching((prev) => { const rows = [...(prev[name] ?? [])]; rows[row] = search; return { ...prev, [name]: rows }; }); const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { const [play] = schemaOutputs; onSubmit({ ...play, data: dedupAlbumArtists(play.data) }); //props.setOpen(false); } }); // track artists and album artists are edited the same way, album artists can be empty const artistsField = (name: 'data.artists' | 'data.albumArtists', legend: string, minRows: number) => ( {legend} {(array) => ( {(array.value ?? []).map((artist, i) => ( 0}> {searching[name]?.[i] === true ? ( setSearchMode(name, i, false)} onChange={(val) => { field.handleChange(val); }} /> ) : ( setSearchMode(name, i, true)} onChange={(val) => field.handleChange(val)} /> )} setArtistsVersion(v => v + 1)} /> {i >= minRows ? { array.removeValue(i); // rows are keyed by index, move the modes of later rows up and remount so no row keeps the state of the removed one setSearching((prev) => ({ ...prev, [name]: [...(prev[name] ?? [])].filter((_, j) => j !== i) })); setArtistsVersion(v => v + 1); }} /> : undefined} {field.errors.map((error) => ( {error.message} ))} )} />)} 0}> {array.errors.map((error) => ( {error.message} ))} {(array.value ?? []).length === 0 ? ( No Album Artists yet! ) : undefined} )} ); return (
{ if (e.key === 'Enter' && e.target instanceof HTMLInputElement) { e.preventDefault(); } }} onSubmit={(e) => { e.preventDefault() e.stopPropagation() form.handleSubmit() }}> {editPopoverContent} state.isDirty && !state.isDefaultValue} children={(canReset) => canReset ? : undefined} /> ( 0}> Track (Title) {searching['data.track']?.[0] === true ? ( setSearchMode('data.track', 0, false)} onChange={(val) => { // selected credits replace existing ones entirely so ids/images from a previous selection are not kept field.form.setFieldValue('data', mergeData(field.form.state.values.data, val)); setArtistsVersion(v => v + 1); }} /> ) : ( setSearchMode('data.track', 0, true)} onChange={(val) => field.handleChange(val)} /> )} {field.errors.map((error) => ( {error.message} ))} )} /> {artistsField('data.artists', 'Artists', 1)} ( 0}> Album {searching['data.album']?.[0] === true ? ( setSearchMode('data.album', 0, false)} onChange={(val) => { field.form.setFieldValue('data', mergeData(field.form.state.values.data, val)); setArtistsVersion(v => v + 1); }} /> ) : ( setSearchMode('data.album', 0, true)} onChange={(val) => field.handleChange(val)} /> )} {field.errors.map((error) => ( {error.message} ))} )} /> {artistsField('data.albumArtists', 'Album Artists', 0)} ( 0} width="fit-content"> Duration The length of the song { field.handleChange(val); }} /> field.form.setFieldValue('data.listenedFor', field.value)}> {field.errors.map((error) => ( {error.message} ))} )} /> ( 0} width="fit-content"> Listened For The amount of time you listened to this song for { field.handleChange(val); }} /> {field.errors.map((error) => ( {error.message} ))} )} /> { return ( 0} width="100%"> Played At The date-time you listened to this song at field.handleChange(toZoned(e.value[0], tz).toAbsoluteString())} > field.handleChange(toZoned(e.value[0], tz).toAbsoluteString())} granularity="second" formatter={formatter}> {field.errors.map((error) => ( {error.message} ))} ) }} />
); }