glam/frontend/src/pages/LinkMLViewerPage.tsx
2025-12-06 19:50:04 +01:00

1069 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
*
* Displays LinkML schema files with:
* - Sidebar listing schemas by category (main, classes, enums, slots)
* - Visual display of selected schema showing classes, slots, and enums
* - Raw YAML view toggle
* - Schema metadata and documentation
* - URL parameter support for deep linking to specific classes (?class=ClassName)
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import {
loadSchema,
loadSchemaRaw,
type LinkMLSchema,
type LinkMLClass,
type LinkMLSlot,
type LinkMLEnum,
type SchemaFile,
extractClasses,
extractSlots,
extractEnums,
} from '../lib/linkml/schema-loader';
import { useLanguage } from '../contexts/LanguageContext';
import { useCollapsibleHeader } from '../hooks/useCollapsibleHeader';
import { ChevronUp, ChevronDown } from 'lucide-react';
import './LinkMLViewerPage.css';
import '../styles/collapsible.css';
/**
* Converts snake_case or kebab-case identifiers to human-readable Title Case.
*
* Examples:
* - "custodian_appellation_class" → "Custodian Appellation Class"
* - "feature_place_class" → "Feature Place Class"
* - "heritage-custodian-observation" → "Heritage Custodian Observation"
*/
const formatDisplayName = (name: string): string => {
if (!name) return name;
return name
// Replace underscores and hyphens with spaces
.replace(/[_-]/g, ' ')
// Capitalize first letter of each word
.replace(/\b\w/g, char => char.toUpperCase());
};
/**
* Admonition transformer for markdown content.
* Converts patterns like "CRITICAL: message" into styled admonition elements.
*
* Supported patterns:
* - CRITICAL: Urgent, must-know information (red)
* - WARNING: Potential issues or gotchas (orange)
* - IMPORTANT: Significant but not urgent (amber)
* - NOTE: Informational, helpful context (blue)
*
* The pattern captures everything from the keyword until:
* - End of line (for single-line admonitions)
* - A blank line (paragraph break)
*/
const transformAdmonitions = (text: string): string => {
if (!text) return text;
// Pattern matches: KEYWORD: rest of the text until end of paragraph
// [^\n]* captures everything on the current line
// (?:\n(?!\n)[^\n]*)* captures continuation lines (lines that don't start a new paragraph)
const admonitionPattern = /\b(CRITICAL|WARNING|IMPORTANT|NOTE):\s*([^\n]*(?:\n(?!\n)[^\n]*)*)/g;
return text.replace(admonitionPattern, (_match, keyword, content) => {
const type = keyword.toLowerCase();
// Return HTML that ReactMarkdown will pass through
return `<span class="linkml-admonition linkml-admonition--${type}"><span class="linkml-admonition__label">${keyword}:</span><span class="linkml-admonition__content">${content.trim()}</span></span>`;
});
};
/**
* CURIE (Compact URI) highlighter for markdown content.
* Highlights patterns like "schema:name", "crm:E41_Appellation", "skos:altLabel".
*
* CURIE format: prefix:localName
* - prefix: lowercase letters (e.g., schema, crm, skos, foaf, dcterms)
* - localName: letters, numbers, underscores, hyphens (e.g., name, E41_Appellation)
*
* Also handles compound CURIEs like "schema:dateCreated/dateModified"
*/
const highlightCuries = (text: string): string => {
if (!text) return text;
// Common ontology prefixes used in heritage/linked data
const knownPrefixes = [
'schema', 'crm', 'skos', 'foaf', 'dcterms', 'dct', 'dc', 'rdfs', 'rdf', 'owl',
'prov', 'org', 'locn', 'cpov', 'tooi', 'rico', 'bf', 'wikidata', 'wd', 'wdt',
'xsd', 'linkml', 'hc', 'pico', 'cv'
].join('|');
// Pattern matches: prefix:localName (with optional /additional parts)
// Negative lookbehind (?<![:/]) prevents matching URLs like http://...
// Negative lookahead (?![:/]) prevents partial URL matches
const curiePattern = new RegExp(
`(?<![:/])\\b((?:${knownPrefixes}):(?:[A-Za-z][A-Za-z0-9_-]*(?:/[A-Za-z][A-Za-z0-9_-]*)*))(?![:/])`,
'g'
);
return text.replace(curiePattern, '<code class="linkml-curie">$1</code>');
};
/**
* Combined content transformer that applies all text transformations.
* Order matters: CURIEs first (so they don't get broken by admonition spans),
* then admonitions.
*/
const transformContent = (text: string): string => {
if (!text) return text;
return transformAdmonitions(highlightCuries(text));
};
// Bilingual text content
const TEXT = {
sidebarTitle: { nl: 'LinkML-schema\'s', en: 'LinkML Schemas' },
pageTitle: { nl: 'LinkML-schemaviewer', en: 'LinkML Schema Viewer' },
visualView: { nl: 'Visuele weergave', en: 'Visual View' },
rawYaml: { nl: 'Ruwe YAML', en: 'Raw YAML' },
loading: { nl: 'Schema laden...', en: 'Loading schema...' },
noSchemasFound: { nl: 'Geen schema\'s gevonden. Zorg dat het manifest is gegenereerd.', en: 'No schemas found. Make sure the manifest is generated.' },
failedToInit: { nl: 'Initialiseren van schemalijst mislukt', en: 'Failed to initialize schema list' },
failedToLoad: { nl: 'Schema laden mislukt:', en: 'Failed to load schema:' },
unnamedSchema: { nl: 'Naamloos schema', en: 'Unnamed Schema' },
prefixes: { nl: 'Prefixen', en: 'Prefixes' },
classes: { nl: 'Klassen', en: 'Classes' },
slots: { nl: 'Slots', en: 'Slots' },
enumerations: { nl: 'Enumeraties', en: 'Enumerations' },
imports: { nl: 'Imports', en: 'Imports' },
abstract: { nl: 'abstract', en: 'abstract' },
required: { nl: 'verplicht', en: 'required' },
multivalued: { nl: 'meervoudig', en: 'multivalued' },
uri: { nl: 'URI:', en: 'URI:' },
id: { nl: 'ID:', en: 'ID:' },
version: { nl: 'Versie:', en: 'Version:' },
range: { nl: 'Bereik:', en: 'Range:' },
pattern: { nl: 'Patroon:', en: 'Pattern:' },
slotsLabel: { nl: 'Slots:', en: 'Slots:' },
exactMappings: { nl: 'Exacte mappings:', en: 'Exact Mappings:' },
closeMappings: { nl: 'Vergelijkbare mappings:', en: 'Close Mappings:' },
permissibleValues: { nl: 'Toegestane waarden:', en: 'Permissible Values:' },
meaning: { nl: 'betekenis:', en: 'meaning:' },
searchPlaceholder: { nl: 'Zoeken in waarden...', en: 'Search values...' },
showAll: { nl: 'Alles tonen', en: 'Show all' },
showLess: { nl: 'Minder tonen', en: 'Show less' },
showing: { nl: 'Toont', en: 'Showing' },
of: { nl: 'van', en: 'of' },
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
searchSchemas: { nl: 'Zoeken in schema\'s...', en: 'Search schemas...' },
mainSchema: { nl: 'Hoofdschema', en: 'Main Schema' },
noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' },
copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' },
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
};
// Dynamically discover schema files from the modules directory
interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
const LinkMLViewerPage: React.FC = () => {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [searchParams] = useSearchParams();
const [categories, setCategories] = useState<SchemaCategory[]>([]);
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
const [rawYaml, setRawYaml] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
const [highlightedClass, setHighlightedClass] = useState<string | null>(null);
const highlightedRef = useRef<HTMLDivElement>(null);
// State for expandable enum ranges in slots
const [expandedEnumRanges, setExpandedEnumRanges] = useState<Set<string>>(new Set());
const [loadedEnums, setLoadedEnums] = useState<Record<string, LinkMLEnum | null>>({});
const [enumSearchFilters, setEnumSearchFilters] = useState<Record<string, string>>({});
const [enumShowAll, setEnumShowAll] = useState<Record<string, boolean>>({});
// State for sidebar search and category filters
const [sidebarSearch, setSidebarSearch] = useState<string>('');
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>({
main: true,
class: true,
enum: true,
slot: true,
});
// State for copy to clipboard feedback
const [copyFeedback, setCopyFeedback] = useState(false);
// Collapsible header
const mainContentRef = useRef<HTMLElement>(null);
const { isCollapsed: isHeaderCollapsed, setIsCollapsed: setIsHeaderCollapsed } = useCollapsibleHeader(mainContentRef);
// Handle URL parameters for deep linking
const handleUrlParams = useCallback((cats: SchemaCategory[]) => {
const classParam = searchParams.get('class');
if (classParam) {
setHighlightedClass(classParam);
// Find the schema file that contains this class
const classFile = cats
.find(cat => cat.name === 'class')
?.files.find(file => file.name === classParam);
if (classFile) {
setSelectedSchema(classFile);
// Ensure classes section is expanded
setExpandedSections(prev => new Set([...prev, 'classes']));
}
}
}, [searchParams]);
// Initialize schema file list from manifest
useEffect(() => {
const initializeSchemas = async () => {
setIsLoading(true);
try {
// Fetch the schema manifest (dynamically generated at build time)
const cats = await fetchSchemaManifest();
if (cats.length === 0) {
setError(t('noSchemasFound'));
return;
}
setCategories(cats);
// Check URL params first for deep linking
handleUrlParams(cats);
// Select main schema by default if no URL param set the schema
const classParam = searchParams.get('class');
if (!classParam && cats[0]?.files.length > 0) {
setSelectedSchema(cats[0].files[0]);
}
} catch (err) {
setError(t('failedToInit'));
console.error(err);
} finally {
setIsLoading(false);
}
};
initializeSchemas();
}, [handleUrlParams, searchParams]);
// Scroll to highlighted class when it changes
useEffect(() => {
if (highlightedClass && highlightedRef.current) {
setTimeout(() => {
highlightedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [highlightedClass, schema]);
// Fetch schema manifest from dynamically generated JSON file
const fetchSchemaManifest = async (): Promise<SchemaCategory[]> => {
try {
const response = await fetch('/schemas/20251121/linkml/manifest.json');
if (!response.ok) {
throw new Error(`Failed to fetch manifest: ${response.status}`);
}
const manifest = await response.json();
// Transform manifest categories to our format
return manifest.categories.map((cat: { name: string; displayName: string; files: Array<{ name: string; path: string; category: string }> }) => ({
name: cat.name,
displayName: cat.displayName,
files: cat.files.map((file: { name: string; path: string; category: string }) => ({
name: file.name,
path: file.path,
category: cat.name as SchemaFile['category']
}))
}));
} catch (err) {
console.error('Failed to fetch schema manifest:', err);
// Return empty categories on error
return [];
}
};
// Load selected schema
useEffect(() => {
if (!selectedSchema) return;
const loadSelectedSchema = async () => {
setIsLoading(true);
setError(null);
try {
const [schemaData, yamlContent] = await Promise.all([
loadSchema(selectedSchema.path),
loadSchemaRaw(selectedSchema.path)
]);
setSchema(schemaData);
setRawYaml(yamlContent);
} catch (err) {
setError(`${t('failedToLoad')} ${selectedSchema.name}`);
console.error(err);
} finally {
setIsLoading(false);
}
};
loadSelectedSchema();
}, [selectedSchema]);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
// Check if a range is an enum type
const isEnumRange = (range: string): boolean => {
return range.endsWith('Enum');
};
// Toggle enum range expansion and load enum data if needed
const toggleEnumRange = async (slotName: string, enumName: string) => {
const key = `${slotName}:${enumName}`;
// Toggle expansion state
setExpandedEnumRanges(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
// Load enum data if not already loaded
if (!loadedEnums[enumName]) {
try {
const enumSchema = await loadSchema(`modules/enums/${enumName}.yaml`);
if (enumSchema?.enums) {
const enumDef = Object.values(enumSchema.enums)[0] as LinkMLEnum;
if (enumDef) {
setLoadedEnums(prev => ({ ...prev, [enumName]: { ...enumDef, name: enumName } }));
}
}
} catch (err) {
console.error(`Failed to load enum ${enumName}:`, err);
setLoadedEnums(prev => ({ ...prev, [enumName]: null }));
}
}
};
// Render enum values for a slot range
const renderEnumValues = (_slotName: string, enumName: string) => {
const enumDef = loadedEnums[enumName];
if (!enumDef?.permissible_values) {
return <div className="linkml-viewer__enum-loading">{t('loading')}</div>;
}
const allValues = Object.entries(enumDef.permissible_values);
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20; // Show first 20 values initially
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
return (
<div className="linkml-viewer__range-enum-values">
<div className="linkml-viewer__range-enum-header">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count">
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
</div>
<div className="linkml-viewer__range-enum-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__range-enum-item">
<code className="linkml-viewer__range-enum-name">{value}</code>
{details.meaning && (
<a
href={details.meaning.startsWith('wikidata:')
? `https://www.wikidata.org/wiki/${details.meaning.replace('wikidata:', '')}`
: details.meaning}
target="_blank"
rel="noopener noreferrer"
className="linkml-viewer__range-enum-link"
title={details.meaning}
>
🔗
</a>
)}
{details.description && (
<span className="linkml-viewer__range-enum-desc">{details.description}</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
);
};
const renderClassDetails = (cls: LinkMLClass) => {
const isHighlighted = highlightedClass === cls.name;
return (
<div
key={cls.name}
ref={isHighlighted ? highlightedRef : null}
className={`linkml-viewer__item ${isHighlighted ? 'linkml-viewer__item--highlighted' : ''}`}
>
<h4 className="linkml-viewer__item-name">
{cls.name}
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>}
</h4>
{cls.class_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{cls.class_uri}</code>
</div>
)}
{cls.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
</div>
)}
{cls.slots && cls.slots.length > 0 && (
<div className="linkml-viewer__slots-list">
<span className="linkml-viewer__label">{t('slotsLabel')}</span>
<div className="linkml-viewer__tag-list">
{cls.slots.map(slot => (
<span key={slot} className="linkml-viewer__tag">{slot}</span>
))}
</div>
</div>
)}
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.exact_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
))}
</div>
</div>
)}
{cls.close_mappings && cls.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.close_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
))}
</div>
</div>
)}
</div>
);
};
const renderSlotDetails = (slot: LinkMLSlot) => {
const rangeIsEnum = slot.range && isEnumRange(slot.range);
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
const isExpanded = expandedEnumRanges.has(enumKey);
return (
<div key={slot.name} className="linkml-viewer__item">
<h4 className="linkml-viewer__item-name">
{slot.name}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
</h4>
{slot.slot_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{slot.slot_uri}</code>
</div>
)}
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label">{t('range')}</span>
{rangeIsEnum ? (
<button
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
onClick={() => toggleEnumRange(slot.name, slot.range!)}
>
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
<code>{slot.range}</code>
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
</button>
) : (
<code>{slot.range}</code>
)}
</div>
)}
{/* Expandable enum values */}
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label">{t('pattern')}</span>
<code>{slot.pattern}</code>
</div>
)}
</div>
);
};
const renderEnumDetails = (enumDef: LinkMLEnum) => {
const enumName = enumDef.name;
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20;
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
return (
<div key={enumDef.name} className="linkml-viewer__item">
<h4 className="linkml-viewer__item-name">{enumDef.name}</h4>
{enumDef.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
</div>
)}
{allValues.length > 0 && (
<div className="linkml-viewer__enum-values">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
<div className="linkml-viewer__value-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__value-item">
<code className="linkml-viewer__value-name">{value}</code>
{details.description && (
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
</div>
)}
{details.meaning && (
<span className="linkml-viewer__value-meaning">
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
)}
</div>
);
};
const renderVisualView = () => {
if (!schema) return null;
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
return (
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
</div>
)}
{schema.id && (
<div className="linkml-viewer__schema-id">
<span className="linkml-viewer__label">{t('id')}</span>
<code>{schema.id}</code>
</div>
)}
{schema.version && (
<div className="linkml-viewer__schema-version">
<span className="linkml-viewer__label">{t('version')}</span>
<code>{schema.version}</code>
</div>
)}
</div>
{/* Prefixes */}
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('prefixes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('prefixes') ? '▼' : '▶'}
</span>
{t('prefixes')} ({Object.keys(schema.prefixes).length})
</button>
{expandedSections.has('prefixes') && (
<div className="linkml-viewer__prefix-list">
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
<div key={prefix} className="linkml-viewer__prefix-item">
<code className="linkml-viewer__prefix-name">{prefix}:</code>
<span className="linkml-viewer__prefix-uri">{uri}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Classes */}
{classes.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('classes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('classes') ? '▼' : '▶'}
</span>
{t('classes')} ({classes.length})
</button>
{expandedSections.has('classes') && (
<div className="linkml-viewer__section-content">
{classes.map(renderClassDetails)}
</div>
)}
</div>
)}
{/* Slots */}
{slots.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('slots')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('slots') ? '▼' : '▶'}
</span>
{t('slots')} ({slots.length})
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(renderSlotDetails)}
</div>
)}
</div>
)}
{/* Enums */}
{enums.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('enums')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('enums') ? '▼' : '▶'}
</span>
{t('enumerations')} ({enums.length})
</button>
{expandedSections.has('enums') && (
<div className="linkml-viewer__section-content">
{enums.map(renderEnumDetails)}
</div>
)}
</div>
)}
{/* Imports */}
{schema.imports && schema.imports.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('imports')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('imports') ? '▼' : '▶'}
</span>
{t('imports')} ({schema.imports.length})
</button>
{expandedSections.has('imports') && (
<div className="linkml-viewer__import-list">
{schema.imports.map(imp => (
<div key={imp} className="linkml-viewer__import-item">
<code>{imp}</code>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
const renderRawView = () => {
if (!rawYaml) return null;
const handleCopyYaml = async () => {
try {
await navigator.clipboard.writeText(rawYaml);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch (err) {
console.error('Failed to copy YAML:', err);
}
};
return (
<div className="linkml-viewer__raw">
<div className="linkml-viewer__raw-header">
<button
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
onClick={handleCopyYaml}
title={copyFeedback ? t('copied') : t('copyToClipboard')}
>
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
</button>
</div>
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
</div>
);
};
return (
<div className="linkml-viewer-page">
{/* Left Sidebar - Schema Files */}
<aside className="linkml-viewer-page__sidebar">
<h2 className="linkml-viewer-page__sidebar-title">{t('sidebarTitle')}</h2>
{/* Sidebar Search */}
<div className="linkml-viewer-page__sidebar-search">
<input
type="text"
placeholder={t('searchSchemas')}
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
className="linkml-viewer-page__sidebar-search-input"
/>
{sidebarSearch && (
<button
className="linkml-viewer-page__sidebar-search-clear"
onClick={() => setSidebarSearch('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* Category Filters */}
<div className="linkml-viewer-page__sidebar-filters">
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.main}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, main: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--main">
{t('mainSchema')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.class}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, class: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--class">
{t('classes')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.enum}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, enum: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--enum">
{t('enumerations')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.slot}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, slot: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--slot">
{t('slots')}
</span>
</label>
</div>
{categories
.filter(category => categoryFilters[category.name] !== false)
.map(category => {
// Filter files based on search
const filteredFiles = sidebarSearch
? category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
)
: category.files;
// Don't render empty categories
if (filteredFiles.length === 0) return null;
return (
<div key={category.name} className="linkml-viewer-page__category">
<h3 className="linkml-viewer-page__category-title">
{category.displayName}
<span className="linkml-viewer-page__category-count">
({filteredFiles.length}{sidebarSearch ? `/${category.files.length}` : ''})
</span>
</h3>
<div className="linkml-viewer-page__file-list">
{filteredFiles.map(file => (
<button
key={file.path}
className={`linkml-viewer-page__file-item ${
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
}`}
onClick={() => setSelectedSchema(file)}
>
{file.name}
</button>
))}
</div>
</div>
);
})}
{/* No results message */}
{sidebarSearch && categories.every(category =>
!categoryFilters[category.name] ||
category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
).length === 0
) && (
<div className="linkml-viewer-page__no-results">
{t('noMatchingSchemas')}
</div>
)}
<div className="minimal-footer">
© 2025{' '}
<a href="https://www.netwerkdigitaalerfgoed.nl" target="_blank" rel="noopener noreferrer">
Netwerk Digitaal Erfgoed
</a>
{' & '}
<a href="https://www.textpast.com" target="_blank" rel="noopener noreferrer">
TextPast
</a>
.{' '}
{language === 'nl' ? 'Alle rechten voorbehouden.' : 'All rights reserved.'}
</div>
</aside>
{/* Main Content */}
<main className="linkml-viewer-page__main" ref={mainContentRef}>
<header className={`linkml-viewer-page__header collapsible-header ${isHeaderCollapsed ? 'collapsible-header--collapsed' : ''}`}>
<h1 className="linkml-viewer-page__title">
{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}
</h1>
<div className="linkml-viewer-page__tabs">
<button
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('visual')}
>
{t('visualView')}
</button>
<button
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('raw')}
>
{t('rawYaml')}
</button>
</div>
</header>
{/* Header Toggle Bar */}
<div className="header-toggle-bar">
<button
className="header-toggle-btn"
onClick={() => setIsHeaderCollapsed(!isHeaderCollapsed)}
title={isHeaderCollapsed ? (language === 'nl' ? 'Toon header' : 'Show header') : (language === 'nl' ? 'Verberg header' : 'Hide header')}
>
{isHeaderCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
<span>{isHeaderCollapsed ? (language === 'nl' ? 'Toon header' : 'Show header') : (language === 'nl' ? 'Verberg header' : 'Hide header')}</span>
</button>
</div>
<div className="linkml-viewer-page__content">
{isLoading ? (
<div className="linkml-viewer-page__loading">{t('loading')}</div>
) : error ? (
<div className="linkml-viewer-page__error">{error}</div>
) : viewMode === 'visual' ? (
renderVisualView()
) : (
renderRawView()
)}
</div>
</main>
</div>
);
};
export default LinkMLViewerPage;