Catalog results
-
Server-ranked matches with facets and relationship context.
+
Search results with relationship and ownership signals.
{hasHiddenResults ? (
Showing first {results.length} of {total} matches. Refine search
@@ -283,12 +313,11 @@ export function ServerCatalogTable({
| Title |
- Type |
- Context |
+ {showEntityType ? Type | : null}
+ {showContext ? Context | : null}
Roles |
Media |
Status |
- Matched fields |
Signals |
@@ -329,12 +358,23 @@ export function ServerCatalogTable({
Open
-
{displayEntityType(result.type)} |
-
- {result.summary ?? result.snippets[0] ?? 'No context'}
- |
+ {showEntityType ? (
+
{displayEntityType(result.type)} |
+ ) : null}
+ {showContext ? (
+
+ {result.summary ?? result.snippets[0] ?? 'No context'}
+ |
+ ) : null}
-
+
+ formatRoleFacet(role, dictionaries),
+ ),
+ )}
+ variant="credit"
+ />
|
@@ -342,9 +382,6 @@ export function ServerCatalogTable({
|
|
-
-
- |
Promise | void
+ onUploadReleaseCover?: (releaseId: string, file: File) => Promise | void
searchRefreshKey: number
}) {
const initialParams = useMemo(
@@ -55,6 +62,24 @@ export function ServerCatalogWorkspace({
'idle' | 'loading' | 'ready' | 'missing' | 'error'
>('idle')
+ useEffect(() => {
+ let isCurrent = true
+
+ queueMicrotask(() => {
+ if (!isCurrent) {
+ return
+ }
+
+ setQuery(initialParams.query)
+ setActiveView(initialParams.activeView)
+ setFilters(initialParams.filters)
+ })
+
+ return () => {
+ isCurrent = false
+ }
+ }, [initialParams])
+
useEffect(() => {
const nextUrl = buildCatalogUrl(query, activeView, filters)
const currentUrl = `${window.location.pathname}${window.location.search}`
@@ -182,6 +207,7 @@ export function ServerCatalogWorkspace({
setSelectedResultId(resultKey(result))}
/>
@@ -210,7 +237,10 @@ export function ServerCatalogWorkspace({
diff --git a/src/features/catalog/ServerEntityFilters.tsx b/src/features/catalog/ServerEntityFilters.tsx
new file mode 100644
index 0000000..bba802c
--- /dev/null
+++ b/src/features/catalog/ServerEntityFilters.tsx
@@ -0,0 +1,106 @@
+import { FilterSelect } from './FilterSelect'
+import { formatRoleFacet, roleFacetValue } from './catalogDisplayLabels'
+import { uniqueValues } from './catalogGraph'
+import type { CatalogDictionaries, CatalogSearchResult } from './catalogApi'
+import type { ServerCatalogFilters } from './catalogWorkspaceShared'
+
+export function EntityFilterBar({
+ filters,
+ dictionaries,
+ results,
+ total,
+ visibleCount,
+ onClearFilters,
+ onFilterChange,
+}: {
+ filters: ServerCatalogFilters
+ dictionaries?: CatalogDictionaries
+ results: CatalogSearchResult[]
+ total: number
+ visibleCount: number
+ onClearFilters: () => void
+ onFilterChange: (filters: ServerCatalogFilters) => void
+}) {
+ function updateFilter(
+ key: Key,
+ value: ServerCatalogFilters[Key],
+ ) {
+ onFilterChange({ ...filters, [key]: value })
+ }
+
+ const mediaOptions = uniqueValues(
+ results.flatMap((result) => result.facets.media),
+ )
+ const statusOptions = uniqueValues(
+ results.flatMap((result) => result.facets.statuses),
+ )
+ const roleOptions = uniqueRoleOptions(
+ results.flatMap((result) => result.facets.roles),
+ dictionaries,
+ )
+ const tagOptions = uniqueValues(
+ results.flatMap((result) => result.facets.tags),
+ )
+
+ return (
+
+
+
+
+ {visibleCount} shown · {total} total
+
+
+
+
+ updateFilter('media', value)}
+ />
+ updateFilter('status', value)}
+ />
+ updateFilter('role', value)}
+ />
+ updateFilter('tag', value)}
+ />
+
+
+ )
+}
+
+function uniqueRoleOptions(
+ roles: string[],
+ dictionaries: CatalogDictionaries | undefined,
+) {
+ const options = new Map()
+
+ for (const role of uniqueValues(roles)) {
+ const value = roleFacetValue(role, dictionaries)
+ options.set(value, {
+ label: formatRoleFacet(role, dictionaries),
+ value,
+ })
+ }
+
+ return [...options.values()]
+}
diff --git a/src/features/catalog/ServerEntityWorkspace.tsx b/src/features/catalog/ServerEntityWorkspace.tsx
index a98e950..2b45fca 100644
--- a/src/features/catalog/ServerEntityWorkspace.tsx
+++ b/src/features/catalog/ServerEntityWorkspace.tsx
@@ -5,15 +5,15 @@ import {
loadCatalogGraphContext,
loadRelationDetail,
searchCatalog,
+ type CatalogDictionaries,
type CatalogGraphContext,
type CatalogSearchResult,
type SearchEntityType,
} from './catalogApi'
import { GraphDetailPanel } from './CatalogGraphDetailPanel'
import { ServerCatalogTable } from './ServerCatalogControls'
-import { FilterSelect } from './FilterSelect'
+import { EntityFilterBar } from './ServerEntityFilters'
import type { CatalogLinkData } from './catalogLinks'
-import { uniqueValues } from './catalogGraph'
import {
emptyServerFilters,
resultKey,
@@ -37,8 +37,11 @@ const emptyRelationCatalogData: CatalogLinkData = {
type ServerEntityWorkspaceProps = {
ariaLabel: string
+ dictionaries?: CatalogDictionaries
entityType?: SearchEntityType
locationSearch: string
+ onRemoveReleaseCover?: (releaseId: string) => Promise | void
+ onUploadReleaseCover?: (releaseId: string, file: File) => Promise | void
placeholder: string
queryParam: string
routePath: AppRoutePath
@@ -49,8 +52,11 @@ type ServerEntityWorkspaceProps = {
export function ServerEntityWorkspace({
ariaLabel,
+ dictionaries,
entityType,
locationSearch,
+ onRemoveReleaseCover,
+ onUploadReleaseCover,
placeholder,
queryParam,
routePath,
@@ -308,6 +314,7 @@ export function ServerEntityWorkspace({
/>
@@ -340,7 +350,10 @@ export function ServerEntityWorkspace({
) : (
)}
@@ -405,85 +418,6 @@ function RelationRouteDetailPanel({
)
}
-function EntityFilterBar({
- filters,
- results,
- total,
- visibleCount,
- onClearFilters,
- onFilterChange,
-}: {
- filters: ServerCatalogFilters
- results: CatalogSearchResult[]
- total: number
- visibleCount: number
- onClearFilters: () => void
- onFilterChange: (filters: ServerCatalogFilters) => void
-}) {
- function updateFilter(
- key: Key,
- value: ServerCatalogFilters[Key],
- ) {
- onFilterChange({ ...filters, [key]: value })
- }
- const mediaOptions = uniqueValues(
- results.flatMap((result) => result.facets.media),
- )
- const statusOptions = uniqueValues(
- results.flatMap((result) => result.facets.statuses),
- )
- const roleOptions = uniqueValues(
- results.flatMap((result) => result.facets.roles),
- )
- const tagOptions = uniqueValues(
- results.flatMap((result) => result.facets.tags),
- )
-
- return (
-
-
-
-
- {visibleCount} shown · {total} total
-
-
-
-
- updateFilter('media', value)}
- />
- updateFilter('status', value)}
- />
- updateFilter('role', value)}
- />
- updateFilter('tag', value)}
- />
-
-
- )
-}
-
function EntitySearchField({
label,
placeholder,
diff --git a/src/features/catalog/api/releaseClient.ts b/src/features/catalog/api/releaseClient.ts
index 8fb05c0..61aeea0 100644
--- a/src/features/catalog/api/releaseClient.ts
+++ b/src/features/catalog/api/releaseClient.ts
@@ -4,6 +4,7 @@ import {
CatalogApiError,
assertNoCollectionIds,
getAllPages,
+ getJson,
readJsonBody,
sendDelete,
sendJson,
@@ -82,6 +83,10 @@ export async function createRelease(
})
}
+export async function loadRelease(releaseId: string) {
+ return getJson(`/api/releases/${encodeURIComponent(releaseId)}`)
+}
+
export async function updateRelease(
release: ReleaseRecord,
tracks?: TrackRecord[],
diff --git a/src/features/catalog/catalogDisplayLabels.ts b/src/features/catalog/catalogDisplayLabels.ts
new file mode 100644
index 0000000..4c1fee7
--- /dev/null
+++ b/src/features/catalog/catalogDisplayLabels.ts
@@ -0,0 +1,133 @@
+import type { CatalogDictionaries } from './catalogApi'
+import { activeDictionaries } from './api/catalogDefaults'
+import {
+ creditRoleLabel,
+ relationTypeLabel,
+ toCreditRoleCode,
+} from './api/catalogValueMappers'
+
+const matchedFieldLabels: Record = {
+ 'artist credits': 'Artist credits',
+ 'credit.contributor': 'Credit artist',
+ 'credit.role': 'Credit role',
+ credits: 'Credits',
+ genre: 'Genre',
+ label: 'Label',
+ 'label releases': 'Label releases',
+ medium: 'Media',
+ name: 'Name',
+ ownershipStatus: 'Ownership status',
+ 'release.type': 'Release type',
+ tag: 'Tag',
+ title: 'Title',
+}
+
+const genericGraphRelations = new Set([
+ 'artist',
+ 'artists',
+ 'artist links',
+ 'credit',
+ 'credits',
+])
+
+export function formatMatchedField(field: string) {
+ return matchedFieldLabels[field] ?? humanizeIdentifier(field)
+}
+
+export function formatRoleFacet(
+ role: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ if (isCreditRoleValue(role, dictionaries)) {
+ return creditRoleLabel(role, dictionaries)
+ }
+
+ if (isArtistRelationValue(role, dictionaries)) {
+ return relationTypeLabel(role, 'artistRelationType', dictionaries)
+ }
+
+ if (isTrackRelationValue(role, dictionaries)) {
+ return relationTypeLabel(role, 'trackRelationType', dictionaries)
+ }
+
+ return humanizeIdentifier(role)
+}
+
+export function roleFacetValue(
+ role: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ return isCreditRoleValue(role, dictionaries)
+ ? toCreditRoleCode(role, dictionaries)
+ : role
+}
+
+export function formatGraphRelation(
+ relation: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ return formatRoleFacet(relation, dictionaries)
+}
+
+export function isGraphArtistRole(
+ value: string | null | undefined,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ const normalized = value?.trim().toLowerCase()
+
+ return Boolean(
+ normalized &&
+ !genericGraphRelations.has(normalized) &&
+ (isCreditRoleValue(value ?? '', dictionaries) ||
+ isArtistRelationValue(value ?? '', dictionaries) ||
+ isTrackRelationValue(value ?? '', dictionaries)),
+ )
+}
+
+function isCreditRoleValue(
+ value: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ const role = value.trim()
+ const code = toCreditRoleCode(role, dictionaries)
+
+ return (dictionaries ?? activeDictionaries).creditRole.some(
+ (entry) => entry.code === code || entry.name === role,
+ )
+}
+
+function isArtistRelationValue(
+ value: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ const relation = value.trim()
+
+ return (dictionaries ?? activeDictionaries).artistRelationType.some(
+ (entry) => entry.code === relation || entry.name === relation,
+ )
+}
+
+function isTrackRelationValue(
+ value: string,
+ dictionaries: CatalogDictionaries | undefined = activeDictionaries,
+) {
+ const relation = value.trim()
+
+ return (dictionaries ?? activeDictionaries).trackRelationType.some(
+ (entry) => entry.code === relation || entry.name === relation,
+ )
+}
+
+function humanizeIdentifier(value: string) {
+ const words = value
+ .replaceAll('.', ' ')
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
+ .replace(/[_-]+/g, ' ')
+ .trim()
+
+ if (!words) {
+ return value
+ }
+
+ return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase()
+}
diff --git a/src/features/releases/ReleaseDetail.tsx b/src/features/releases/ReleaseDetail.tsx
index b71c8ac..4d80460 100644
--- a/src/features/releases/ReleaseDetail.tsx
+++ b/src/features/releases/ReleaseDetail.tsx
@@ -293,12 +293,12 @@ export function ReleaseDetail({
}
type ReleaseCoverPanelProps = {
- release: ReleaseRecord
+ release: Pick
onRemoveCover?: (releaseId: string) => Promise | void
onUploadCover?: (releaseId: string, file: File) => Promise | void
}
-function ReleaseCoverPanel({
+export function ReleaseCoverPanel({
release,
onRemoveCover,
onUploadCover,
diff --git a/src/test/catalogActionFixtures.ts b/src/test/catalogActionFixtures.ts
new file mode 100644
index 0000000..380ed01
--- /dev/null
+++ b/src/test/catalogActionFixtures.ts
@@ -0,0 +1,116 @@
+import { jsonResponse } from './appTestHarness'
+
+export function searchResponseWithRelease() {
+ return jsonResponse({
+ items: [
+ {
+ id: 'release-stripped',
+ type: 'release',
+ title: 'Stripped',
+ subtitle: 'Mute',
+ summary: 'Imported single release.',
+ matchedFields: ['title', 'credit.role'],
+ snippets: ['Depeche Mode · Stripped'],
+ facets: {
+ roles: ['mainArtist'],
+ media: [],
+ statuses: [],
+ tags: [],
+ labelId: 'label-mute',
+ collectorSignals: [],
+ },
+ rank: 1,
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ total: 1,
+ })
+}
+
+export function graphResponseForReleaseWithDuplicateArtists() {
+ return jsonResponse({
+ entity: {
+ id: 'release-stripped',
+ type: 'release',
+ title: 'Stripped',
+ subtitle: 'Mute',
+ summary: 'Imported single release.',
+ },
+ sections: {
+ artists: [
+ {
+ id: 'artist-depeche-mode',
+ type: 'artist',
+ title: 'Depeche Mode',
+ subtitle: 'Group',
+ relation: 'mainArtist',
+ },
+ ],
+ credits: [
+ {
+ id: 'artist-depeche-mode',
+ type: 'artist',
+ title: 'Depeche Mode',
+ subtitle: 'mainArtist',
+ relation: 'credit',
+ },
+ ],
+ releases: [],
+ tracks: [
+ {
+ id: 'track-stripped',
+ type: 'track',
+ title: 'Stripped',
+ subtitle: '1',
+ relation: 'tracklist',
+ },
+ ],
+ ownedCopies: [],
+ labels: [
+ {
+ id: 'label-mute',
+ type: 'label',
+ title: 'Mute',
+ subtitle: 'BONG 010',
+ relation: 'label',
+ },
+ ],
+ playlists: [],
+ relations: [],
+ media: [],
+ },
+ collectorSignals: [],
+ })
+}
+
+export function releaseDetailWithoutCover() {
+ return jsonResponse({
+ id: 'release-stripped',
+ title: 'Stripped',
+ type: 'standalone',
+ year: 1986,
+ releaseDate: '1986-02-10',
+ genres: [],
+ tags: [],
+ coverImage: null,
+ isVariousArtists: false,
+ notOnLabel: false,
+ artistCredits: [
+ {
+ artistId: 'artist-depeche-mode',
+ artistName: 'Depeche Mode',
+ role: 'mainArtist',
+ },
+ ],
+ labels: [
+ {
+ labelId: 'label-mute',
+ name: 'Mute',
+ catalogNumber: 'BONG 010',
+ hasNoCatalogNumber: false,
+ },
+ ],
+ tracklist: [],
+ })
+}
|