import { Menu } from '@headlessui/react' import cx from 'clsx' import dynamic from 'next/dynamic' import Link from 'next/link' import type { PropsWithChildren } from 'react' import { Fragment, useState } from 'react' import type { ActorDto, PropertyDto } from '@/api/sshoc' import { useGetItemCategories } from '@/api/sshoc' import { getMediaFileUrl, getMediaThumbnailUrl } from '@/api/sshoc/client' import type { Item as GenericItem, ItemCategory as ItemCategoryWithStep, ItemSearchQuery, } from '@/api/sshoc/types' import DocumentIcon from '@/elements/icons/small/document.svg' import ProtectedView from '@/modules/auth/ProtectedView' import RelatedItems from '@/modules/item/RelatedItems' import GridLayout from '@/modules/layout/GridLayout' import HStack from '@/modules/layout/HStack' import VStack from '@/modules/layout/VStack' import Metadata from '@/modules/metadata/Metadata' import { Anchor } from '@/modules/ui/Anchor' import Breadcrumbs from '@/modules/ui/Breadcrumbs' import Header from '@/modules/ui/Header' import { ItemCategoryIcon } from '@/modules/ui/ItemCategoryIcon' import Triangle from '@/modules/ui/Triangle' import { SectionTitle } from '@/modules/ui/typography/SectionTitle' import { Title } from '@/modules/ui/typography/Title' import styles from '@/screens/item/ItemLayout.module.css' import { formatDate } from '@/utils/formatDate' import { getSingularItemCategoryLabel } from '@/utils/getSingularItemCategoryLabel' import type { RequiredFields } from '@/utils/ts/object' import { Svg as UrlIcon } from '@@/assets/icons/url.svg' import OrcidIcon from '@@/public/assets/images/orcid.svg' /** lazy load markdown processor */ const Markdown = dynamic(() => import('@/modules/markdown/Markdown')) type ItemCategory = Exclude type ItemProperties = Exclude type ItemProperty = ItemProperties[number] type ItemContributors = Exclude type RelatedItems = Exclude type Item = GenericItem & { dateCreated?: string; dateLastUpdated?: string } /** * Shared item details screen. */ export default function ItemLayout({ item: _item, children, }: PropsWithChildren<{ item: Item }>): JSX.Element { const { data: itemCategories = {} } = useGetItemCategories() /** we can assume these fields to be present */ const item = _item as RequiredFields< typeof _item, 'id' | 'label' | 'category' | 'accessibleAt' > const query: ItemSearchQuery = { categories: [item.category], order: ['label'], } const relatedItems = item.relatedItems as RelatedItems return (
{item.label}
{item.status === 'approved' ? ( Edit ) : null} History
{children}
) } function SideColumn({ children }: PropsWithChildren) { return
{children}
} /** * Thumbail or category icon */ function ItemThumbnail({ category, thumbnail, }: { category: ItemCategory thumbnail: Item['thumbnail'] }) { if (thumbnail != null && thumbnail.mediaId !== undefined) { return ( ) } return ( ) } /** * Description */ function ItemDescription({ description, }: { description: Item['description'] }) { if (description === undefined) return null return (
) } /** * Upstream link(s) to item. */ function AccessibleAtLinks({ accessibleAt, category, }: { accessibleAt: Array category: ItemCategory }) { if (accessibleAt === undefined || accessibleAt.length === 0) return null /** we only get plural labels from the backend in GET items-categories */ const label = getSingularItemCategoryLabel(category) const buttonClassNames = 'w-full text-xl rounded py-4 px-4 bg-primary-800 text-white hover:bg-primary-700 transition-colors duration-150 inline-flex items-center' /** when more than one upstream link is provided render a menu button */ if (accessibleAt.length > 1) { return ( {({ open }) => (
Go to {label} {accessibleAt.map((href) => ( {href} ))}
)}
) } return ( Go to {label} ) } /** * Item media. */ function ItemMedia({ media }: { media: Item['media'] }) { const [index, setIndex] = useState(0) if (media === undefined || media.length === 0) return null const current = media[index] // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mediaId = current.info!.mediaId! const currentMediaUrl = current.info?.location?.sourceUrl ?? getMediaFileUrl({ mediaId }) const currentMediaCategory = current.info?.category return (
Media
{currentMediaCategory === 'image' ? ( ) : currentMediaCategory === 'video' ? ( // eslint-disable-next-line jsx-a11y/media-has-caption
    {media.map((m, index) => { const mediaId = m.info?.mediaId const hasThumbnail = m.info?.hasThumbnail if (mediaId == null) return null return (
  • ) })}
) } /** * Some properties are marked as `hidden` by the backend. * `thumbnail` and `media` are already shown, so we treat these as additional hidden properties * in the metadata sidepanel. */ const ADDITIONAL_HIDDEN_PROPERTIES = ['thumbnail', 'media'] interface ItemMetadata { properties: ItemProperties contributors: ItemContributors source?: Item['source'] sourceItemId?: Item['sourceItemId'] dateCreated?: string dateLastUpdated?: string externalIds?: Item['externalIds'] } /** * Properties. */ function ItemPropertiesList(props: ItemMetadata) { const metadata = useItemMetadata(props) if (metadata == null) return null return ( ) } function ItemPropertyValue({ property }: { property: ItemProperty }) { switch (property.type?.type) { case 'concept': return ( {property.concept?.label} ) case 'string': return {property.value} case 'url': return {property.value} case 'int': case 'float': return {property.value} case 'date': return property.value === undefined ? null : ( ) default: return null } } function useItemMetadata({ properties, contributors, source, sourceItemId, dateCreated, dateLastUpdated, externalIds, }: ItemMetadata) { const metadata: any = {} if (Array.isArray(properties) && properties.length > 0) { const grouped: Record>> = {} properties.forEach((property) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const type = property.type! if ( // The api returns hidden properties only for authenticated users with role moderator/administrator, // and we want to show hidden properties in the ui for these users. // type.hidden === true || // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ADDITIONAL_HIDDEN_PROPERTIES.includes(type.code!) ) { return } const groupName = property.type?.groupName ?? 'Other' // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition grouped[groupName] = grouped[groupName] ?? {} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const label = type.label! // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition grouped[groupName][label] = grouped[groupName][label] ?? [] grouped[groupName][label].push(property) }) const sorted = Object.entries(grouped) .map(([key, values]) => { const sortedValues = Object.entries(values).sort(([, [a]], [, [b]]) => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion a.type!.ord! > b.type!.ord! ? 1 : -1, ) return [key, sortedValues] as [ string, Array<[string, Array]>, ] }) .sort(([a], [b]) => (a > b ? 1 : -1)) metadata.properties = sorted.length === 0 ? null : (
    {sorted.map(([groupName, entries]) => { return (
  • {groupName}
      {entries.map(([label, properties]) => { return (
    • {label}:
        {properties.map((property, index) => { return (
      • {index !== 0 ? ', ' : null}
      • ) })}
    • ) })}
  • ) })}
) } if (Array.isArray(contributors) && contributors.length > 0) { const grouped: Record> = {} contributors.forEach((contributor) => { const role = contributor.role?.label if (role != null && contributor.actor != null) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition grouped[role] = grouped[role] ?? [] grouped[role].push(contributor.actor) } }) const sorted = Object.entries(grouped) .map(([key, value]) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return [key, value.sort((a, b) => a.name!.localeCompare(b.name!))] as [ string, Array, ] }) .sort(([a], [b]) => (a > b ? 1 : -1)) metadata.contributors = sorted.length === 0 ? null : (
    {sorted.map(([role, actors]) => { return (
  • {role}
      {actors.map((actor) => { const orcid = actor.externalIds?.find( (a) => a.identifierService?.code === 'ORCID', ) return (
    • {actor.name} {orcid != null ? ( ) : null} {Array.isArray(actor.affiliations) && actor.affiliations.length > 0 ? ( {actor.affiliations .map((affiliation) => { return affiliation.name }) .join(', ')} ) : null} {actor.email != null ? ( {actor.email} ) : null} {actor.website != null ? ( Website ) : null}
    • ) })}
  • ) })}
) } if (dateCreated != null || dateLastUpdated != null) { metadata.dates = (
{dateCreated != null ? (
Created:
) : null} {dateLastUpdated != null ? (
Last updated:
) : null}
) } if (source != null) { const label = source.label if (sourceItemId != null) { metadata.sourceItemId = (
Source: {source.urlTemplate != null ? ( {label} ) : ( {label} )}
) } } if (Array.isArray(externalIds) && externalIds.length > 0) { metadata.externalIds = (
    {externalIds.map((id) => { return (
  • {id.identifierService?.label}: {id.identifier}
  • ) })}
) } return metadata }