;\n}\n\ninterface TabPanelProps extends ComponentPropsWithoutRef<'div'> {\n className?: string;\n children: ReactNode;\n index?: number;\n}\nexport function TabPanel({\n className,\n children,\n index,\n ...domProps\n}: TabPanelProps) {\n const {id} = useContext(TabContext);\n\n const [tabIndex, setTabIndex] = useState(0);\n const ref = useRef(null);\n\n // The tabpanel should have tabIndex=0 when there are no tabbable elements within it.\n // Otherwise, tabbing from the focused tab should go directly to the first tabbable element\n // within the tabpanel.\n useLayoutEffect(() => {\n if (ref?.current) {\n const update = () => {\n // Detect if there are any tabbable elements and update the tabIndex accordingly.\n const walker = getFocusableTreeWalker(ref.current!, {tabbable: true});\n setTabIndex(walker.nextNode() ? undefined : 0);\n };\n\n update();\n\n // Update when new elements are inserted, or the tabIndex/disabled attribute updates.\n const observer = new MutationObserver(update);\n observer.observe(ref.current, {\n subtree: true,\n childList: true,\n attributes: true,\n attributeFilter: ['tabIndex', 'disabled'],\n });\n\n return () => {\n observer.disconnect();\n };\n }\n }, [ref]);\n\n return (\n
\n \n );\n}\n","import {createPortal, flushSync} from 'react-dom';\nimport React, {useImperativeHandle, useRef, useState} from 'react';\nimport {ConnectedDraggable, DragPreviewRenderer} from './use-draggable';\nimport {rootEl} from '@common/core/root-el';\n\nexport interface DragPreviewProps {\n children: (draggable: ConnectedDraggable) => JSX.Element;\n}\nexport const DragPreview = React.forwardRef<\n DragPreviewRenderer,\n DragPreviewProps\n>((props, ref) => {\n const render = props.children;\n const [children, setChildren] = useState(null);\n const domRef = useRef(null!);\n\n useImperativeHandle(\n ref,\n () =>\n (\n draggable: ConnectedDraggable,\n callback: (node: HTMLElement) => void,\n ) => {\n // This will be called during the onDragStart event by useDrag. We need to render the\n // preview synchronously before this event returns, so we can call event.dataTransfer.setDragImage.\n flushSync(() => {\n setChildren(render(draggable));\n });\n\n // Yield back to useDrag to set the drag image.\n callback(domRef.current);\n\n // Remove the preview from the DOM after a frame so the browser has time to paint.\n requestAnimationFrame(() => {\n setChildren(null);\n });\n },\n [render],\n );\n\n if (!children) {\n return null;\n }\n\n // portal preview, in case in needs to be used in
or another element that does not accept div as child\n return createPortal(\n
\n {children}\n
,\n rootEl,\n );\n});\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const MusicNoteIcon = createSvgIcon(\n \n, 'MusicNoteOutlined');\n","import {Track} from '@app/web-player/tracks/track';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\nimport clsx from 'clsx';\nimport {MusicNoteIcon} from '@common/icons/material/MusicNote';\n\ninterface TrackImageProps {\n track: Track;\n className?: string;\n size?: string;\n background?: string;\n}\nexport function TrackImage({\n track,\n className,\n size,\n background = 'bg-fg-base/4',\n}: TrackImageProps) {\n const {trans} = useTrans();\n const src = getTrackImageSrc(track);\n const imgClassName = clsx(\n className,\n size,\n background,\n 'object-cover',\n !src ? 'flex items-center justify-center' : 'block',\n );\n return src ? (\n \n ) : (\n \n \n \n );\n}\n\nexport function getTrackImageSrc(track: Track) {\n if (track.image) {\n return track.image;\n } else if (track.album?.image) {\n return track.album.image;\n }\n}\n","import {Fragment, memo} from 'react';\nimport {useTrans, UseTransReturn} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\n\ninterface ParsedMS {\n days: number;\n hours: number;\n minutes: number;\n seconds: number;\n}\n\ninterface FormattedTrackDurationProps {\n ms?: number;\n minutes?: number;\n seconds?: number;\n verbose?: boolean;\n addZeroToFirstUnit?: boolean;\n}\nexport const FormattedDuration = memo(\n ({\n minutes,\n seconds,\n ms,\n verbose = false,\n addZeroToFirstUnit = true,\n }: FormattedTrackDurationProps) => {\n const {trans} = useTrans();\n\n if (minutes) {\n ms = minutes * 60000;\n } else if (seconds) {\n ms = seconds * 1000;\n }\n if (!ms) {\n ms = 0;\n }\n\n const unsignedMs = ms < 0 ? -ms : ms;\n const parsedMS: ParsedMS = {\n days: Math.trunc(unsignedMs / 86400000),\n hours: Math.trunc(unsignedMs / 3600000) % 24,\n minutes: Math.trunc(unsignedMs / 60000) % 60,\n seconds: Math.trunc(unsignedMs / 1000) % 60,\n };\n\n let formattedValue: string;\n if (verbose) {\n formattedValue = formatVerbose(parsedMS, trans);\n } else {\n formattedValue = formatCompact(parsedMS, addZeroToFirstUnit);\n }\n\n return {formattedValue};\n }\n);\n\nfunction formatVerbose(t: ParsedMS, trans: UseTransReturn['trans']) {\n const output: string[] = [];\n\n if (t.days) {\n output.push(`${t.days}${trans(message('d'))}`);\n }\n if (t.hours) {\n output.push(`${t.hours}${trans(message('hr'))}`);\n }\n if (t.minutes) {\n output.push(`${t.minutes}${trans(message('min'))}`);\n }\n if (t.seconds && !t.hours) {\n output.push(`${t.seconds}${trans(message('sec'))}`);\n }\n\n return output.join(' ');\n}\n\nfunction formatCompact(t: ParsedMS, addZeroToFirstUnit = true) {\n const seconds = addZero(t.seconds);\n let output = '';\n if (t.days && !output) {\n output = `${t.days}:${addZero(t.hours)}:${addZero(t.minutes)}:${seconds}`;\n }\n if (t.hours && !output) {\n output = `${addZero(t.hours, addZeroToFirstUnit)}:${addZero(\n t.minutes\n )}:${seconds}`;\n }\n if (!output) {\n output = `${addZero(t.minutes, addZeroToFirstUnit)}:${seconds}`;\n }\n return output;\n}\n\nfunction addZero(v: number, addZero = true) {\n if (!addZero) return v;\n let value = `${v}`;\n if (value.length === 1) {\n value = '0' + value;\n }\n return value;\n}\n","import {Link, LinkProps} from 'react-router-dom';\nimport clsx from 'clsx';\nimport React, {useMemo} from 'react';\nimport {slugifyString} from '@common/utils/string/slugify-string';\nimport {Track} from '@app/web-player/tracks/track';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\ninterface TrackLinkProps extends Omit {\n track: Track;\n className?: string;\n}\nexport function TrackLink({track, className, ...linkProps}: TrackLinkProps) {\n const finalUri = useMemo(() => {\n return getTrackLink(track);\n }, [track]);\n\n return (\n \n {track.name}\n \n );\n}\n\nexport function getTrackLink(\n track: Track,\n {absolute}: {absolute?: boolean} = {}\n): string {\n let link = `/track/${track.id}/${slugifyString(track.name)}`;\n if (absolute) {\n link = `${getBootstrapData().settings.base_url}${link}`;\n }\n return link;\n}\n","export const GENRE_MODEL = 'genre';\n\nexport interface Genre {\n id: number;\n name: string;\n display_name: string;\n model_type: 'genre';\n image: string;\n}\n","export const WAVE_WIDTH = 1240;\nexport const WAVE_HEIGHT = 45;\nconst BAR_WIDTH = 3;\nconst BAR_GAP: number = 0.5;\n\nexport function generateWaveformData(file: File): Promise {\n const audioContext = new window.AudioContext();\n return new Promise((resolve, abort) => {\n const canvas = document.createElement('canvas');\n const context = canvas.getContext('2d');\n\n if (!context) {\n abort();\n return;\n }\n canvas.width = WAVE_WIDTH;\n canvas.height = WAVE_HEIGHT;\n\n // read file buffer\n const reader = new FileReader();\n reader.onload = e => {\n const buffer = e.target?.result;\n if (!buffer) {\n abort();\n } else {\n audioContext.decodeAudioData(\n buffer as ArrayBuffer,\n buffer => {\n const waveData = extractBuffer(buffer, context);\n resolve(waveData);\n },\n () => resolve(null)\n );\n }\n };\n reader.readAsArrayBuffer(file);\n });\n}\n\nfunction extractBuffer(buffer: AudioBuffer, context: CanvasRenderingContext2D) {\n const waveData: number[][] = [];\n const channelData = buffer.getChannelData(0);\n const sections = WAVE_WIDTH;\n const len = Math.floor(channelData.length / sections);\n const maxHeight = WAVE_HEIGHT;\n const vals = [];\n for (let i = 0; i < sections; i += BAR_WIDTH) {\n vals.push(bufferMeasure(i * len, len, channelData) * 10000);\n }\n\n for (let j = 0; j < sections; j += BAR_WIDTH) {\n const scale = maxHeight / Math.max(...vals);\n let val = bufferMeasure(j * len, len, channelData) * 10000;\n val *= scale;\n val += 1;\n waveData.push(getBarData(j, val));\n }\n\n // clear canvas for redrawing\n context.clearRect(0, 0, WAVE_WIDTH, WAVE_HEIGHT);\n return waveData;\n}\n\nfunction bufferMeasure(position: number, length: number, data: Float32Array) {\n let sum = 0.0;\n for (let i = position; i <= position + length - 1; i++) {\n sum += Math.pow(data[i], 2);\n }\n return Math.sqrt(sum / data.length);\n}\n\nfunction getBarData(i: number, h: number) {\n let w = BAR_WIDTH;\n if (BAR_GAP !== 0) {\n w *= Math.abs(1 - BAR_GAP);\n }\n const x = i + w / 2,\n y = WAVE_HEIGHT - h;\n\n return [x, y, w, h];\n}\n","import {Album} from '@app/web-player/albums/album';\n\nexport function assignAlbumToTracks(album: Album): Album {\n album.tracks = album.tracks?.map(track => {\n if (!track.album) {\n track.album = {...album, tracks: undefined};\n }\n return track;\n });\n return album;\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useParams} from 'react-router-dom';\nimport {Track} from '@app/web-player/tracks/track';\nimport {assignAlbumToTracks} from '@app/web-player/albums/assign-album-to-tracks';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\nexport interface getTrackResponse extends BackendResponse {\n track: Track;\n loader: Params['loader'];\n}\n\ninterface Params {\n loader: 'track' | 'trackPage' | 'editTrackPage';\n}\n\nexport function useTrack(params: Params) {\n const {trackId} = useParams();\n return useQuery({\n queryKey: ['tracks', +trackId!, params],\n queryFn: () => fetchTrack(trackId!, params),\n initialData: () => {\n const data = getBootstrapData().loaders?.[params.loader];\n if (data?.track?.id == trackId && data?.loader === params.loader) {\n return data;\n }\n return undefined;\n },\n });\n}\n\nfunction fetchTrack(trackId: number | string, params: Params) {\n return apiClient\n .get(`tracks/${trackId}`, {params})\n .then(response => {\n if (response.data.track.album) {\n response.data.track = {\n ...response.data.track,\n album: assignAlbumToTracks(response.data.track.album),\n };\n }\n return response.data;\n });\n}\n","import {useMemo} from 'react';\nimport {useAuth} from '@common/auth/use-auth';\nimport {Track} from '@app/web-player/tracks/track';\n\nexport function useTrackPermissions(tracks: (Track | undefined)[]) {\n const {user, hasPermission} = useAuth();\n\n return useMemo(() => {\n const permissions = {\n canEdit: true,\n canDelete: true,\n managesTrack: true,\n };\n tracks.every(track => {\n if (!track) {\n permissions.canEdit = false;\n permissions.canDelete = false;\n permissions.managesTrack = false;\n return;\n }\n\n const trackArtistIds = track.artists?.map(a => a.id);\n const managesTrack =\n track.owner_id === user?.id ||\n !!user?.artists?.find(a => trackArtistIds?.includes(a.id as number));\n\n if (!managesTrack) {\n permissions.managesTrack = false;\n }\n\n if (\n !hasPermission('tracks.update') &&\n !hasPermission('music.update') &&\n !managesTrack\n ) {\n permissions.canEdit = false;\n }\n\n if (\n !hasPermission('tracks.delete') &&\n !hasPermission('music.delete') &&\n !managesTrack\n ) {\n permissions.canDelete = false;\n }\n });\n return permissions;\n }, [user, tracks, hasPermission]);\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const AlbumIcon = createSvgIcon(\n \n, 'AlbumOutlined');\n","import {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\nimport {Album} from '@app/web-player/albums/album';\nimport clsx from 'clsx';\nimport {AlbumIcon} from '@common/icons/material/Album';\n\ninterface AlbumImageProps {\n album: Album;\n className?: string;\n size?: string;\n}\nexport function AlbumImage({album, className, size}: AlbumImageProps) {\n const {trans} = useTrans();\n const src = album?.image;\n const imgClassName = clsx(\n className,\n size,\n 'object-cover bg-fg-base/4',\n !src ? 'flex items-center justify-center' : 'block',\n );\n\n return src ? (\n \n ) : (\n \n \n \n );\n}\n","import {Link} from 'react-router-dom';\nimport clsx from 'clsx';\nimport React, {useMemo} from 'react';\nimport {Album} from '@app/web-player/albums/album';\nimport {Artist} from '@app/web-player/artists/artist';\nimport {slugifyString} from '@common/utils/string/slugify-string';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\ninterface AlbumLinkProps {\n album: Album;\n artist?: Artist;\n className?: string;\n target?: string;\n}\nexport function AlbumLink({album, artist, className, target}: AlbumLinkProps) {\n if (!artist && album.artists) {\n artist = album.artists[0];\n }\n const uri = useMemo(() => {\n return getAlbumLink(album, {artist});\n }, [artist, album]);\n\n return (\n \n {album.name}\n \n );\n}\n\nexport function getAlbumLink(\n album: Album,\n options: {artist?: Artist; absolute?: boolean} = {}\n) {\n const artist = options.artist || album.artists?.[0];\n const artistName = slugifyString(artist?.name || 'Various Artists');\n const albumName = slugifyString(album.name);\n let link = `/album/${album.id}/${artistName}/${albumName}`;\n if (options.absolute) {\n link = `${getBootstrapData().settings.base_url}${link}`;\n }\n return link;\n}\n","import {Genre} from '../genres/genre';\nimport {Artist} from '../artists/artist';\nimport {Tag} from '@common/tags/tag';\nimport {Track} from '@app/web-player/tracks/track';\n\nexport const ALBUM_MODEL = 'album';\n\nexport interface Album {\n id: number;\n name: string;\n model_type: typeof ALBUM_MODEL;\n release_date?: string;\n spotify_id?: string;\n image?: string;\n artists?: Omit[];\n reposts_count?: number;\n likes_count?: number;\n plays?: number;\n views: number;\n description?: string;\n tracks?: Track[];\n tags?: Tag[];\n genres?: Genre[];\n created_at?: string;\n owner_id?: number;\n comments_count?: number;\n tracks_count?: number;\n updated_at: string;\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useParams} from 'react-router-dom';\nimport {Album} from '@app/web-player/albums/album';\nimport {assignAlbumToTracks} from '@app/web-player/albums/assign-album-to-tracks';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\nexport interface GetAlbumResponse extends BackendResponse {\n album: Album;\n loader: Params['loader'];\n}\n\ninterface Params {\n loader: 'albumPage' | 'editAlbumPage' | 'album' | 'albumEmbed';\n}\n\nexport function useAlbum(params: Params) {\n const {albumId} = useParams();\n return useQuery({\n queryKey: ['albums', +albumId!],\n queryFn: () => fetchAlbum(albumId!, params),\n initialData: () => {\n const data = getBootstrapData().loaders?.[params.loader];\n if (data?.album?.id == albumId && data?.loader === params.loader) {\n return data;\n }\n return undefined;\n },\n });\n}\n\nfunction fetchAlbum(albumId: number | string, params: Params) {\n return apiClient\n .get(`albums/${albumId}`, {\n params,\n })\n .then(response => {\n response.data.album = assignAlbumToTracks(response.data.album);\n return response.data;\n });\n}\n","import {Album} from '@app/web-player/albums/album';\nimport {useMemo} from 'react';\nimport {useAuth} from '@common/auth/use-auth';\n\nexport function useAlbumPermissions(album?: Album) {\n const {user, hasPermission} = useAuth();\n return useMemo(() => {\n const permissions = {\n canEdit: false,\n canDelete: false,\n managesAlbum: false,\n };\n if (user?.id && album) {\n const albumArtistIds = album.artists?.map(a => a.id);\n const managesAlbum =\n album.owner_id === user.id ||\n !!user.artists?.find(a => albumArtistIds?.includes(a.id as number));\n\n permissions.canEdit =\n hasPermission('albums.update') ||\n hasPermission('music.update') ||\n managesAlbum;\n\n permissions.canDelete =\n hasPermission('albums.delete') ||\n hasPermission('music.delete') ||\n managesAlbum;\n\n permissions.managesAlbum = managesAlbum;\n }\n return permissions;\n }, [user, album, hasPermission]);\n}\n","import {useAuth} from '@common/auth/use-auth';\nimport {UserArtist} from '@app/web-player/user-profile/user-artist';\n\nexport function usePrimaryArtistForCurrentUser(): UserArtist | undefined {\n const {user} = useAuth();\n return user?.artists?.find(a => a.role === 'artist');\n}\n","import {Artist} from '@app/web-player/artists/artist';\nimport {ArtistLink} from '@app/web-player/artists/artist-link';\nimport {Fragment, HTMLAttributeAnchorTarget} from 'react';\nimport {Trans} from '@common/i18n/trans';\nimport clsx from 'clsx';\n\ninterface ArtistLinksProps {\n artists?: Artist[];\n className?: string;\n linkClassName?: string;\n target?: HTMLAttributeAnchorTarget;\n onLinkClick?: () => void;\n}\nexport function ArtistLinks({\n artists,\n className,\n target,\n linkClassName,\n onLinkClick,\n}: ArtistLinksProps) {\n if (!artists?.length) {\n return (\n
\n );\n}\n","import {Avatar, AvatarProps} from '@common/ui/images/avatar';\nimport {User} from '@common/auth/user';\nimport {useContext} from 'react';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\n\ninterface UserAvatarProps extends Omit {\n user?: User;\n}\nexport function UserAvatar({user, ...props}: UserAvatarProps) {\n const {auth} = useContext(SiteConfigContext);\n return (\n \n );\n}\n","import dot from 'dot-object';\n\nconst MAX_SAFE_INTEGER = 9007199254740991;\n\nexport function sortArrayOfObjects(\n data: T[],\n orderBy: string,\n orderDir: 'asc' | 'desc' = 'desc'\n): T[] {\n return data.sort((a, b) => {\n let valueA = sortingDataAccessor(a, orderBy);\n let valueB = sortingDataAccessor(b, orderBy);\n\n // If there are data in the column that can be converted to a number,\n // it must be ensured that the rest of the data\n // is of the same type so as not to order incorrectly.\n const valueAType = typeof valueA;\n const valueBType = typeof valueB;\n\n if (valueAType !== valueBType) {\n if (valueAType === 'number') {\n valueA += '';\n }\n if (valueBType === 'number') {\n valueB += '';\n }\n }\n\n // If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if\n // one value exists while the other doesn't. In this case, existing value should come last.\n // This avoids inconsistent results when comparing values to undefined/null.\n // If neither value exists, return 0 (equal).\n let comparatorResult = 0;\n if (valueA != null && valueB != null) {\n // Check if one value is greater than the other; if equal, comparatorResult should remain 0.\n if (valueA > valueB) {\n comparatorResult = 1;\n } else if (valueA < valueB) {\n comparatorResult = -1;\n }\n } else if (valueA != null) {\n comparatorResult = 1;\n } else if (valueB != null) {\n comparatorResult = -1;\n }\n\n return comparatorResult * (orderDir === 'asc' ? 1 : -1);\n });\n}\n\n/**\n * Data accessor function that is used for accessing data properties for sorting through\n * the default sortData function.\n * This default function assumes that the sort header IDs (which defaults to the column name)\n * matches the data's properties (e.g. column Xyz represents data['Xyz']).\n * May be set to a custom function for different behavior.\n */\nfunction sortingDataAccessor(data: object, key: string): string {\n const value = dot.pick(key, data);\n\n if (isNumberValue(value)) {\n const numberValue = Number(value);\n\n // Numbers beyond `MAX_SAFE_INTEGER` can't be compared reliably, so we\n // leave them as strings. For more info: https://goo.gl/y5vbSg\n return numberValue < MAX_SAFE_INTEGER ? numberValue : value;\n }\n\n return value;\n}\n\nfunction isNumberValue(value: any): boolean {\n // parseFloat(value) handles most of the cases we're interested in (it treats null, empty string,\n // and other non-number values as NaN, where Number just uses 0) but it considers the string\n // '123hello' to be a valid number. Therefore, we also check if Number(value) is NaN.\n return !isNaN(parseFloat(value as any)) && !isNaN(Number(value));\n}\n","import {useMemo, useState} from 'react';\nimport {SortDescriptor} from '@common/ui/tables/types/sort-descriptor';\nimport {sortArrayOfObjects} from '@common/utils/array/sort-array-of-objects';\nimport {TableDataItem} from '@common/ui/tables/types/table-data-item';\nimport {TableProps} from '@common/ui/tables/table';\n\nexport function useSortableTableData(\n data?: T[]\n): {\n data: T[];\n sortDescriptor: TableProps['sortDescriptor'];\n onSortChange: TableProps['onSortChange'];\n} {\n const [sortDescriptor, onSortChange] = useState({});\n const sortedData: T[] = useMemo(() => {\n if (!data) {\n return [];\n } else if (sortDescriptor?.orderBy) {\n return sortArrayOfObjects(\n [...data],\n sortDescriptor.orderBy,\n sortDescriptor.orderDir\n );\n }\n return data;\n }, [sortDescriptor, data]);\n return {data: sortedData, sortDescriptor, onSortChange};\n}\n","import {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useMutation} from '@tanstack/react-query';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\nimport {useLocation} from 'react-router-dom';\nimport {useNavigate} from '@common/utils/hooks/use-navigate';\nimport {useAuth} from '@common/auth/use-auth';\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n albumId: number;\n}\n\nexport function useDeleteAlbum() {\n const {pathname} = useLocation();\n const navigate = useNavigate();\n const {getRedirectUri} = useAuth();\n\n return useMutation({\n mutationFn: (payload: Payload) => deleteAlbum(payload),\n onSuccess: (response, {albumId}) => {\n toast(message('Album deleted'));\n // navigate to homepage if we are on this album page currently\n if (pathname.startsWith(`/album/${albumId}`)) {\n navigate(getRedirectUri());\n }\n queryClient.invalidateQueries({queryKey: ['tracks']});\n queryClient.invalidateQueries({queryKey: ['albums']});\n queryClient.invalidateQueries({queryKey: ['artists']});\n },\n onError: r => showHttpErrorToast(r),\n });\n}\n\nfunction deleteAlbum({albumId}: Payload): Promise {\n return apiClient.delete(`albums/${albumId}`).then(r => r.data);\n}\n","import {useFieldArray} from 'react-hook-form';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {CloseIcon} from '@common/icons/material/Close';\nimport {Button} from '@common/ui/buttons/button';\nimport {AddIcon} from '@common/icons/material/Add';\nimport React from 'react';\nimport {UserLink} from '@app/web-player/user-profile/user-link';\n\nexport function ProfileLinksForm() {\n const {fields, append, remove} = useFieldArray<{links: UserLink[]}>({\n name: 'links',\n });\n return (\n
\n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useParams} from 'react-router-dom';\nimport {Artist} from '@app/web-player/artists/artist';\nimport {PaginationResponse} from '@common/http/backend-response/pagination-response';\nimport {Album} from '@app/web-player/albums/album';\nimport {assignAlbumToTracks} from '@app/web-player/albums/assign-album-to-tracks';\nimport {Track} from '@app/web-player/tracks/track';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\nexport const albumLayoutKey = 'artistPage-albumLayout';\n\nexport interface UseArtistResponse extends BackendResponse {\n artist: Artist;\n albums?: PaginationResponse;\n tracks?: PaginationResponse