From 462ce9825acd49b507a6510a3ded0a1d3b5d718b Mon Sep 17 00:00:00 2001 From: Tarje Lavik Date: Sun, 28 Dec 2025 16:54:43 +0100 Subject: [PATCH 1/8] feat: implement kulturnav input components and schema - Replaced APISearchInput with kulturnavInput in sanity.config.ts. - Added new fields for creator and subjects in Post.js schema to reference entities from kulturnav.org. - Introduced components for handling kulturnav references, including KulturnavInput, KulturnavArrayInput, SearchInput, and SelectedBadges. - Implemented search functionality for kulturnav API with debounced input and result handling. - Created a new schema type for kulturnav references to align with Linked Art reference model. - Added configuration management for the kulturnav plugin to handle API settings and defaults. --- apps/example-studio/sanity.config.ts | 9 +- apps/example-studio/schemas/Post.js | 30 +++ .../src/components/KulturnavArrayInput.tsx | 95 ++++++++++ .../src/components/KulturnavInput.tsx | 81 ++++++++ .../src/components/SearchInput.tsx | 174 ++++++++++++++++++ .../src/components/SearchResults.tsx | 103 +++++++++++ .../src/components/SelectedBadges.tsx | 80 ++++++++ .../src/index.ts | 84 ++++++++- .../src/lib/config.ts | 37 ++++ .../src/lib/kulturnavClient.ts | 129 +++++++++++++ .../src/schemas/KulturnavReference.ts | 87 +++++++++ .../src/types.ts | 64 +++++++ .../tsconfig.tsbuildinfo | 2 +- 13 files changed, 962 insertions(+), 13 deletions(-) create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/SearchInput.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/SearchResults.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/schemas/KulturnavReference.ts create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/types.ts diff --git a/apps/example-studio/sanity.config.ts b/apps/example-studio/sanity.config.ts index 4907632..e92826f 100644 --- a/apps/example-studio/sanity.config.ts +++ b/apps/example-studio/sanity.config.ts @@ -3,7 +3,7 @@ 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 { kulturnavInput } from '@seidhr/sanity-plugin-muna-kulturnav-api' import { munaDocsWidget } from '@seidhr/sanity-plugin-dashboard-widget-muna-docs' import { timespanInput } from '@seidhr/sanity-plugin-timespan-input' import { schemaTypes } from './schemas' @@ -23,7 +23,12 @@ export default defineConfig({ munaDocsWidget({ layout: { width: 'auto' } }), ] }), - APISearchInput({}), + kulturnavInput({ + apiBaseUrl: 'https://kulturnav.org', + apiVersion: 'v1.5', + defaultLanguage: 'no', + storageMode: 'reference', + }), timespanInput(), colorInput(), ], diff --git a/apps/example-studio/schemas/Post.js b/apps/example-studio/schemas/Post.js index 25c2a7e..bf734ec 100644 --- a/apps/example-studio/schemas/Post.js +++ b/apps/example-studio/schemas/Post.js @@ -15,6 +15,36 @@ export default defineType({ title: 'Timespan', type: 'Timespan', }), + defineField({ + name: 'creator', + title: 'Creator', + type: 'array', + description: 'References to people from kulturnav.org', + of: [ + { + type: 'kulturnavReference', + }, + ], + options: { + entityType: 'Person', + lang: 'no', + }, + }), + defineField({ + name: 'subjects', + title: 'Subjects', + type: 'array', + description: 'Array of concept references from kulturnav.org', + of: [ + { + type: 'kulturnavReference', + }, + ], + options: { + entityType: 'Concept', + lang: 'no', + }, + }), ], preview: { diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx new file mode 100644 index 0000000..dccab64 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx @@ -0,0 +1,95 @@ +import { useCallback, useMemo } from 'react' +import React from 'react' +import { ArrayInputProps, insert, setIfMissing, unset } from 'sanity' + +import type { KulturnavAutocompleteItem, KulturnavReference } from '../types' +import { transformKulturnavResponse } from '../lib/kulturnavClient' +import { getPluginConfig } from '../lib/config' +import { SearchInput } from './SearchInput' +import { SelectedBadges } from './SelectedBadges' + +interface KulturnavArrayInputProps extends ArrayInputProps { + // Field options from schema + options?: { + entityType?: string + dataset?: string + lang?: string + propertyType?: 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 plugin config + const config = useMemo(() => getPluginConfig(), []) + + // Get field options from the array field's options + const fieldOptions = schemaType.options as KulturnavArrayInputProps['options'] | undefined + const filters = useMemo( + () => ({ + entityType: fieldOptions?.entityType || props.options?.entityType, + dataset: fieldOptions?.dataset || props.options?.dataset, + lang: fieldOptions?.lang || props.options?.lang, + propertyType: fieldOptions?.propertyType || props.options?.propertyType || 'compoundName', + }), + [fieldOptions, props.options], + ) + + // Current value is an array of reference objects (or undefined) + // Sanity arrays can be undefined initially + const currentValues: KulturnavReference[] = Array.isArray(value) ? value : [] + + const handleSelect = useCallback( + (item: KulturnavAutocompleteItem) => { + const reference: KulturnavReference = { + ...transformKulturnavResponse(item, filters.entityType, config.apiBaseUrl), + _type: 'kulturnavReference', + _key: `kulturnav-${item.uuid}-${Date.now()}`, // Generate unique key + } + + // 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, filters.entityType, config.apiBaseUrl, currentValues], + ) + + const handleRemove = useCallback( + (index: number) => { + onChange([unset([index])]) + }, + [onChange], + ) + + return ( +
+ { + // Search is handled internally + }} + onSelect={handleSelect} + filters={filters} + config={config} + placeholder="Search kulturnav..." + /> + {currentValues.length > 0 && ( + + )} +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx new file mode 100644 index 0000000..90480b1 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx @@ -0,0 +1,81 @@ +import { useCallback, useMemo } from 'react' +import React from 'react' +import { ObjectInputProps, set, unset } from 'sanity' + +import type { KulturnavAutocompleteItem, KulturnavReference } from '../types' +import { transformKulturnavResponse } from '../lib/kulturnavClient' +import { getPluginConfig } from '../lib/config' +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 plugin config + const config = useMemo(() => getPluginConfig(), []) + + // Get field options + const fieldOptions = schemaType.options as KulturnavInputProps['options'] | undefined + const filters = useMemo( + () => ({ + entityType: fieldOptions?.entityType || props.options?.entityType, + dataset: fieldOptions?.dataset || props.options?.dataset, + lang: fieldOptions?.lang || props.options?.lang, + propertyType: fieldOptions?.propertyType || props.options?.propertyType || 'compoundName', + }), + [fieldOptions, props.options], + ) + + // Current value is a single reference object (or null) + const currentValue: KulturnavReference | null = value as KulturnavReference | null + + const handleSelect = useCallback( + (item: KulturnavAutocompleteItem) => { + const reference: KulturnavReference = { + ...transformKulturnavResponse(item, filters.entityType, config.apiBaseUrl), + _type: 'kulturnavReference', + } + onChange([set(reference)]) + }, + [onChange, filters.entityType, config.apiBaseUrl], + ) + + const handleRemove = useCallback(() => { + onChange([unset()]) + }, [onChange]) + + return ( +
+ { + // Search is handled internally + }} + onSelect={handleSelect} + filters={filters} + config={config} + placeholder="Search kulturnav..." + /> + {currentValue && ( + handleRemove()} /> + )} +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchInput.tsx new file mode 100644 index 0000000..da01e70 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchInput.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import React from 'react' + +import type { KulturnavAutocompleteItem, SearchFilters } from '../types' +import { searchKulturnav } from '../lib/kulturnavClient' +import type { KulturnavClientConfig } from '../lib/kulturnavClient' +import { SearchResults } from './SearchResults' + +interface SearchInputProps { + value: string + onChange: (value: string) => void + onSelect: (item: KulturnavAutocompleteItem) => void + filters: SearchFilters + config: KulturnavClientConfig + placeholder?: string + disabled?: boolean +} + +export function SearchInput({ + value, + onChange, + onSelect, + filters, + config, + placeholder = 'Search kulturnav...', + disabled = false, +}: SearchInputProps): React.JSX.Element { + const [searchQuery, setSearchQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [showResults, setShowResults] = useState(false) + const [error, setError] = useState(null) + const debounceTimerRef = useRef(null) + const containerRef = useRef(null) + + // Debounced search + useEffect(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + if (searchQuery.trim().length === 0) { + setResults([]) + setShowResults(false) + setIsLoading(false) + return + } + + setIsLoading(true) + setError(null) + + debounceTimerRef.current = setTimeout(async () => { + try { + const searchResults = await searchKulturnav(searchQuery, filters, config) + setResults(searchResults) + setShowResults(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to search') + setResults([]) + setShowResults(false) + } finally { + setIsLoading(false) + } + }, 400) // 400ms debounce + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, [searchQuery, filters, config]) + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowResults(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const handleSelect = useCallback( + (item: KulturnavAutocompleteItem) => { + onSelect(item) + setSearchQuery('') + setShowResults(false) + setResults([]) + }, + [onSelect], + ) + + return ( +
+
+
+ { + setSearchQuery(e.currentTarget.value) + onChange(e.currentTarget.value) + }} + placeholder={placeholder} + disabled={disabled} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #ccc', + borderRadius: '4px', + fontSize: '14px', + fontFamily: 'inherit', + }} + /> +
+ {isLoading && ( +
+ +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {showResults && results.length > 0 && ( +
+ +
+ )} +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchResults.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchResults.tsx new file mode 100644 index 0000000..ec1ac1b --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/SearchResults.tsx @@ -0,0 +1,103 @@ +import React from 'react' + +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 = '#e5e5e5' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent' + }} + > +
+
+ {item.caption} +
+ {item.datasetCaption && ( +
+ {item.datasetCaption} +
+ )} +
+
+ ))} +
+
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx new file mode 100644 index 0000000..4a0ef7d --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx @@ -0,0 +1,80 @@ +import React from 'react' + +import type { KulturnavReference } from '../types' + +interface SelectedBadgesProps { + items: KulturnavReference[] + onRemove: (index: number) => void +} + +export function SelectedBadges({ + items, + onRemove, +}: SelectedBadgesProps): React.JSX.Element | null { + if (items.length === 0) { + return null + } + + return ( +
+ {items.map((item, index) => ( +
+ + {item.label || item.id} + + +
+ ))} +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/index.ts b/packages/sanity-plugin-muna-kulturnav-api/src/index.ts index 902f66e..74ae105 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/index.ts +++ b/packages/sanity-plugin-muna-kulturnav-api/src/index.ts @@ -1,28 +1,92 @@ import { definePlugin } from 'sanity' -interface MyPluginConfig { - /* nothing here yet */ -} +import { KulturnavInput } from './components/KulturnavInput' +import { KulturnavArrayInput } from './components/KulturnavArrayInput' +import { setPluginConfig } from './lib/config' +import { kulturnavReference } from './schemas/KulturnavReference' +import type { KulturnavPluginConfig } from './types' /** * Usage in `sanity.config.ts` (or .js) * * ```ts * import {defineConfig} from 'sanity' - * import {APISearchInput} from 'sanity-plugin-muna-kulturnav-api' -* + * import {kulturnavInput} from '@seidhr/sanity-plugin-muna-kulturnav-api' + * * export default defineConfig({ * // ... - * plugins: [APISearchInput()], + * plugins: [kulturnavInput({ + * apiBaseUrl: 'https://kulturnav.org', + * apiVersion: 'v1.5', + * defaultLanguage: 'no', + * })], + * schema: { + * types: [ + * defineType({ + * name: 'Artwork', + * type: 'document', + * fields: [ + * defineField({ + * name: 'creator', + * type: 'kulturnavReference', + * title: 'Creator', + * options: { + * entityType: 'Person', + * }, + * }), + * ], + * }), + * ], + * }, * }) * ``` * * @public */ -export const APISearchInput = definePlugin((config = {}) => { - // eslint-disable-next-line no-console - console.log('hello from sanity-plugin-muna-kulturnav-api, Tarje') +export const kulturnavInput = definePlugin((config = {}) => { + // Store config for components to access + setPluginConfig(config) + return { - name: 'sanity-plugin-muna-kulturnav-api', + name: '@seidhr/sanity-plugin-muna-kulturnav-api', + schema: { + types: [kulturnavReference], + }, + form: { + components: { + input: (props) => { + // Check if this is an array input + if (props.schemaType.jsonType === 'array') { + // Check if the array contains kulturnavReference + const ofTypes = props.schemaType.of || [] + const hasKulturnavReference = ofTypes.some( + (type: any) => type.name === 'kulturnavReference', + ) + if (hasKulturnavReference) { + return KulturnavArrayInput(props as any) + } + } + // For object inputs, use the default or custom object input + if (props.schemaType.name === 'kulturnavReference') { + return KulturnavInput(props as any) + } + // Use default input for other types + return props.renderDefault(props) + }, + }, + }, } }) + +/** + * Backward compatibility alias for kulturnavInput + * + * @public + */ +export const APISearchInput = kulturnavInput + +export { kulturnavReference } +export { KulturnavInput } +export { KulturnavArrayInput } +export type { KulturnavPluginConfig } from './types' +export type { KulturnavReference } from './types' diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts b/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts new file mode 100644 index 0000000..059a36c --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts @@ -0,0 +1,37 @@ +import type { KulturnavPluginConfig } from '../types' +import type { KulturnavClientConfig } from './kulturnavClient' + +// Store plugin config at module level +let pluginConfig: KulturnavClientConfig | null = null + +/** + * Set the plugin configuration + */ +export function setPluginConfig(config: KulturnavPluginConfig | void): void { + const cfg = config || {} + pluginConfig = { + 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, + } +} + +/** + * Get the plugin configuration + */ +export function getPluginConfig(): KulturnavClientConfig { + if (!pluginConfig) { + // Return defaults if not set + return { + apiBaseUrl: 'https://kulturnav.org', + apiVersion: 'v1.5', + searchEndpoint: 'autocomplete', + } + } + return pluginConfig +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts b/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts new file mode 100644 index 0000000..b04ead9 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts @@ -0,0 +1,129 @@ +import type { + KulturnavAutocompleteItem, + KulturnavReference, + SearchFilters, +} from '../types' + +export interface KulturnavClientConfig { + apiBaseUrl: string + apiVersion: string + defaultEntityType?: string + defaultDataset?: string + defaultLanguage?: string + searchEndpoint?: 'autocomplete' | 'search' | 'core' + 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 { + id: `${apiBaseUrl}/${item.uuid}`, + type: entityType || 'Concept', // Default to Concept if not provided + label: item.caption, + } +} + +/** + * Build kulturnav autocomplete URL + */ +export function buildAutocompleteUrl( + query: string, + filters: SearchFilters, + config: KulturnavClientConfig, +): string { + if (config.customSearchUrl) { + return config.customSearchUrl(query, filters) + } + + const baseUrl = config.apiBaseUrl.replace(/\/$/, '') + const version = config.apiVersion || 'v1.5' + const endpoint = config.searchEndpoint || 'autocomplete' + + const params = new URLSearchParams() + params.set('query', query) + + // Use filters or defaults from config + const entityType = filters.entityType || config.defaultEntityType + const dataset = filters.dataset || config.defaultDataset + const lang = filters.lang || config.defaultLanguage + const propertyType = filters.propertyType || 'compoundName' + + if (entityType) { + params.set('entityType', entityType) + } + if (propertyType) { + params.set('propertyType', propertyType) + } + if (dataset) { + params.set('dataset', dataset) + } + if (lang) { + params.set('lang', lang) + } + + return `${baseUrl}/api/${version}/${endpoint}?${params.toString()}` +} + +/** + * Search kulturnav API + */ +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() + + // Kulturnav autocomplete returns { fullMatch: [], startMatch: [] } + // We combine both arrays and remove duplicates + if (data.fullMatch || data.startMatch) { + const fullMatch = data.fullMatch || [] + const startMatch = data.startMatch || [] + const combined = [...fullMatch, ...startMatch] + + // Remove duplicates by uuid + const seen = new Set() + return combined.filter((item: KulturnavAutocompleteItem) => { + if (seen.has(item.uuid)) { + return false + } + seen.add(item.uuid) + return true + }) + } + + // If it's already an array, return it + if (Array.isArray(data)) { + return data + } + + return [] + } catch (error) { + console.error('Kulturnav API error:', error) + throw error + } +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/schemas/KulturnavReference.ts b/packages/sanity-plugin-muna-kulturnav-api/src/schemas/KulturnavReference.ts new file mode 100644 index 0000000..56cfc04 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/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 { + 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', + components: { + input: KulturnavInput, + }, + 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-kulturnav-api/src/types.ts b/packages/sanity-plugin-muna-kulturnav-api/src/types.ts new file mode 100644 index 0000000..f8208b3 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/types.ts @@ -0,0 +1,64 @@ +/** + * Linked Art reference model structure + * @see https://linked.art/api/1.0/shared/reference/ + * + * @public + */ +export interface KulturnavReference { + _type?: 'kulturnavReference' + 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) +} + +/** + * Kulturnav API autocomplete response item + */ +export interface KulturnavAutocompleteItem { + uuid: string + caption: string + datasetUuid?: string + datasetCaption?: string +} + +/** + * Search filters for kulturnav API + */ +export interface SearchFilters { + entityType?: string + dataset?: string + lang?: string + propertyType?: string +} + +/** + * Plugin configuration options + * + * @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' // Default: 'autocomplete' + transformResponse?: (data: KulturnavAutocompleteItem) => KulturnavReference + // Generic API support + customSearchUrl?: (query: string, filters: SearchFilters) => string + customTransformResponse?: (data: any) => any +} + +/** + * Field-level options (can be set per field) + */ +export interface KulturnavFieldOptions { + entityType?: string + dataset?: string + lang?: string + propertyType?: string +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo b/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo index 1bfdbc2..73ee095 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo +++ b/packages/sanity-plugin-muna-kulturnav-api/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/index.ts","./package.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/index.ts","./src/types.ts","./src/components/kulturnavinput.tsx","./src/components/searchinput.tsx","./src/components/searchresults.tsx","./src/components/selectedbadges.tsx","./src/lib/config.ts","./src/lib/kulturnavclient.ts","./src/schemas/kulturnavreference.ts","./package.config.ts"],"version":"5.9.3"} \ No newline at end of file From a865b4775dba272d6fef0c454d1a6848740bd8ce Mon Sep 17 00:00:00 2001 From: Tarje Lavik Date: Sun, 28 Dec 2025 17:58:07 +0100 Subject: [PATCH 2/8] feat: enhance kulturnav plugin with new preview components and type definitions - Added PreviewAvatar and PreviewBadge components for displaying kulturnav references in different styles. - Introduced DetailsPopup component for showing detailed information about selected references. - Updated types to include new PreviewStyle and PreviewComponentProps for better type safety. - Refactored configuration management to separate client and plugin configurations. - Enhanced SelectedBadges component to utilize new preview components and manage loading states for details. --- .../src/components/DetailsPopup.tsx | 559 ++++++++++++++++++ .../src/components/KulturnavArrayInput.tsx | 6 +- .../src/components/KulturnavInput.tsx | 6 +- .../src/components/PreviewAvatar.tsx | 101 ++++ .../src/components/PreviewBadge.tsx | 84 +++ .../src/components/SelectedBadges.tsx | 212 +++++-- .../src/index.ts | 5 +- .../src/lib/config.ts | 31 +- .../src/lib/kulturnavClient.ts | 120 ++++ .../src/types.ts | 41 +- 10 files changed, 1083 insertions(+), 82 deletions(-) create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx create mode 100644 packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx new file mode 100644 index 0000000..3fb9d34 --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx @@ -0,0 +1,559 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' + +import type { KulturnavDetails, KulturnavReference } from '../types' + +interface DetailsPopupProps { + item: KulturnavReference + details: KulturnavDetails | null + isLoading: boolean + onClose: () => void +} + +/** + * 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(), + } +} + +/** + * Detect if dark mode is active + */ +function useDarkMode(): boolean { + const [isDark, setIsDark] = useState(() => { + // Check if body has dark background or if prefers-color-scheme is dark + if (typeof window === 'undefined') return false + + // Check Sanity's theme by looking at body background + const bodyBg = window.getComputedStyle(document.body).backgroundColor + const rgb = bodyBg.match(/\d+/g) + if (rgb) { + const [r, g, b] = rgb.map(Number) + // If background is dark (average < 128), it's dark mode + const avg = (r + g + b) / 3 + if (avg < 128) return true + } + + // Fallback to prefers-color-scheme + return window.matchMedia('(prefers-color-scheme: dark)').matches + }) + + useEffect(() => { + // Watch for changes in body background + const observer = new MutationObserver(() => { + const bodyBg = window.getComputedStyle(document.body).backgroundColor + const rgb = bodyBg.match(/\d+/g) + if (rgb) { + const [r, g, b] = rgb.map(Number) + const avg = (r + g + b) / 3 + setIsDark(avg < 128) + } + }) + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['style', 'class'], + }) + + // Also watch for media query changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e: MediaQueryListEvent) => setIsDark(e.matches) + mediaQuery.addEventListener('change', handleChange) + + return () => { + observer.disconnect() + mediaQuery.removeEventListener('change', handleChange) + } + }, []) + + return isDark +} + +/** + * 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 isDark = useDarkMode() + + 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?.caption, details?.['entity.fullCaption'], details?.name, details?.title, 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 + + // Dark mode colors + const bgColor = isDark ? '#1a1a1a' : '#fff' + const textColor = isDark ? '#e5e5e5' : '#1a1a1a' + const textColorMuted = isDark ? '#999' : '#666' + const textColorLight = isDark ? '#666' : '#999' + const borderColor = isDark ? '#333' : '#e5e5e5' + const bgColorSecondary = isDark ? '#252525' : '#f9f9f9' + const bgColorTertiary = isDark ? '#2a2a2a' : '#f5f5f5' + const hoverBg = isDark ? '#333' : '#f0f0f0' + const overlayBg = isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.6)' + + return ( +
+
+ {/* 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 */} + {(details.description || details.definition) && ( +
+

+ {details.description || details.definition} +

+
+ )} + + {/* Metadata Grid */} +
+
+
+ Type +
+
+ {details.entityType || item.type} +
+
+ + {details.datasetCaption && ( +
+
+ Dataset +
+
+ {details.datasetCaption} +
+
+ )} + + {details.uuid && ( +
+
+ UUID +
+
+ {details.uuid} +
+
+ )} +
+ + {/* External Link */} + {externalUrl && ( + + )} + + {/* Full Details (Expandable) */} + {Object.keys(details).length > 0 && ( +
+ { + e.currentTarget.style.backgroundColor = hoverBg + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = bgColorTertiary + }} + > + + Show all details + +
+                      {JSON.stringify(details, null, 2)}
+                    
+
+ )} +
+ ) : ( +
+
+ No additional details available +
+
+ {externalUrl && ( + + View on Kulturnav → + + )} +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx index dccab64..e4e21d6 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavArrayInput.tsx @@ -4,7 +4,7 @@ import { ArrayInputProps, insert, setIfMissing, unset } from 'sanity' import type { KulturnavAutocompleteItem, KulturnavReference } from '../types' import { transformKulturnavResponse } from '../lib/kulturnavClient' -import { getPluginConfig } from '../lib/config' +import { getClientConfig } from '../lib/config' import { SearchInput } from './SearchInput' import { SelectedBadges } from './SelectedBadges' @@ -27,8 +27,8 @@ interface KulturnavArrayInputProps extends ArrayInputProps { export function KulturnavArrayInput(props: KulturnavArrayInputProps): React.JSX.Element { const { value, onChange, schemaType } = props - // Get plugin config - const config = useMemo(() => getPluginConfig(), []) + // Get client config for API calls + const config = useMemo(() => getClientConfig(), []) // Get field options from the array field's options const fieldOptions = schemaType.options as KulturnavArrayInputProps['options'] | undefined diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx index 90480b1..17882ab 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx @@ -4,7 +4,7 @@ import { ObjectInputProps, set, unset } from 'sanity' import type { KulturnavAutocompleteItem, KulturnavReference } from '../types' import { transformKulturnavResponse } from '../lib/kulturnavClient' -import { getPluginConfig } from '../lib/config' +import { getClientConfig } from '../lib/config' import { SearchInput } from './SearchInput' import { SelectedBadges } from './SelectedBadges' @@ -27,8 +27,8 @@ interface KulturnavInputProps extends ObjectInputProps { export function KulturnavInput(props: KulturnavInputProps): React.JSX.Element { const { value, onChange, schemaType } = props - // Get plugin config - const config = useMemo(() => getPluginConfig(), []) + // Get client config for API calls + const config = useMemo(() => getClientConfig(), []) // Get field options const fieldOptions = schemaType.options as KulturnavInputProps['options'] | undefined diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx new file mode 100644 index 0000000..9df43ee --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx @@ -0,0 +1,101 @@ +import React from 'react' + +import type { PreviewComponentProps } from '../types' + +/** + * Avatar-style preview (default for persons) + * + * @public + */ +export function PreviewAvatar({ + item, + onRemove, + onShowDetails, +}: PreviewComponentProps): React.JSX.Element { + const initials = item.label + ?.split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) || '?' + + return ( +
{ + e.stopPropagation() + onShowDetails() + }} + > +
+ {initials} +
+ + {item.label || item.id} + + +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx new file mode 100644 index 0000000..fe8961c --- /dev/null +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx @@ -0,0 +1,84 @@ +import React from 'react' + +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() + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = '#f0f7ff' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent' + }} + > + + {item.label || item.id} + + +
+ ) +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx index 4a0ef7d..9bbcc72 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/SelectedBadges.tsx @@ -1,80 +1,168 @@ -import React from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' -import type { KulturnavReference } from '../types' +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) + + // Use UUID directly from item, or extract from ID as fallback + const uuid = item.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) => ( -
- - {item.label || item.id} - - -
- ))} -
+ <> +
+ {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-kulturnav-api/src/index.ts b/packages/sanity-plugin-muna-kulturnav-api/src/index.ts index 74ae105..e0a522f 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/index.ts +++ b/packages/sanity-plugin-muna-kulturnav-api/src/index.ts @@ -88,5 +88,8 @@ export const APISearchInput = kulturnavInput export { kulturnavReference } export { KulturnavInput } export { KulturnavArrayInput } -export type { KulturnavPluginConfig } from './types' +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-kulturnav-api/src/lib/config.ts b/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts index 059a36c..cbcb37d 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts +++ b/packages/sanity-plugin-muna-kulturnav-api/src/lib/config.ts @@ -2,28 +2,19 @@ import type { KulturnavPluginConfig } from '../types' import type { KulturnavClientConfig } from './kulturnavClient' // Store plugin config at module level -let pluginConfig: KulturnavClientConfig | null = null +let pluginConfig: KulturnavPluginConfig | null = null /** * Set the plugin configuration */ export function setPluginConfig(config: KulturnavPluginConfig | void): void { - const cfg = config || {} - pluginConfig = { - 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, - } + pluginConfig = config || null } /** * Get the plugin configuration */ -export function getPluginConfig(): KulturnavClientConfig { +export function getPluginConfig(): KulturnavPluginConfig { if (!pluginConfig) { // Return defaults if not set return { @@ -35,3 +26,19 @@ export function getPluginConfig(): KulturnavClientConfig { 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-kulturnav-api/src/lib/kulturnavClient.ts b/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts index b04ead9..106d607 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts +++ b/packages/sanity-plugin-muna-kulturnav-api/src/lib/kulturnavClient.ts @@ -1,5 +1,6 @@ import type { KulturnavAutocompleteItem, + KulturnavDetails, KulturnavReference, SearchFilters, } from '../types' @@ -26,6 +27,7 @@ export function transformKulturnavResponse( id: `${apiBaseUrl}/${item.uuid}`, type: entityType || 'Concept', // Default to Concept if not provided label: item.caption, + uuid: item.uuid, // Store UUID for direct lookup } } @@ -127,3 +129,121 @@ export async function searchKulturnav( } } +/** + * 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 +} + diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/types.ts b/packages/sanity-plugin-muna-kulturnav-api/src/types.ts index f8208b3..de7f26b 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/types.ts +++ b/packages/sanity-plugin-muna-kulturnav-api/src/types.ts @@ -1,3 +1,5 @@ +import type React from 'react' + /** * Linked Art reference model structure * @see https://linked.art/api/1.0/shared/reference/ @@ -9,6 +11,7 @@ export interface KulturnavReference { 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) + uuid?: string // Optional: UUID for fetching full details from core API notation?: string[] // Optional: additional identifiers (e.g., language tags) complete?: boolean // Optional: if true, stores full embedded data (maps to _complete in Linked Art) } @@ -23,6 +26,38 @@ export interface KulturnavAutocompleteItem { 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: KulturnavReference + onRemove: () => void + onShowDetails: () => void + details?: KulturnavDetails | null + isLoadingDetails?: boolean +} + /** * Search filters for kulturnav API */ @@ -50,6 +85,9 @@ export interface KulturnavPluginConfig { // Generic API support customSearchUrl?: (query: string, filters: SearchFilters) => string customTransformResponse?: (data: any) => any + // Preview configuration + previewComponent?: (props: PreviewComponentProps) => React.ReactElement + defaultPreviewStyle?: PreviewStyle | ((item: KulturnavReference) => PreviewStyle) } /** @@ -60,5 +98,6 @@ export interface KulturnavFieldOptions { dataset?: string lang?: string propertyType?: string + previewStyle?: PreviewStyle | ((item: KulturnavReference) => PreviewStyle) + previewComponent?: (props: PreviewComponentProps) => React.ReactElement } - From 724a85c57a1058d283ba9d7ea547fae40b0dbe56 Mon Sep 17 00:00:00 2001 From: Tarje Lavik Date: Mon, 29 Dec 2025 21:09:22 +0100 Subject: [PATCH 3/8] feat: enhance kulturnav plugin with new UI components and functionality - Added @sanity/icons and @sanity/ui dependencies for improved UI elements. - Refactored DetailsPopup component to utilize new UI components, enhancing layout and user experience. - Updated SearchInput and KulturnavInput components to use Stack for better spacing and layout. - Improved PreviewAvatar and PreviewBadge components with new styling and functionality. - Enhanced SearchResults component to provide better loading and error handling visuals. - Refactored SelectedBadges component to utilize Stack for consistent spacing. --- .../package.json | 2 + .../src/components/DetailsPopup.tsx | 594 ++++++------------ .../src/components/KulturnavInput.tsx | 5 +- .../src/components/PreviewAvatar.tsx | 81 +-- .../src/components/PreviewBadge.tsx | 95 +-- .../src/components/SearchInput.tsx | 101 +-- .../src/components/SearchResults.tsx | 86 +-- .../src/components/SelectedBadges.tsx | 37 +- 8 files changed, 321 insertions(+), 680 deletions(-) diff --git a/packages/sanity-plugin-muna-kulturnav-api/package.json b/packages/sanity-plugin-muna-kulturnav-api/package.json index 0415c27..afa6f57 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/package.json +++ b/packages/sanity-plugin-muna-kulturnav-api/package.json @@ -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/src/components/DetailsPopup.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx index 3fb9d34..bae9ebb 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/DetailsPopup.tsx @@ -1,4 +1,6 @@ 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' @@ -9,6 +11,20 @@ interface DetailsPopupProps { 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 */ @@ -33,58 +49,6 @@ function parseCaption(caption: string): { } } -/** - * Detect if dark mode is active - */ -function useDarkMode(): boolean { - const [isDark, setIsDark] = useState(() => { - // Check if body has dark background or if prefers-color-scheme is dark - if (typeof window === 'undefined') return false - - // Check Sanity's theme by looking at body background - const bodyBg = window.getComputedStyle(document.body).backgroundColor - const rgb = bodyBg.match(/\d+/g) - if (rgb) { - const [r, g, b] = rgb.map(Number) - // If background is dark (average < 128), it's dark mode - const avg = (r + g + b) / 3 - if (avg < 128) return true - } - - // Fallback to prefers-color-scheme - return window.matchMedia('(prefers-color-scheme: dark)').matches - }) - - useEffect(() => { - // Watch for changes in body background - const observer = new MutationObserver(() => { - const bodyBg = window.getComputedStyle(document.body).backgroundColor - const rgb = bodyBg.match(/\d+/g) - if (rgb) { - const [r, g, b] = rgb.map(Number) - const avg = (r + g + b) / 3 - setIsDark(avg < 128) - } - }) - - observer.observe(document.body, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - // Also watch for media query changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - const handleChange = (e: MediaQueryListEvent) => setIsDark(e.matches) - mediaQuery.addEventListener('change', handleChange) - - return () => { - observer.disconnect() - mediaQuery.removeEventListener('change', handleChange) - } - }, []) - - return isDark -} /** * Popup component to show detailed information about a kulturnav reference @@ -98,7 +62,6 @@ export function DetailsPopup({ onClose, }: DetailsPopupProps): React.JSX.Element { const popupRef = useRef(null) - const isDark = useDarkMode() const parsedCaption = useMemo(() => { // Try to get caption from details first, then from item @@ -169,391 +132,190 @@ export function DetailsPopup({ const externalUrl = uuid ? `https://kulturnav.org/${uuid}` : null - // Dark mode colors - const bgColor = isDark ? '#1a1a1a' : '#fff' - const textColor = isDark ? '#e5e5e5' : '#1a1a1a' - const textColorMuted = isDark ? '#999' : '#666' - const textColorLight = isDark ? '#666' : '#999' - const borderColor = isDark ? '#333' : '#e5e5e5' - const bgColorSecondary = isDark ? '#252525' : '#f9f9f9' - const bgColorTertiary = isDark ? '#2a2a2a' : '#f5f5f5' - const hoverBg = isDark ? '#333' : '#f0f0f0' - const overlayBg = isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.6)' - return ( -
-
+ - {/* Header */} -
e.stopPropagation()} > - - - {isLoading ? ( -
-
Loading details...
-
- -
-
- ) : details ? ( -
-

- {parsedCaption.name} -

-
- {parsedCaption.dates && ( -
- {parsedCaption.dates} -
- )} - {parsedCaption.language && ( -
- {parsedCaption.language} -
+ {/* Header */} + + + + {isLoading ? ( + + + Loading details... + + + + ) : details ? ( + + {parsedCaption.name} + + {parsedCaption.dates && ( + + {parsedCaption.dates} + + )} + {parsedCaption.language && ( + + {parsedCaption.language} + + )} + {parsedCaption.profession && ( + + {parsedCaption.profession} + + )} + + + ) : ( + {item.label || 'Untitled'} )} - {parsedCaption.profession && ( -
- {parsedCaption.profession} -
- )} -
-
- ) : ( -
-

- {item.label || 'Untitled'} -

-
- )} -
+
+
-
- )} - - )} - - + + )} + + )} + + + ) } diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx index 17882ab..172c91a 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/KulturnavInput.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react' import React from 'react' import { ObjectInputProps, set, unset } from 'sanity' +import { Stack } from '@sanity/ui' import type { KulturnavAutocompleteItem, KulturnavReference } from '../types' import { transformKulturnavResponse } from '../lib/kulturnavClient' @@ -61,7 +62,7 @@ export function KulturnavInput(props: KulturnavInputProps): React.JSX.Element { }, [onChange]) return ( -
+ { @@ -75,7 +76,7 @@ export function KulturnavInput(props: KulturnavInputProps): React.JSX.Element { {currentValue && ( handleRemove()} /> )} -
+ ) } diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx index 9df43ee..bb8138d 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewAvatar.tsx @@ -1,4 +1,6 @@ import React from 'react' +import { Avatar, Flex, Text, Button } from '@sanity/ui' +import { CloseIcon } from '@sanity/icons' import type { PreviewComponentProps } from '../types' @@ -20,82 +22,47 @@ export function PreviewAvatar({ .slice(0, 2) || '?' return ( -
{ e.stopPropagation() onShowDetails() }} > -
- {initials} -
- + {item.label || item.id} - - -
+ /> + ) } diff --git a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx index fe8961c..702daf5 100644 --- a/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx +++ b/packages/sanity-plugin-muna-kulturnav-api/src/components/PreviewBadge.tsx @@ -1,4 +1,6 @@ import React from 'react' +import { Badge, Flex, Text, Button } from '@sanity/ui' +import { CloseIcon } from '@sanity/icons' import type { PreviewComponentProps } from '../types' @@ -13,72 +15,43 @@ export function PreviewBadge({ onShowDetails, }: PreviewComponentProps): React.JSX.Element { return ( -
{ e.stopPropagation() onShowDetails() }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = '#f0f7ff' - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent' - }} > - - {item.label || item.id} - - -
+ + + {item.label || item.id} + +