diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/example-studio/package.json b/apps/example-studio/package.json index 7823305..b96af75 100644 --- a/apps/example-studio/package.json +++ b/apps/example-studio/package.json @@ -17,9 +17,10 @@ "dependencies": { "@sanity/color-input": "^6.0.2", "@sanity/dashboard": "^5.0.1", + "@sanity/sanity-plugin-async-list": "1.3.3", "@sanity/vision": "^5.1.0", "@seidhr/sanity-plugin-dashboard-widget-muna-docs": "*", - "@seidhr/sanity-plugin-muna-kulturnav-api": "*", + "@seidhr/sanity-plugin-muna-la-external-references": "*", "@seidhr/sanity-plugin-timespan-input": "*", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -42,4 +43,4 @@ "bracketSpacing": false, "singleQuote": true } -} \ No newline at end of file +} diff --git a/apps/example-studio/sanity.config.ts b/apps/example-studio/sanity.config.ts index 4907632..e1a28d2 100644 --- a/apps/example-studio/sanity.config.ts +++ b/apps/example-studio/sanity.config.ts @@ -3,10 +3,11 @@ import { structureTool } from 'sanity/structure' import { dashboardTool } from "@sanity/dashboard"; import { visionTool } from '@sanity/vision' import { colorInput } from '@sanity/color-input' -import { APISearchInput } from '@seidhr/sanity-plugin-muna-kulturnav-api' +import { createKulturnavLoader, externalReferenceInput } from '@seidhr/sanity-plugin-muna-la-external-references' import { munaDocsWidget } from '@seidhr/sanity-plugin-dashboard-widget-muna-docs' import { timespanInput } from '@seidhr/sanity-plugin-timespan-input' import { schemaTypes } from './schemas' +import { asyncList } from '@sanity/sanity-plugin-async-list'; export default defineConfig({ name: 'default', @@ -23,9 +24,26 @@ export default defineConfig({ munaDocsWidget({ layout: { width: 'auto' } }), ] }), - APISearchInput({}), + externalReferenceInput({ + schemaType: 'kulturnavReference', + loader: createKulturnavLoader({ + entityTypes: ['Concept'], + }), + }), timespanInput(), colorInput(), + asyncList({ + schemaType: 'disneyCharacter', // Name of type to be used in schema definitions + // Loader function to fetch data however you prefer + loader: async () => { + const response = await fetch('https://api.disneyapi.dev/character') + const result: { data: { name: string }[] } = await response.json() + + return result.data.map((item) => { + return { value: item.name, ...item } + }) + }, + }), ], schema: { diff --git a/apps/example-studio/schemas/Post.js b/apps/example-studio/schemas/Post.js index 25c2a7e..dab25e5 100644 --- a/apps/example-studio/schemas/Post.js +++ b/apps/example-studio/schemas/Post.js @@ -15,6 +15,63 @@ export default defineType({ title: 'Timespan', type: 'Timespan', }), + defineField({ + name: 'creator', + title: 'Creator', + type: 'array', + description: 'References to people from kulturnav.org', + of: [ + { + type: 'kulturnavReference', + // Field-level options override plugin defaults + // This field will search for Person entities instead of Concept + options: { + loaderOptions: { + entityTypes: ['Person'], + }, + autocompleteProps: { + placeholder: 'Search for a person...', + }, + }, + }, + ], + }), + defineField({ + name: 'subjects', + title: 'Subjects', + type: 'array', + description: 'Array of concept references from kulturnav.org', + of: [ + { + type: 'kulturnavReference', + // This field uses the plugin default (Concept) from sanity.config.ts + // but you could override it here if needed: + // options: { loaderOptions: { entityTypes: ['Concept'] } } + }, + ], + }), + defineField({ + name: 'related_posts', + title: 'Related Posts', + type: 'array', + description: 'References to other posts', + of: [ + { + type: 'reference', + to: [{ type: 'Post' }], + }, + ], + }), + defineField({ + name: 'disney', + type: 'array', + description: 'References to disney characters', + of: [ + { + type: 'disneyCharacter', + }, + ], + }), ], preview: { diff --git a/apps/example-studio/turbo.json b/apps/example-studio/turbo.json index 9823271..0a6b127 100644 --- a/apps/example-studio/turbo.json +++ b/apps/example-studio/turbo.json @@ -18,7 +18,7 @@ "with": [ "@seidhr/sanity-plugin-dashboard-widget-muna-docs#watch", "@seidhr/sanity-plugin-timespan-input#watch", - "@seidhr/sanity-plugin-muna-kulturnav-api#watch" + "@seidhr/sanity-plugin-muna-la-external-references#watch" ] } } diff --git a/package-lock.json b/package-lock.json index 14cb58f..dd26fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,9 +122,10 @@ "dependencies": { "@sanity/color-input": "^6.0.2", "@sanity/dashboard": "^5.0.1", + "@sanity/sanity-plugin-async-list": "1.3.3", "@sanity/vision": "^5.1.0", "@seidhr/sanity-plugin-dashboard-widget-muna-docs": "*", - "@seidhr/sanity-plugin-muna-kulturnav-api": "*", + "@seidhr/sanity-plugin-muna-la-external-references": "*", "@seidhr/sanity-plugin-timespan-input": "*", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -12838,6 +12839,282 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sanity/sanity-plugin-async-list": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@sanity/sanity-plugin-async-list/-/sanity-plugin-async-list-1.3.3.tgz", + "integrity": "sha512-b5vs05JAWua8+o/bRN869P5+y74TkHLiJ0IqCz5r7f8om3EdnwkKR6zxREvo2talrlMjzsH7FMxxRWgVzQj3XQ==", + "license": "MIT", + "dependencies": { + "@sanity/icons": "^3.5.0", + "@sanity/incompatible-plugin": "^1.0.4", + "@sanity/studio-secrets": "^3.0.0", + "@sanity/ui": "^2.9.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19", + "sanity": "^3 || ^4.0.0-0 || ^5.0.0-0", + "styled-components": "^6.1" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/@sanity/ui": { + "version": "2.16.22", + "resolved": "https://registry.npmjs.org/@sanity/ui/-/ui-2.16.22.tgz", + "integrity": "sha512-Zw217nqjLhROHrjFYPCwV61xEYHwUbBOohHO2DZ4LdQKqNfTKsqcjLVx9Heb4oDzB06L+1CamIrvPaexVijfeg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@juggle/resize-observer": "^3.4.0", + "@sanity/color": "^3.0.6", + "@sanity/icons": "^3.7.4", + "csstype": "^3.1.3", + "motion": "^12.23.24", + "react-compiler-runtime": "1.0.0", + "react-refractor": "^2.2.0", + "use-effect-event": "^2.0.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^18 || >=19.0.0-0", + "react-dom": "^18 || >=19.0.0-0", + "react-is": "^18 || >=19.0.0-0", + "styled-components": "^5.2 || ^6" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/react-refractor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-refractor/-/react-refractor-2.2.0.tgz", + "integrity": "sha512-UvWkBVqH/2b9nkkkt4UNFtU3aY1orQfd4plPjx5rxbefy6vGajNHU9n+tv8CbykFyVirr3vEBfN2JTxyK0d36g==", + "license": "MIT", + "dependencies": { + "refractor": "^3.6.0", + "unist-util-filter": "^2.0.2", + "unist-util-visit-parents": "^3.0.2" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/unist-util-filter": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-2.0.3.tgz", + "integrity": "sha512-8k6Jl/KLFqIRTHydJlHh6+uFgqYHq66pV75pZgr1JwfyFSjbWb12yfb0yitW/0TbHXjr9U4G9BQpOvMANB+ExA==", + "license": "MIT", + "dependencies": { + "unist-util-is": "^4.0.0" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/sanity-plugin-async-list/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/@sanity/schema": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@sanity/schema/-/schema-5.1.0.tgz", @@ -13023,6 +13300,279 @@ "@noble/hashes": "^2.0.0" } }, + "node_modules/@sanity/studio-secrets": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@sanity/studio-secrets/-/studio-secrets-3.0.3.tgz", + "integrity": "sha512-L9eNKVOelFhnFxM3WTyXfal63iiOjlHdXiwdJg9Iz9+5mjXOiHgtiVSWSlkqsexTDgTqAyONcQGL6FMJxSOF6w==", + "license": "MIT", + "dependencies": { + "@sanity/incompatible-plugin": "^1.0.5", + "@sanity/ui": "^2.10.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19", + "sanity": "^3.36.4 || ^4.0.0-0 || ^5.0.0", + "styled-components": "^6.1" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/@sanity/ui": { + "version": "2.16.22", + "resolved": "https://registry.npmjs.org/@sanity/ui/-/ui-2.16.22.tgz", + "integrity": "sha512-Zw217nqjLhROHrjFYPCwV61xEYHwUbBOohHO2DZ4LdQKqNfTKsqcjLVx9Heb4oDzB06L+1CamIrvPaexVijfeg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@juggle/resize-observer": "^3.4.0", + "@sanity/color": "^3.0.6", + "@sanity/icons": "^3.7.4", + "csstype": "^3.1.3", + "motion": "^12.23.24", + "react-compiler-runtime": "1.0.0", + "react-refractor": "^2.2.0", + "use-effect-event": "^2.0.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^18 || >=19.0.0-0", + "react-dom": "^18 || >=19.0.0-0", + "react-is": "^18 || >=19.0.0-0", + "styled-components": "^5.2 || ^6" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@sanity/studio-secrets/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/react-refractor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-refractor/-/react-refractor-2.2.0.tgz", + "integrity": "sha512-UvWkBVqH/2b9nkkkt4UNFtU3aY1orQfd4plPjx5rxbefy6vGajNHU9n+tv8CbykFyVirr3vEBfN2JTxyK0d36g==", + "license": "MIT", + "dependencies": { + "refractor": "^3.6.0", + "unist-util-filter": "^2.0.2", + "unist-util-visit-parents": "^3.0.2" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/unist-util-filter": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-2.0.3.tgz", + "integrity": "sha512-8k6Jl/KLFqIRTHydJlHh6+uFgqYHq66pV75pZgr1JwfyFSjbWb12yfb0yitW/0TbHXjr9U4G9BQpOvMANB+ExA==", + "license": "MIT", + "dependencies": { + "unist-util-is": "^4.0.0" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@sanity/studio-secrets/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/@sanity/telemetry": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@sanity/telemetry/-/telemetry-0.8.1.tgz", @@ -13206,8 +13756,8 @@ "resolved": "packages/sanity-plugin-dashboard-widget-muna-docs", "link": true }, - "node_modules/@seidhr/sanity-plugin-muna-kulturnav-api": { - "resolved": "packages/sanity-plugin-muna-kulturnav-api", + "node_modules/@seidhr/sanity-plugin-muna-la-external-references": { + "resolved": "packages/sanity-plugin-muna-la-external-references", "link": true }, "node_modules/@seidhr/sanity-plugin-muna-schemas": { @@ -35228,7 +35778,7 @@ "@svgr/cli": "^8.1.0", "@swc/core": "^1.3.84", "eslint-config-sanity": "^7.1.3", - "svgo": "^3.3.2", + "svgo": "^4.0.0", "svgo-autocrop": "^1.1.2", "tsup": "^7.2.0" }, @@ -35241,6 +35791,63 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "packages/react-muna-logo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "packages/react-muna-logo/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "packages/react-muna-logo/node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "packages/react-muna-logo/node_modules/svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.4.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, "packages/sanity-plugin-dashboard-widget-muna-docs": { "name": "@seidhr/sanity-plugin-dashboard-widget-muna-docs", "version": "3.0.0", @@ -35334,6 +35941,7 @@ "packages/sanity-plugin-muna-kulturnav-api": { "name": "@seidhr/sanity-plugin-muna-kulturnav-api", "version": "1.0.0", + "extraneous": true, "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^3.0.1", @@ -35371,7 +35979,49 @@ "sanity": "^5" } }, - "packages/sanity-plugin-muna-kulturnav-api/node_modules/eslint-plugin-prettier": { + "packages/sanity-plugin-muna-la-external-references": { + "name": "@seidhr/sanity-plugin-muna-la-external-references", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.1", + "@sanity/icons": "^3.1.11", + "@sanity/incompatible-plugin": "^1.0.5", + "@sanity/ui": "^3.1.11", + "prettier-linter-helpers": "^1.0.0" + }, + "devDependencies": { + "@sanity/pkg-utils": "^10.2.3", + "@sanity/plugin-kit": "^4.0.0", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.7.4", + "prettier-plugin-packagejson": "^2.5.20", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-is": "^19.2.3", + "rimraf": "^6.0.0", + "sanity": "^5.1.0", + "styled-components": "^6.1.19", + "typescript": "^5.9.3", + "typescript-eslint": "^8.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^19.2.3", + "sanity": "^5" + } + }, + "packages/sanity-plugin-muna-la-external-references/node_modules/eslint-plugin-prettier": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", @@ -35402,7 +36052,7 @@ } } }, - "packages/sanity-plugin-muna-kulturnav-api/node_modules/prettier": { + "packages/sanity-plugin-muna-la-external-references/node_modules/prettier": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/index.ts b/packages/sanity-plugin-muna-kulturnav-api/src/index.ts deleted file mode 100644 index 902f66e..0000000 --- a/packages/sanity-plugin-muna-kulturnav-api/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { definePlugin } from 'sanity' - -interface MyPluginConfig { - /* nothing here yet */ -} - -/** - * Usage in `sanity.config.ts` (or .js) - * - * ```ts - * import {defineConfig} from 'sanity' - * import {APISearchInput} from 'sanity-plugin-muna-kulturnav-api' -* - * export default defineConfig({ - * // ... - * plugins: [APISearchInput()], - * }) - * ``` - * - * @public - */ -export const APISearchInput = definePlugin((config = {}) => { - // eslint-disable-next-line no-console - console.log('hello from sanity-plugin-muna-kulturnav-api, Tarje') - return { - name: 'sanity-plugin-muna-kulturnav-api', - } -}) diff --git a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo b/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo deleted file mode 100644 index 1bfdbc2..0000000 --- a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/index.ts","./package.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/sanity-plugin-muna-kulturnav-api/.editorconfig b/packages/sanity-plugin-muna-la-external-references/.editorconfig similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/.editorconfig rename to packages/sanity-plugin-muna-la-external-references/.editorconfig diff --git a/packages/sanity-plugin-muna-kulturnav-api/.prettierignore b/packages/sanity-plugin-muna-la-external-references/.prettierignore similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/.prettierignore rename to packages/sanity-plugin-muna-la-external-references/.prettierignore diff --git a/packages/sanity-plugin-muna-kulturnav-api/.prettierrc b/packages/sanity-plugin-muna-la-external-references/.prettierrc similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/.prettierrc rename to packages/sanity-plugin-muna-la-external-references/.prettierrc diff --git a/packages/sanity-plugin-muna-kulturnav-api/LICENSE b/packages/sanity-plugin-muna-la-external-references/LICENSE similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/LICENSE rename to packages/sanity-plugin-muna-la-external-references/LICENSE diff --git a/packages/sanity-plugin-muna-kulturnav-api/README.md b/packages/sanity-plugin-muna-la-external-references/README.md similarity index 78% rename from packages/sanity-plugin-muna-kulturnav-api/README.md rename to packages/sanity-plugin-muna-la-external-references/README.md index f6b4b76..0a994f8 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/README.md +++ b/packages/sanity-plugin-muna-la-external-references/README.md @@ -1,11 +1,11 @@ -# sanity-plugin-muna-kulturnav-api +# sanity-plugin-muna-la-external-references > This is a **Sanity Studio v3** plugin. ## Installation ```sh -npm install sanity-plugin-muna-kulturnav-api +npm install sanity-plugin-muna-la-external-references ``` ## Usage @@ -14,7 +14,7 @@ Add it as a plugin in `sanity.config.ts` (or .js): ```ts import {defineConfig} from 'sanity' -import {myPlugin} from 'sanity-plugin-muna-kulturnav-api' +import {myPlugin} from 'sanity-plugin-muna-la-external-references' export default defineConfig({ //... diff --git a/packages/sanity-plugin-muna-kulturnav-api/eslint.config.js b/packages/sanity-plugin-muna-la-external-references/eslint.config.js similarity index 90% rename from packages/sanity-plugin-muna-kulturnav-api/eslint.config.js rename to packages/sanity-plugin-muna-la-external-references/eslint.config.js index 942637c..5fe3bc3 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/eslint.config.js +++ b/packages/sanity-plugin-muna-la-external-references/eslint.config.js @@ -1,7 +1,6 @@ import tseslint from 'typescript-eslint'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; -import prettier from 'eslint-plugin-prettier'; export default [ { @@ -17,7 +16,6 @@ export default [ plugins: { react, 'react-hooks': reactHooks, - prettier, }, settings: { react: { @@ -29,7 +27,6 @@ export default [ ...react.configs['jsx-runtime'].rules, ...reactHooks.configs.recommended.rules, '@typescript-eslint/no-unnecessary-type-constraint': 'off', - 'prettier/prettier': 'error', }, }, { diff --git a/packages/sanity-plugin-muna-kulturnav-api/package.config.ts b/packages/sanity-plugin-muna-la-external-references/package.config.ts similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/package.config.ts rename to packages/sanity-plugin-muna-la-external-references/package.config.ts diff --git a/packages/sanity-plugin-muna-kulturnav-api/package.json b/packages/sanity-plugin-muna-la-external-references/package.json similarity index 95% rename from packages/sanity-plugin-muna-kulturnav-api/package.json rename to packages/sanity-plugin-muna-la-external-references/package.json index 0415c27..0d68eda 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/package.json +++ b/packages/sanity-plugin-muna-la-external-references/package.json @@ -1,5 +1,5 @@ { - "name": "@seidhr/sanity-plugin-muna-kulturnav-api", + "name": "@seidhr/sanity-plugin-muna-la-external-references", "private": true, "version": "1.0.0", "description": "", @@ -48,6 +48,8 @@ "dependencies": { "@pnpm/npm-conf": "^3.0.1", "@sanity/incompatible-plugin": "^1.0.5", + "@sanity/icons": "^3.1.11", + "@sanity/ui": "^3.1.11", "prettier-linter-helpers": "^1.0.0" }, "devDependencies": { diff --git a/packages/sanity-plugin-muna-kulturnav-api/sanity.json b/packages/sanity-plugin-muna-la-external-references/sanity.json similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/sanity.json rename to packages/sanity-plugin-muna-la-external-references/sanity.json diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/DetailsPopup.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/DetailsPopup.tsx new file mode 100644 index 0000000..1d0b352 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/DetailsPopup.tsx @@ -0,0 +1,349 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + Portal, + Card, + Stack, + Text, + Button, + Heading, + Box, + Flex, + Spinner, + Badge, + Code, +} from '@sanity/ui' +import { CloseIcon, LaunchIcon } from '@sanity/icons' + +import type { KulturnavDetails, KulturnavReference } from '../types' + +interface DetailsPopupProps { + item: KulturnavReference + details: KulturnavDetails | null + isLoading: boolean + onClose: () => void +} + +/** + * Extract value from JSON-LD object or return the value itself + */ +function extractJsonLdValue(value: any): string { + if (value == null) return '' + if (typeof value === 'string') return value + if (typeof value === 'object' && value['@value']) return value['@value'] + if (typeof value === 'object' && Array.isArray(value)) { + // If it's an array, get the first value + return value.map(extractJsonLdValue).filter(Boolean).join(', ') || '' + } + return String(value) +} + +/** + * Parse kulturnav caption to extract structured information + */ +function parseCaption(caption: string): { + name: string + dates?: string + language?: string + profession?: string +} { + // Pattern: "Name (dates) [lang] Profession" or variations + const match = caption.match(/^(.+?)(?:\s*\(([^)]+)\))?(?:\s*\[([^\]]+)\])?(?:\s+(.+))?$/) + if (!match) { + return { name: caption } + } + + const [, name, dates, language, profession] = match + return { + name: name.trim(), + dates: dates?.trim(), + language: language?.trim(), + profession: profession?.trim(), + } +} + +/** + * Popup component to show detailed information about a kulturnav reference + * + * @public + */ +export function DetailsPopup({ + item, + details, + isLoading, + onClose, +}: DetailsPopupProps): React.JSX.Element { + const popupRef = useRef(null) + + const parsedCaption = useMemo(() => { + // Try to get caption from details first, then from item + let caption = details?.caption || item.label || '' + + // Handle JSON-LD format: entity.fullCaption, name, or title with @value + if (details && !caption) { + if (details['entity.fullCaption']) { + const fullCaption = details['entity.fullCaption'] + if (typeof fullCaption === 'object' && fullCaption['@value']) { + caption = fullCaption['@value'] + } else if (typeof fullCaption === 'string') { + caption = fullCaption + } + } else if (details.name) { + const name = details.name + if (typeof name === 'object' && name['@value']) { + caption = name['@value'] + } else if (typeof name === 'string') { + caption = name + } + } else if (details.title) { + const title = details.title + if (typeof title === 'object' && title['@value']) { + caption = title['@value'] + } else if (typeof title === 'string') { + caption = title + } + } + } + + return parseCaption(caption) + }, [details, item.label]) + + // Close on escape key + useEffect(() => { + function handleEscape(e: KeyboardEvent) { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [onClose]) + + // Close on click outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (popupRef.current && !popupRef.current.contains(e.target as Node)) { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [onClose]) + + // Extract UUID for external link + const uuid = useMemo(() => { + const match = item.id.match(/\/([a-f0-9-]+)$/i) + return match ? match[1] : null + }, [item.id]) + + const externalUrl = uuid ? `https://kulturnav.org/${uuid}` : null + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + + + {isLoading ? ( + + + Loading details... + + + + ) : details ? ( + + {parsedCaption.name} + + {parsedCaption.dates && ( + + {parsedCaption.dates} + + )} + {parsedCaption.language && ( + + {parsedCaption.language} + + )} + {parsedCaption.profession && ( + + {parsedCaption.profession} + + )} + + + ) : ( + {item.label || 'Untitled'} + )} + + + + + + {/* Content */} + {!isLoading && ( + + {details ? ( + + {/* Description */} + {(extractJsonLdValue(details.description) || + extractJsonLdValue(details.definition)) && ( + + {extractJsonLdValue(details.description || details.definition)} + + )} + + {/* Metadata Grid */} + + + + + Type + + + {extractJsonLdValue(details.entityType) || + (details['@type'] && Array.isArray(details['@type']) + ? details['@type'].join(', ') + : extractJsonLdValue(details['@type'])) || + item.type} + + + + {extractJsonLdValue(details.datasetCaption) && ( + + + Dataset + + {extractJsonLdValue(details.datasetCaption)} + + )} + + {details.uuid && ( + + + UUID + + + {details.uuid} + + + )} + + + + {/* External Link */} + {externalUrl && ( + + )} + + {/* Full Details (Expandable) */} + {Object.keys(details).length > 0 && ( + + + + + + + + {JSON.stringify(details, null, 2)} + + + + + )} + + ) : ( + + + No additional details available + + {externalUrl && ( + + )} + + )} + + )} + + + + ) +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceArrayInput.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceArrayInput.tsx new file mode 100644 index 0000000..9eec91a --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceArrayInput.tsx @@ -0,0 +1,123 @@ +import { useCallback } from 'react' +import React from 'react' +import { insert, setIfMissing, unset, ArraySchemaType } from 'sanity' +import { Stack, Card, Text, Flex, Box, Button } from '@sanity/ui' + +import type { ExternalReference, Loader, LoaderResult } from '../types' +import { SearchInput } from './SearchInput' + +interface ExternalReferenceArrayInputProps { + value?: ExternalReference[] + onChange: (patches: any[]) => void + schemaType: ArraySchemaType + renderDefault: (props: any) => React.ReactElement + renderItem?: (props: any) => React.ReactElement + renderPreview?: (props: any) => React.ReactElement + loader: Loader + autocompleteProps?: { + placeholder?: string + [key: string]: any + } +} + +// Type for array items with _key (required by Sanity arrays) +type ExternalReferenceWithKey = ExternalReference & { + _key: string +} + +/** + * Array input component for external references + * Handles multiple references in an array + * + * @public + */ +export function ExternalReferenceArrayInput( + props: ExternalReferenceArrayInputProps, +): React.JSX.Element { + const { value, onChange, loader, autocompleteProps, schemaType } = props + + // Get the item type name from the array's 'of' types + const itemTypeName = schemaType.of?.[0]?.name || 'ExternalReference' + + // Current value is an array of reference objects (or undefined) + // Sanity arrays can be undefined initially + const currentValues: ExternalReferenceWithKey[] = Array.isArray(value) + ? (value as ExternalReferenceWithKey[]) + : [] + + const handleSelect = useCallback( + (result: LoaderResult) => { + // Transform LoaderResult to ExternalReference (drop value field) + // Use the item type name for _type to match the schema definition + const reference: ExternalReferenceWithKey = { + _key: Date.now().toString(36) + Math.random().toString(36).substring(2, 9), // Short unique key for ordering + _type: itemTypeName, + id: result.id, + type: result.type, + label: result.label, + notation: result.notation, + complete: result.complete, + } + + // Check if this item is already in the array (by id) + const exists = currentValues.some((v) => v.id === reference.id) + if (exists) { + return // Don't add duplicates + } + + // Insert the new reference at the end of the array + const patches = [setIfMissing([]), insert([reference], 'after', [-1])] + onChange(patches) + }, + [onChange, currentValues, itemTypeName], + ) + + const handleRemove = useCallback( + (index: number) => { + onChange([unset([index])]) + }, + [onChange], + ) + + return ( + + + + {currentValues.map((item, index) => ( + + + + + + {item.label || item.id} + + {item.type && ( + + {item.type} + + )} + {item.id && ( + + {item.id} + + )} + + + handleRemove(index)} + /> + + + ))} + + + ) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceInput.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceInput.tsx new file mode 100644 index 0000000..d1ae128 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/ExternalReferenceInput.tsx @@ -0,0 +1,174 @@ +import { useCallback, useMemo } from 'react' +import React from 'react' +import { ObjectInputProps, set, unset } from 'sanity' +import { Stack, Card, Text, Flex, Box, Button } from '@sanity/ui' + +import type { + ExternalReference, + Loader, + LoaderResult, + ExternalReferenceFieldOptions, +} from '../types' +import { SearchInput } from './SearchInput' +import { getLoaderForSchemaType } from '../lib/loaderRegistry' +import { createKulturnavLoader } from '../lib/kulturnavClient' + +// Stable no-op loader for fallback +const noOpLoader: Loader = async () => [] + +interface ExternalReferenceInputProps extends ObjectInputProps { + loader?: Loader + autocompleteProps?: { + placeholder?: string + [key: string]: any + } +} + +/** + * Main input component for external references + * Works for single object fields. For array fields, Sanity wraps each item. + * + * @public + */ +export function ExternalReferenceInput( + props: ExternalReferenceInputProps, +): React.JSX.Element { + const { value, onChange, loader: propLoader, autocompleteProps: propAutocompleteProps, schemaType } = props + + // Get field-level options from schemaType.options + const fieldOptions = useMemo( + () => (schemaType.options as ExternalReferenceFieldOptions | undefined) || {}, + [schemaType.options] + ) + + // Determine which loader to use (priority: prop > field options > registry default) + const loader = useMemo(() => { + if (propLoader) { + return propLoader + } + if (fieldOptions.loader) { + return fieldOptions.loader + } + // If fieldOptions has loaderOptions, create a kulturnav loader with those options + if (fieldOptions.loaderOptions) { + return createKulturnavLoader(fieldOptions.loaderOptions) + } + // Fall back to registry default + const registryEntry = getLoaderForSchemaType(schemaType.name) + if (registryEntry) { + return registryEntry.loader + } + // No loader found - this shouldn't happen if plugin is configured correctly + // Return a no-op loader that returns empty results to prevent crashes + console.warn( + `No loader found for schema type "${schemaType.name}". Please configure the plugin or provide field-level options.` + ) + return noOpLoader + }, [propLoader, fieldOptions, schemaType.name]) + + // Merge autocomplete props (priority: prop > field options > registry default) + const autocompleteProps = useMemo(() => { + const registryEntry = getLoaderForSchemaType(schemaType.name) + return { + ...registryEntry?.autocompleteProps, + ...fieldOptions.autocompleteProps, + ...propAutocompleteProps, + } + }, [propAutocompleteProps, fieldOptions.autocompleteProps, schemaType.name]) + + // Current value is a single reference object (or null) + const currentValue: ExternalReference | null = value as ExternalReference | null + const hasData = !!(currentValue && currentValue.id) + + // Check if we're in an array context by examining the path + // Array items have numeric indices or object keys in their path + const pathArray = Array.isArray(props.path) ? props.path : [] + const isInArray = pathArray.some( + (segment) => + typeof segment === 'number' || + (typeof segment === 'object' && segment !== null && '_key' in segment) + ) + + // Check if current value has _key (for array items) + const currentValueWithKey = currentValue as ExternalReference & { _key?: string } + const hasKey = !!(currentValueWithKey && '_key' in currentValueWithKey && currentValueWithKey._key) + + const handleSelect = useCallback( + (result: LoaderResult) => { + // Transform LoaderResult to ExternalReference (drop value field) + // Use the schema type name for _type to match the schema definition + const reference: any = { + _type: schemaType.name, + id: result.id, + type: result.type, + label: result.label, + notation: result.notation, + complete: result.complete, + } + + // If we're in an array context and the current value doesn't have a _key, + // add one when creating a new reference + if (isInArray && !hasKey) { + reference._key = + Date.now().toString(36) + Math.random().toString(36).substring(2, 9) + } + + onChange([set(reference)]) + }, + [onChange, schemaType.name, isInArray, hasKey], + ) + + const handleRemove = useCallback(() => { + onChange([unset()]) + }, [onChange]) + + // If there's no meaningful data (eg. new item in the modal), show only the search input. + if (!hasData) { + return ( + + ) + } + + // When there's a value, show a display + allow changing/removing via search/remove + return ( + + + + + + + {currentValue.label || currentValue.id} + + {currentValue.type && ( + + Type: {currentValue.type} + + )} + {currentValue.id && ( + + {currentValue.id} + + )} + + + + + + + + ) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavArrayInput.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavArrayInput.tsx new file mode 100644 index 0000000..4e61af0 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavArrayInput.tsx @@ -0,0 +1,102 @@ +import { useCallback, useMemo } from 'react' +import React from 'react' +import { insert, setIfMissing, unset, ArraySchemaType } from 'sanity' +import { Stack } from '@sanity/ui' + +import type { KulturnavReference, LoaderResult } from '../types' +import { createKulturnavLoader } from '../lib/kulturnavClient' +import { SearchInput } from './SearchInput' +import { SelectedBadges } from './SelectedBadges' + +interface KulturnavArrayInputProps { + value?: KulturnavReference[] + onChange: (patches: any[]) => void + schemaType: ArraySchemaType + // Field options from schema + options?: { + entityType?: string + dataset?: string + lang?: string + propertyType?: string + } +} + +// Type for array items with _key (required by Sanity arrays) +type KulturnavReferenceWithKey = KulturnavReference & { + _key: string +} + +/** + * Array input component for kulturnav references + * Handles multiple references in an array + * + * @public + */ +export function KulturnavArrayInput(props: KulturnavArrayInputProps): React.JSX.Element { + const { value, onChange, schemaType } = props + + // Get field options from the array field's options + const fieldOptions = schemaType.options as KulturnavArrayInputProps['options'] | undefined + const entityType = fieldOptions?.entityType || props.options?.entityType + const dataset = fieldOptions?.dataset || props.options?.dataset + + // Create loader with appropriate options + const loader = useMemo(() => { + return createKulturnavLoader({ + entityTypes: entityType ? [entityType] : undefined, + datasetUuids: dataset ? [dataset] : undefined, + }) + }, [entityType, dataset]) + + // Current value is an array of reference objects (or undefined) + // Sanity arrays can be undefined initially + const currentValues: KulturnavReferenceWithKey[] = Array.isArray(value) ? (value as KulturnavReferenceWithKey[]) : [] + + const handleSelect = useCallback( + (result: LoaderResult) => { + const reference: KulturnavReferenceWithKey = { + _key: Date.now().toString(36) + Math.random().toString(36).substring(2, 9), // Short unique key for ordering + _type: 'ExternalReference', + id: result.id, + type: result.type, + label: result.label, + notation: result.notation, + complete: result.complete, + } + + // Check if this item is already in the array (by id) + const exists = currentValues.some((v) => v.id === reference.id) + if (exists) { + return // Don't add duplicates + } + + // Insert the new reference at the end of the array + const patches = [setIfMissing([]), insert([reference], 'after', [-1])] + onChange(patches) + }, + [onChange, currentValues], + ) + + const handleRemove = useCallback( + (index: number) => { + onChange([unset([index])]) + }, + [onChange], + ) + + return ( + + + {currentValues.length > 0 && ( + + )} + + ) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavInput.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavInput.tsx new file mode 100644 index 0000000..828bf3a --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/KulturnavInput.tsx @@ -0,0 +1,80 @@ +import { useCallback, useMemo } from 'react' +import React from 'react' +import { ObjectInputProps, set, unset } from 'sanity' +import { Stack } from '@sanity/ui' + +import type { KulturnavReference, LoaderResult } from '../types' +import { createKulturnavLoader } from '../lib/kulturnavClient' +import { SearchInput } from './SearchInput' +import { SelectedBadges } from './SelectedBadges' + +interface KulturnavInputProps extends ObjectInputProps { + // Field options from schema + options?: { + entityType?: string + dataset?: string + lang?: string + propertyType?: string + } +} + +/** + * Main input component for kulturnav references + * Works for single object fields. For array fields, Sanity wraps each item. + * + * @public + */ +export function KulturnavInput(props: KulturnavInputProps): React.JSX.Element { + const { value, onChange, schemaType } = props + + // Get field options + const fieldOptions = schemaType.options as KulturnavInputProps['options'] | undefined + const entityType = fieldOptions?.entityType || props.options?.entityType + const dataset = fieldOptions?.dataset || props.options?.dataset + + // Create loader with appropriate options + const loader = useMemo(() => { + return createKulturnavLoader({ + entityTypes: entityType ? [entityType] : undefined, + datasetUuids: dataset ? [dataset] : undefined, + }) + }, [entityType, dataset]) + + // Current value is a single reference object (or null) + const currentValue: KulturnavReference | null = value as KulturnavReference | null + + const handleSelect = useCallback( + (result: LoaderResult) => { + const reference: KulturnavReference = { + _type: 'ExternalReference', + id: result.id, + type: result.type, + label: result.label, + notation: result.notation, + complete: result.complete, + } + onChange([set(reference)]) + }, + [onChange], + ) + + const handleRemove = useCallback(() => { + onChange([unset()]) + }, [onChange]) + + return ( + + + {currentValue && ( + handleRemove()} /> + )} + + ) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/PreviewAvatar.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/PreviewAvatar.tsx new file mode 100644 index 0000000..abec2a5 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/PreviewAvatar.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { PreviewBadge } from './PreviewBadge' +import type { PreviewComponentProps } from '../types' + +/** + * Avatar-style preview (default for persons) + * Now uses the same badge style as other previews + * + * @public + */ +export function PreviewAvatar(props: PreviewComponentProps): React.JSX.Element { + return +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/PreviewBadge.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/PreviewBadge.tsx new file mode 100644 index 0000000..69d6b2b --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/PreviewBadge.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Badge, Flex, Button } from '@sanity/ui' +import { CloseIcon } from '@sanity/icons' + +import type { PreviewComponentProps } from '../types' + +/** + * Badge-style preview (default for concepts) + * + * @public + */ +export function PreviewBadge({ + item, + onRemove, + onShowDetails, +}: PreviewComponentProps): React.JSX.Element { + return ( + { + e.stopPropagation() + onShowDetails() + }} + > + + {item.label || item.id} + { + e.preventDefault() + e.stopPropagation() + onRemove() + }} + padding={0} + tone="primary" + aria-label="Remove" + /> + + + ) +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/SearchInput.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/SearchInput.tsx new file mode 100644 index 0000000..e5a717f --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/SearchInput.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React from 'react' +import { Autocomplete, Stack, Text, Card } from '@sanity/ui' + +import type { Loader, LoaderResult } from '../types' + +interface SearchInputProps { + loader: Loader + onSelect: (result: LoaderResult) => void + autocompleteProps?: { + placeholder?: string + [key: string]: any + } + disabled?: boolean +} + +export function SearchInput({ + loader, + onSelect, + autocompleteProps = {}, + disabled = false, +}: SearchInputProps): React.JSX.Element { + const [searchQuery, setSearchQuery] = useState(null) + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const debounceTimerRef = useRef(null) + const resultsMapRef = useRef>(new Map()) + + // Generate a unique ID for this Autocomplete instance + const autocompleteId = useMemo(() => `search-input-${Math.random().toString(36).substring(2, 9)}`, []) + + // Debounced search when query changes + useEffect(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + if (!searchQuery || searchQuery.trim().length === 0) { + setResults([]) + setIsLoading(false) + resultsMapRef.current.clear() + return + } + + setIsLoading(true) + setError(null) + + debounceTimerRef.current = setTimeout(async () => { + try { + const searchResults = await loader(searchQuery) + setResults(searchResults) + // Build a map from option key to full LoaderResult for quick lookup + resultsMapRef.current.clear() + searchResults.forEach((result) => { + const key = result.value || result.label || result.id + if (key) { + resultsMapRef.current.set(key, result) + } + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to search') + setResults([]) + resultsMapRef.current.clear() + } finally { + setIsLoading(false) + } + }, 400) // 400ms debounce + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, [searchQuery, loader]) + + // Transform LoaderResult[] to {value: string}[] format for Autocomplete + const autocompleteOptions = useMemo(() => { + return results + .map((result) => { + const key = result.value || result.label || result.id + return key ? { value: key } : null + }) + .filter((opt): opt is { value: string } => opt !== null) + }, [results]) + + // Handle query change - this is called by Autocomplete when user types + const handleQueryChange = useCallback((query: string | null) => { + setSearchQuery(query) + }, []) + + // Handle option selection - map the value string back to full LoaderResult + const handleSelect = useCallback( + (value: string) => { + const selectedResult = resultsMapRef.current.get(value) + if (selectedResult) { + onSelect(selectedResult) + // Clear the search query and results + setSearchQuery(null) + setResults([]) + resultsMapRef.current.clear() + } + }, + [onSelect], + ) + + // Render each option in the dropdown + const renderOption = useCallback( + (option: { value: string }) => { + const result = resultsMapRef.current.get(option.value) + if (!result) { + return {option.value} + } + + return ( + + + {result.value || result.label || result.id} + + {result.label && result.label !== result.value && ( + + {result.label} + + )} + {result.type && ( + + Type: {result.type} + + )} + + ) + }, + [], + ) + + return ( + + + {!isLoading && + !error && + searchQuery && + searchQuery.trim().length > 0 && + results.length === 0 && ( + + No results for “{searchQuery.trim()}”. + + )} + {error && ( + + {error} + + )} + + ) +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/SearchResults.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/SearchResults.tsx new file mode 100644 index 0000000..5ccd1a5 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/SearchResults.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { Card, Stack, Text, Box } from '@sanity/ui' + +import type { KulturnavAutocompleteItem } from '../types' + +interface SearchResultsProps { + results: KulturnavAutocompleteItem[] + onSelect: (item: KulturnavAutocompleteItem) => void + isLoading?: boolean +} + +export function SearchResults({ + results, + onSelect, + isLoading, +}: SearchResultsProps): React.JSX.Element | null { + if (isLoading) { + return ( + + + Searching... + + + ) + } + + if (results.length === 0) { + return ( + + + No results found + + + ) + } + + return ( + + + {results.map((item) => ( + onSelect(item)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'var(--card-hovered-bg-color)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent' + }} + > + + + {item.caption} + + {item.datasetCaption && ( + + {item.datasetCaption} + + )} + + + ))} + + + ) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/components/SelectedBadges.tsx b/packages/sanity-plugin-muna-la-external-references/src/components/SelectedBadges.tsx new file mode 100644 index 0000000..4f72592 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/components/SelectedBadges.tsx @@ -0,0 +1,171 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { Stack } from '@sanity/ui' + +import type { KulturnavReference, PreviewComponentProps, PreviewStyle } from '../types' +import { getPluginConfig, getClientConfig } from '../lib/config' +import { fetchKulturnavDetails, extractUuidFromId } from '../lib/kulturnavClient' +import { PreviewAvatar } from './PreviewAvatar' +import { PreviewBadge } from './PreviewBadge' +import { DetailsPopup } from './DetailsPopup' + +interface SelectedBadgesProps { + items: KulturnavReference[] + onRemove: (index: number) => void +} + +/** + * Get preview style for an item + */ +function getPreviewStyle( + item: KulturnavReference, + defaultStyle?: PreviewStyle | ((item: KulturnavReference) => PreviewStyle), +): PreviewStyle { + if (typeof defaultStyle === 'function') { + return defaultStyle(item) + } + if (defaultStyle) { + return defaultStyle + } + // Default: avatar for Person, badge for others + return item.type === 'Person' ? 'avatar' : 'badge' +} + +/** + * Get preview component for an item + */ +function getPreviewComponent( + item: KulturnavReference, + style: PreviewStyle, + customComponent?: (props: PreviewComponentProps) => React.ReactElement, +): (props: PreviewComponentProps) => React.ReactElement { + if (customComponent) { + return customComponent + } + + switch (style) { + case 'avatar': + return PreviewAvatar + case 'badge': + return PreviewBadge + default: + return PreviewBadge + } +} + +export function SelectedBadges({ + items, + onRemove, +}: SelectedBadgesProps): React.JSX.Element | null { + const [selectedItem, setSelectedItem] = useState(null) + const [details, setDetails] = useState>({}) + const [loadingDetails, setLoadingDetails] = useState>({}) + + // Use refs to track state for memoization checks + const detailsRef = useRef>({}) + const loadingRef = useRef>({}) + + // Sync refs with state + React.useEffect(() => { + detailsRef.current = details + }, [details]) + + React.useEffect(() => { + loadingRef.current = loadingDetails + }, [loadingDetails]) + + const config = useMemo(() => getPluginConfig(), []) + + // Get preview style and component from config + const defaultPreviewStyle = useMemo( + () => config.defaultPreviewStyle, + [config.defaultPreviewStyle], + ) + const defaultPreviewComponent = useMemo( + () => config.previewComponent, + [config.previewComponent], + ) + + const handleShowDetails = useCallback( + async (item: KulturnavReference) => { + setSelectedItem(item) + + // Extract UUID from ID + const uuid = extractUuidFromId(item.id) + if (!uuid) { + console.warn('No UUID found for item:', item) + return + } + + const cacheKey = item.id + + // Check if already loaded or loading using refs + if (detailsRef.current[cacheKey] || loadingRef.current[cacheKey]) { + return // Already loaded or loading + } + + // Mark as loading + loadingRef.current[cacheKey] = true + setLoadingDetails((prev) => ({ ...prev, [cacheKey]: true })) + + try { + const clientConfig = getClientConfig() + // Fetch full entity details from core API using the UUID + const itemDetails = await fetchKulturnavDetails(uuid, { + apiBaseUrl: clientConfig.apiBaseUrl || 'https://kulturnav.org', + apiVersion: clientConfig.apiVersion || 'v1.5', + }) + detailsRef.current[cacheKey] = itemDetails + setDetails((prev) => ({ ...prev, [cacheKey]: itemDetails })) + } catch (error) { + console.error('Failed to fetch details:', error) + detailsRef.current[cacheKey] = null + setDetails((prev) => ({ ...prev, [cacheKey]: null })) + } finally { + loadingRef.current[cacheKey] = false + setLoadingDetails((prev) => ({ ...prev, [cacheKey]: false })) + } + }, + [], + ) + + const handleCloseDetails = useCallback(() => { + setSelectedItem(null) + }, []) + + if (items.length === 0) { + return null + } + + return ( + <> + + + {items.map((item, index) => { + const style = getPreviewStyle(item, defaultPreviewStyle) + const PreviewComponent = getPreviewComponent(item, style, defaultPreviewComponent) + + return ( + onRemove(index)} + onShowDetails={() => handleShowDetails(item)} + details={details[item.id] || null} + isLoadingDetails={loadingDetails[item.id] || false} + /> + ) + })} + + + + {selectedItem && ( + + )} + > + ) +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/index.ts b/packages/sanity-plugin-muna-la-external-references/src/index.ts new file mode 100644 index 0000000..fc89076 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/index.ts @@ -0,0 +1,140 @@ +import { definePlugin } from 'sanity' + +import { createExternalReferenceSchema } from './schemas/createExternalReferenceSchema' +import { ExternalReferenceInput } from './components/ExternalReferenceInput' +import { ExternalReferenceArrayInput } from './components/ExternalReferenceArrayInput' +import type { ExternalReferencePluginConfig } from './types' +import { registerLoader, getLoaderForSchemaType } from './lib/loaderRegistry' + +// Track registered schema types to avoid duplicates +const registeredSchemaTypes = new Set() + +/** + * External reference input plugin + * Each instance registers a schema type with its own loader + * + * @example + * ```ts + * import { externalReferenceInput, createKulturnavLoader } from '@seidhr/sanity-plugin-muna-la-external-references' + * + * export default defineConfig({ + * plugins: [ + * externalReferenceInput({ + * schemaType: 'kulturnavReference', + * loader: createKulturnavLoader({ + * entityTypes: ['Person', 'Group'], + * datasetUuids: ['some-uuid'], + * }), + * autocompleteProps: { + * placeholder: 'Search kulturnav...', + * }, + * }), + * ], + * }) + * ``` + * + * @public + */ +export const externalReferenceInput = definePlugin( + (config) => { + const { schemaType, loader, autocompleteProps } = config + + // Register loader and autocompleteProps for this schemaType + registerLoader(schemaType, loader, autocompleteProps) + + // Create schema type if not already registered + let schemaTypeDefinition + if (!registeredSchemaTypes.has(schemaType)) { + schemaTypeDefinition = createExternalReferenceSchema(schemaType) + registeredSchemaTypes.add(schemaType) + } + + return { + name: '@seidhr/sanity-plugin-muna-la-external-references', + schema: { + types: schemaTypeDefinition ? [schemaTypeDefinition] : [], + }, + form: { + components: { + // Let Sanity handle arrays and everything else. + // We only override the object input for our registered schema type(s), + // which will be used both for standalone fields and for items inside arrays + // (eg. in the \"Edit ...\" dialog). + input: (props) => { + const inputSchemaTypeName = props.schemaType.name + + // Check if this schemaType has a registered loader + const registryEntry = getLoaderForSchemaType(inputSchemaTypeName) + + // Check if this schema type has field-level external reference options + // (loader, loaderOptions, or autocompleteProps specific to external references) + const fieldOptions = props.schemaType.options as any + const hasExternalReferenceOptions = + fieldOptions && + typeof fieldOptions === 'object' && + (fieldOptions.loader || fieldOptions.loaderOptions || fieldOptions.autocompleteProps) + + // Only apply our custom input if: + // 1. The schema type is registered in the plugin, OR + // 2. The schema type has field-level external reference options + // Otherwise, use Sanity's default input + if (!registryEntry && !hasExternalReferenceOptions) { + return props.renderDefault(props) + } + + // Use our custom object input for this schema type + // The component will handle priority: prop > field options > registry default + return ExternalReferenceInput({ + ...props, + loader: registryEntry?.loader, + autocompleteProps: registryEntry?.autocompleteProps, + } as any) + }, + }, + }, + } + }, +) + +// Re-export for convenience +export { getLoaderForSchemaType } from './lib/loaderRegistry' + +/** + * @deprecated Use externalReferenceInput instead + * @public + */ +export const kulturnavInput = externalReferenceInput + +/** + * @deprecated Use externalReferenceInput instead + * @public + */ +export const APISearchInput = externalReferenceInput + +// Export components +export { ExternalReferenceInput } from './components/ExternalReferenceInput' +export { ExternalReferenceArrayInput } from './components/ExternalReferenceArrayInput' + +// Export loader factory +export { createKulturnavLoader } from './lib/kulturnavClient' + +// Export types +export type { + Loader, + LoaderResult, + ExternalReference, + ExternalReferencePluginConfig, + ExternalReferenceFieldOptions, + KulturnavLoaderOptions, +} from './types' + +// Deprecated exports for backward compatibility +export { PreviewAvatar } from './components/PreviewAvatar' +export { PreviewBadge } from './components/PreviewBadge' +export { DetailsPopup } from './components/DetailsPopup' +export type { + KulturnavPluginConfig, + PreviewStyle, + PreviewComponentProps, +} from './types' +export type { KulturnavReference } from './types' diff --git a/packages/sanity-plugin-muna-la-external-references/src/lib/config.ts b/packages/sanity-plugin-muna-la-external-references/src/lib/config.ts new file mode 100644 index 0000000..0006eef --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/lib/config.ts @@ -0,0 +1,43 @@ +import type { KulturnavPluginConfig } from '../types' +import type { KulturnavClientConfig } from './kulturnavClient' + +// Store plugin config at module level +let pluginConfig: KulturnavPluginConfig | null = null + +/** + * Set the plugin configuration + */ +export function setPluginConfig(config: KulturnavPluginConfig | void): void { + pluginConfig = config || null +} + +/** + * Get the plugin configuration + */ +export function getPluginConfig(): KulturnavPluginConfig { + if (!pluginConfig) { + // Return defaults if not set + return { + apiBaseUrl: 'https://kulturnav.org', + apiVersion: 'v1.5', + searchEndpoint: 'autocomplete', + } + } + return pluginConfig +} + +/** + * Get the client configuration (for API calls) + */ +export function getClientConfig(): KulturnavClientConfig { + const cfg = getPluginConfig() + return { + apiBaseUrl: cfg.apiBaseUrl || 'https://kulturnav.org', + apiVersion: cfg.apiVersion || 'v1.5', + defaultEntityType: cfg.defaultEntityType, + defaultDataset: cfg.defaultDataset, + defaultLanguage: cfg.defaultLanguage, + searchEndpoint: cfg.searchEndpoint || 'autocomplete', + customSearchUrl: cfg.customSearchUrl, + } +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/lib/kulturnavClient.ts b/packages/sanity-plugin-muna-la-external-references/src/lib/kulturnavClient.ts new file mode 100644 index 0000000..cd00bbf --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/lib/kulturnavClient.ts @@ -0,0 +1,365 @@ +import type { + KulturnavAutocompleteItem, + KulturnavDetails, + KulturnavReference, + SearchFilters, + Loader, + LoaderResult, + KulturnavLoaderOptions, +} from '../types' + +export interface KulturnavClientConfig { + apiBaseUrl: string + apiVersion: string + defaultEntityType?: string + defaultDataset?: string + defaultLanguage?: string + searchEndpoint?: 'autocomplete' | 'search' | 'core' | 'summary' + customSearchUrl?: (query: string, filters: SearchFilters) => string +} + +/** + * Transform kulturnav autocomplete response to Linked Art reference + */ +export function transformKulturnavResponse( + item: KulturnavAutocompleteItem, + entityType?: string, + apiBaseUrl: string = 'https://kulturnav.org', +): KulturnavReference { + return { + _type: 'ExternalReference', + id: `${apiBaseUrl}/${item.uuid}`, + type: entityType || 'Concept', // Default to Concept if not provided + label: item.caption, + } +} + +/** + * Build kulturnav search URL using summary endpoint with custom search string format + */ +export function buildAutocompleteUrl( + query: string, + filters: SearchFilters, + config: KulturnavClientConfig, +): string { + if (config.customSearchUrl) { + return config.customSearchUrl(query, filters) + } + + const baseUrl = config.apiBaseUrl.replace(/\/$/, '') + // Use summary endpoint which supports custom search string format + // Note: summary endpoint doesn't use version in path, format is: /api/summary/searchString + + // Use filters or defaults from config + const entityType = filters.entityType || config.defaultEntityType || 'Concept' + const propertyType = filters.propertyType || 'compoundName' + + // Build custom search string in format: field:value,field:value + // Example: actualEntityType:Concept,concept.isCollection:false,compoundName:fisk* + const searchParts: string[] = [] + + // Add entity type filter + searchParts.push(`actualEntityType:${entityType}`) + + // Add concept collection filter (exclude collections and guide terms) + if (entityType === 'Concept') { + searchParts.push('concept.isCollection:false') + } + + // Add search query with wildcard + // propertyType is typically 'compoundName' for name searches + const searchQuery = query.trim() + if (searchQuery) { + searchParts.push(`${propertyType}:${searchQuery}*`) + } + + // Build URL: /api/summary/searchString + const searchString = searchParts.join(',') + return `${baseUrl}/api/summary/${searchString}` +} + +/** + * Extract text value from language-keyed object or string + */ +function extractTextValue(value: any, lang: string): string { + if (!value) return '' + if (typeof value === 'string') return value + if (typeof value === 'object') { + return value[lang] || value.no || value.en || (Object.values(value)[0] as string) || '' + } + return '' +} + +/** + * Search kulturnav API using summary endpoint + */ +export async function searchKulturnav( + query: string, + filters: SearchFilters, + config: KulturnavClientConfig, +): Promise { + if (!query || query.trim().length === 0) { + return [] + } + + const url = buildAutocompleteUrl(query, filters, config) + + try { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + // Summary endpoint returns a direct array of entities + if (Array.isArray(data)) { + // Map summary response to KulturnavAutocompleteItem format + const lang = filters.lang || config.defaultLanguage || 'no' + + return data.map((item: any): KulturnavAutocompleteItem => { + // Extract caption from name or caption object (can be language-keyed object or string) + const caption = extractTextValue(item.caption, lang) || extractTextValue(item.name, lang) + + return { + uuid: item.uuid, + caption: caption || '', + datasetUuid: item.dataset?.uuid, + datasetCaption: extractTextValue(item.dataset?.displayValue, lang), + } + }) + } + + return [] + } catch (error) { + console.error('Kulturnav API error:', error) + throw error + } +} + +/** + * Memoized cache for API responses + */ +const detailsCache = new Map() +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +/** + * Fetch full details for a kulturnav entity + * Fetches from /api/{uuid} which returns JSON-LD format + * Results are memoized with a TTL + */ +export async function fetchKulturnavDetails( + uuid: string, + config: KulturnavClientConfig, +): Promise { + const cacheKey = `${config.apiBaseUrl}/${uuid}` + const cached = detailsCache.get(cacheKey) + + // Return cached data if still valid + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data + } + + const baseUrl = config.apiBaseUrl.replace(/\/$/, '') + // Use /{uuid}.json-ld endpoint (e.g., https://kulturnav.org/{uuid}.json-ld) + const url = `${baseUrl}/${uuid}.json-ld` + + try { + const response = await fetch(url, { + headers: { + Accept: 'application/ld+json, application/json', + }, + }) + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + // JSON-LD format has @graph array with entities + // The main entity is typically the first one in @graph + let entityData: KulturnavDetails | null = null + + if (data['@graph'] && Array.isArray(data['@graph']) && data['@graph'].length > 0) { + // Find the main entity (usually the first one, or one with @id matching the UUID) + const mainEntityId = `${baseUrl}/${uuid}` + entityData = + data['@graph'].find((item: any) => item['@id'] === mainEntityId) || data['@graph'][0] + } else if (data['@id']) { + // If it's already a single entity object + entityData = data + } + + if (!entityData) { + throw new Error('No entity data found in response') + } + + // Extract caption from entity.fullCaption or name or title + let caption = '' + if (entityData['entity.fullCaption']) { + // Can be { "@language": "sv", "@value": "..." } or string + if ( + typeof entityData['entity.fullCaption'] === 'object' && + entityData['entity.fullCaption']['@value'] + ) { + caption = entityData['entity.fullCaption']['@value'] + } else if (typeof entityData['entity.fullCaption'] === 'string') { + caption = entityData['entity.fullCaption'] + } + } else if (entityData.name) { + // Can be { "@language": "sv", "@value": "..." } or string + if (typeof entityData.name === 'object' && entityData.name['@value']) { + caption = entityData.name['@value'] + } else if (typeof entityData.name === 'string') { + caption = entityData.name + } + } else if (entityData.title) { + if (typeof entityData.title === 'object' && entityData.title['@value']) { + caption = entityData.title['@value'] + } else if (typeof entityData.title === 'string') { + caption = entityData.title + } + } + + // Extract UUID from @id if not present + if (!entityData.uuid) { + const idMatch = entityData['@id']?.match(/\/([a-f0-9-]+)$/i) + if (idMatch) { + entityData.uuid = idMatch[1] + } else { + entityData.uuid = uuid + } + } + + // Create a normalized details object + const normalizedDetails: KulturnavDetails = { + ...entityData, // Include all original data first + uuid: entityData.uuid || uuid, // Ensure UUID is set + caption: caption || entityData.caption || '', // Use extracted caption or fallback + } + + // Cache the result + detailsCache.set(cacheKey, { data: normalizedDetails, timestamp: Date.now() }) + + return normalizedDetails + } catch (error) { + console.error('Kulturnav details API error:', error) + throw error + } +} + +/** + * Extract UUID from kulturnav reference ID + */ +export function extractUuidFromId(id: string): string | null { + // ID format: https://kulturnav.org/{uuid} + const match = id.match(/\/([a-f0-9-]+)$/i) + return match ? match[1] : null +} + +/** + * Create a kulturnav loader function + * @public + */ +export function createKulturnavLoader( + options: KulturnavLoaderOptions = {}, +): Loader { + // Hardcoded API settings + const apiBaseUrl = 'https://kulturnav.org' + const apiVersion = 'v1.5' + const defaultLanguage = 'no' + const searchEndpoint: 'summary' = 'summary' + + const config: KulturnavClientConfig = { + apiBaseUrl, + apiVersion, + defaultLanguage, + searchEndpoint, + } + + return async (query: string): Promise => { + if (!query || query.trim().length === 0) { + return [] + } + + // Build filters from options + const filters: SearchFilters = { + propertyType: 'compoundName', + } + + // Apply entityTypes filter - if multiple, we'll need to handle OR logic + // For now, if multiple entityTypes, we'll search each separately and combine + const entityTypes = options.entityTypes || [] + const datasetUuids = options.datasetUuids || [] + + // Store results with their entity types + type ResultWithType = KulturnavAutocompleteItem & { entityType: string } + let allResults: ResultWithType[] = [] + + console.debug('[kulturnav] search', { query, entityTypes, datasetUuids, filters, config }) + + if (entityTypes.length > 0) { + // Search for each entity type separately and track which type each result came from + for (const entityType of entityTypes) { + const typeFilters: SearchFilters = { + ...filters, + entityType, + } + + try { + const results = await searchKulturnav(query, typeFilters, config) + // Tag each result with its entity type + const typedResults: ResultWithType[] = results.map((item) => ({ + ...item, + entityType, + })) + allResults = [...allResults, ...typedResults] + } catch (error) { + console.error(`Error searching for entity type ${entityType}:`, error) + } + } + } else { + // No entity type filter - search all (default to Concept) + try { + const results = await searchKulturnav(query, filters, config) + // Tag with default entity type + allResults = results.map((item) => ({ + ...item, + entityType: 'Concept', + })) + } catch (error) { + console.error('Error searching kulturnav:', error) + throw error + } + } + + // Filter by datasetUuids if provided + if (datasetUuids.length > 0) { + allResults = allResults.filter( + (item) => item.datasetUuid && datasetUuids.includes(item.datasetUuid), + ) + } + + // Remove duplicates (by UUID), keeping the first occurrence + const uniqueResults = Array.from( + new Map(allResults.map((item) => [item.uuid, item])).values(), + ) + + // Transform to LoaderResult format + return uniqueResults.map((item): LoaderResult => { + return { + value: item.caption, // For autocomplete display + id: `${apiBaseUrl}/${item.uuid}`, // Dereferenceable URI + type: item.entityType, // Entity type from the search + label: item.caption, // Human-readable label + // notation and complete are optional and not available from autocomplete + } + }) + } +} diff --git a/packages/sanity-plugin-muna-la-external-references/src/lib/loaderRegistry.ts b/packages/sanity-plugin-muna-la-external-references/src/lib/loaderRegistry.ts new file mode 100644 index 0000000..b568b07 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/lib/loaderRegistry.ts @@ -0,0 +1,37 @@ +import type { ExternalReferencePluginConfig, Loader } from '../types' + +// Global registry to store loaders and autocompleteProps per schemaType +// This is shared across all plugin instances +const loaderRegistry = new Map< + string, + { loader: Loader; autocompleteProps?: ExternalReferencePluginConfig['autocompleteProps'] } +>() + +/** + * Register a loader for a schema type + * @internal + */ +export function registerLoader( + schemaType: string, + loader: Loader, + autocompleteProps?: ExternalReferencePluginConfig['autocompleteProps'], +): void { + loaderRegistry.set(schemaType, { loader, autocompleteProps }) +} + +/** + * Get loader and autocompleteProps for a schema type + * @internal + */ +export function getLoaderForSchemaType(schemaTypeName: string) { + return loaderRegistry.get(schemaTypeName) +} + +/** + * Get all registered schema types + * @internal + */ +export function getRegisteredSchemaTypes(): string[] { + return Array.from(loaderRegistry.keys()) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/schemas/ExternalReference.ts b/packages/sanity-plugin-muna-la-external-references/src/schemas/ExternalReference.ts new file mode 100644 index 0000000..8f09b30 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/schemas/ExternalReference.ts @@ -0,0 +1,22 @@ +import { defineField, defineType, ObjectDefinition } from 'sanity' + +/** + * External reference schema type definition + * This is used as a template - actual schema types are created dynamically via createExternalReferenceSchema + * @public + */ +export interface ExternalReferenceDefinition extends Omit< + ObjectDefinition, + 'type' | 'fields' | 'options' +> { + type: string // Dynamic type name +} + +declare module '@sanity/types' { + export interface IntrinsicDefinitions { + // Dynamic schema types will be registered at runtime + // TypeScript will accept any string as a type name + [key: string]: ExternalReferenceDefinition + } +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/schemas/KulturnavReference.ts b/packages/sanity-plugin-muna-la-external-references/src/schemas/KulturnavReference.ts new file mode 100644 index 0000000..e8af8ca --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/schemas/KulturnavReference.ts @@ -0,0 +1,87 @@ +import { defineField, defineType, ObjectDefinition } from 'sanity' + +import { KulturnavInput } from '../components/KulturnavInput' +import type { KulturnavFieldOptions } from '../types' + +const kulturnavReferenceTypeName = 'kulturnavReference' + +/** + * @public + */ +export interface KulturnavReferenceDefinition extends Omit< + ObjectDefinition, + 'type' | 'fields' | 'options' +> { + type: typeof kulturnavReferenceTypeName + options?: KulturnavFieldOptions +} + +declare module '@sanity/types' { + export interface IntrinsicDefinitions { + kulturnavReference: KulturnavReferenceDefinition + } +} + +/** + * Schema type for kulturnav references following Linked Art reference model + * @see https://linked.art/api/1.0/shared/reference/ + * + * @public + */ +export const kulturnavReference = defineType({ + name: kulturnavReferenceTypeName, + type: 'object', + title: 'Kulturnav Reference', + description: 'Reference to an entity from kulturnav.org or similar API', + fields: [ + defineField({ + name: 'id', + type: 'string', + title: 'ID', + description: 'Dereferenceable URI identifying the referenced resource', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'type', + type: 'string', + title: 'Type', + description: 'Entity type (e.g., Person, Concept, Place)', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'label', + type: 'string', + title: 'Label', + description: + 'Human-readable label for the referenced resource (maps to _label in Linked Art)', + }), + defineField({ + name: 'notation', + type: 'array', + title: 'Notation', + description: 'Additional identifiers (e.g., language tags)', + of: [{ type: 'string' }], + }), + defineField({ + name: 'complete', + type: 'boolean', + title: 'Complete', + description: + 'If true, stores full embedded data instead of reference (maps to _complete in Linked Art)', + initialValue: false, + }), + ], + preview: { + select: { + label: 'label', + type: 'type', + id: 'id', + }, + prepare({ label, type, id }: { label?: string; type?: string; id?: string }) { + return { + title: label || id || 'Untitled Reference', + subtitle: type || 'Unknown Type', + } + }, + }, +}) diff --git a/packages/sanity-plugin-muna-la-external-references/src/schemas/createExternalReferenceSchema.ts b/packages/sanity-plugin-muna-la-external-references/src/schemas/createExternalReferenceSchema.ts new file mode 100644 index 0000000..609fd05 --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/schemas/createExternalReferenceSchema.ts @@ -0,0 +1,71 @@ +import { defineField, defineType } from 'sanity' + +/** + * Create an external reference schema type with Linked Art structure + * @public + */ +export function createExternalReferenceSchema(schemaTypeName: string) { + return defineType({ + name: schemaTypeName, + type: 'object', + title: 'External Reference', + description: 'Reference to an external entity following Linked Art reference model', + options: { + // Prevent modal from opening when used in arrays + collapsible: false, + collapsed: false, + }, + fields: [ + defineField({ + name: 'id', + type: 'string', + title: 'ID', + description: 'Dereferenceable URI identifying the referenced resource', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'type', + type: 'string', + title: 'Type', + description: 'Entity type (e.g., Person, Concept, Place)', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'label', + type: 'string', + title: 'Label', + description: + 'Human-readable label for the referenced resource (maps to _label in Linked Art)', + }), + defineField({ + name: 'notation', + type: 'array', + title: 'Notation', + description: 'Additional identifiers (e.g., language tags)', + of: [{ type: 'string' }], + }), + defineField({ + name: 'complete', + type: 'boolean', + title: 'Complete', + description: + 'If true, stores full embedded data instead of reference (maps to _complete in Linked Art)', + initialValue: false, + }), + ], + preview: { + select: { + label: 'label', + type: 'type', + id: 'id', + }, + prepare({ label, type, id }: { label?: string; type?: string; id?: string }) { + return { + title: label || id || 'Untitled Reference', + subtitle: type || 'Unknown Type', + } + }, + }, + }) +} + diff --git a/packages/sanity-plugin-muna-la-external-references/src/types.ts b/packages/sanity-plugin-muna-la-external-references/src/types.ts new file mode 100644 index 0000000..fa0e81b --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/src/types.ts @@ -0,0 +1,188 @@ +import type React from 'react' + +/** + * Loader result type - what loaders return (includes value for autocomplete display) + * @public + */ +export type LoaderResult = { + value: string // For autocomplete display only - NOT stored in document + id: string // Required: dereferenceable URI + type: string // Required: entity type (e.g., "Person", "Concept", "Place") + label: string // Required: human-readable label + notation?: string[] // Optional: additional identifiers + complete?: boolean // Optional: full embedded data flag +} + +/** + * Loader function type + * @public + */ +export type Loader = (query: string, options?: any) => Promise + +/** + * Linked Art reference model structure (stored document) + * @see https://linked.art/api/1.0/shared/reference/ + * + * @public + */ +export interface ExternalReference { + _type: 'ExternalReference' + id: string // Required: dereferenceable URI + type: string // Required: entity type (e.g., "Person", "Concept", "Place") + label?: string // Recommended: human-readable label (maps to _label in Linked Art) + notation?: string[] // Optional: additional identifiers (e.g., language tags) + complete?: boolean // Optional: if true, stores full embedded data (maps to _complete in Linked Art) +} + +/** + * @deprecated Use ExternalReference instead + * @public + */ +export type KulturnavReference = ExternalReference + +/** + * Kulturnav API autocomplete response item + */ +export interface KulturnavAutocompleteItem { + uuid: string + caption: string + datasetUuid?: string + datasetCaption?: string +} + +/** + * Full details from kulturnav API + */ +export interface KulturnavDetails { + uuid: string + caption: string + description?: string + datasetUuid?: string + datasetCaption?: string + [key: string]: any // Allow additional properties +} + +/** + * Preview style options + * + * @public + */ +export type PreviewStyle = 'avatar' | 'badge' | 'custom' + +/** + * Preview component props + * + * @public + */ +export interface PreviewComponentProps { + item: ExternalReference + onRemove: () => void + onShowDetails: () => void + details?: KulturnavDetails | null + isLoadingDetails?: boolean +} + +/** + * Search filters for kulturnav API + */ +export interface SearchFilters { + entityType?: string + dataset?: string + lang?: string + propertyType?: string +} + +/** + * Plugin configuration options + * + * @public + */ +export interface ExternalReferencePluginConfig { + schemaType: string + loader: Loader + autocompleteProps?: { + placeholder?: string + // Other Sanity Autocomplete props can be added here + [key: string]: any + } +} + +/** + * Field-level options for external reference inputs + * These can be specified in the schema field definition to override plugin defaults + * + * @example + * ```ts + * defineField({ + * name: 'creator', + * type: 'kulturnavReference', + * options: { + * loaderOptions: { entityTypes: ['Person'] }, + * autocompleteProps: { placeholder: 'Search for a person...' } + * } + * }) + * ``` + * + * @public + */ +export interface ExternalReferenceFieldOptions { + /** + * Custom loader function to use for this field (overrides plugin default) + */ + loader?: Loader + /** + * Options to pass to createKulturnavLoader (only used if loader is not provided) + * This allows field-level customization of entityTypes, datasetUuids, etc. + */ + loaderOptions?: KulturnavLoaderOptions + /** + * Autocomplete props specific to this field + */ + autocompleteProps?: { + placeholder?: string + [key: string]: any + } +} + +/** + * Options for creating a kulturnav loader + * + * @public + */ +export interface KulturnavLoaderOptions { + entityTypes?: string[] // Array of entity types to filter by (e.g., ["Person", "Group", "Concept"]) + datasetUuids?: string[] // Array of dataset UUIDs to filter by +} + +/** + * @deprecated Use ExternalReferencePluginConfig instead + * @public + */ +export interface KulturnavPluginConfig { + apiBaseUrl?: string // Default: 'https://kulturnav.org' + apiVersion?: string // Default: 'v1.5' + defaultEntityType?: string + defaultDataset?: string + defaultLanguage?: string + storageMode?: 'reference' | 'embed' // Default: 'reference' + searchEndpoint?: 'autocomplete' | 'search' | 'core' | 'summary' // Default: 'summary' + transformResponse?: (data: KulturnavAutocompleteItem) => ExternalReference + // Generic API support + customSearchUrl?: (query: string, filters: SearchFilters) => string + customTransformResponse?: (data: any) => any + // Preview configuration + previewComponent?: (props: PreviewComponentProps) => React.ReactElement + defaultPreviewStyle?: PreviewStyle | ((item: ExternalReference) => PreviewStyle) +} + +/** + * @deprecated Field-level options removed - loader is at plugin level + */ +export interface KulturnavFieldOptions { + entityType?: string + dataset?: string + lang?: string + propertyType?: string + previewStyle?: PreviewStyle | ((item: ExternalReference) => PreviewStyle) + previewComponent?: (props: PreviewComponentProps) => React.ReactElement +} diff --git a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.dist.json b/packages/sanity-plugin-muna-la-external-references/tsconfig.dist.json similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/tsconfig.dist.json rename to packages/sanity-plugin-muna-la-external-references/tsconfig.dist.json diff --git a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.json b/packages/sanity-plugin-muna-la-external-references/tsconfig.json similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/tsconfig.json rename to packages/sanity-plugin-muna-la-external-references/tsconfig.json diff --git a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.settings.json b/packages/sanity-plugin-muna-la-external-references/tsconfig.settings.json similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/tsconfig.settings.json rename to packages/sanity-plugin-muna-la-external-references/tsconfig.settings.json diff --git a/packages/sanity-plugin-muna-la-external-references/tsconfig.tsbuildinfo b/packages/sanity-plugin-muna-la-external-references/tsconfig.tsbuildinfo new file mode 100644 index 0000000..7139f3b --- /dev/null +++ b/packages/sanity-plugin-muna-la-external-references/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/index.ts","./src/types.ts","./src/components/detailspopup.tsx","./src/components/externalreferencearrayinput.tsx","./src/components/externalreferenceinput.tsx","./src/components/kulturnavarrayinput.tsx","./src/components/kulturnavinput.tsx","./src/components/previewavatar.tsx","./src/components/previewbadge.tsx","./src/components/searchinput.tsx","./src/components/searchresults.tsx","./src/components/selectedbadges.tsx","./src/lib/config.ts","./src/lib/kulturnavclient.ts","./src/schemas/externalreference.ts","./src/schemas/kulturnavreference.ts","./src/schemas/createexternalreferenceschema.ts","./package.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/sanity-plugin-muna-kulturnav-api/v2-incompatible.js b/packages/sanity-plugin-muna-la-external-references/v2-incompatible.js similarity index 100% rename from packages/sanity-plugin-muna-kulturnav-api/v2-incompatible.js rename to packages/sanity-plugin-muna-la-external-references/v2-incompatible.js
+ {details.uuid} +
+ {JSON.stringify(details, null, 2)} +