@semiont/react-ui 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-NIMALXNZ.js → PdfAnnotationCanvas.client-U4EVBZEV.js} +6 -52
  3. package/dist/PdfAnnotationCanvas.client-U4EVBZEV.js.map +1 -0
  4. package/dist/{ar-U2EXWUMQ.js → ar-WA47UUWA.js} +4 -8
  5. package/dist/ar-WA47UUWA.js.map +1 -0
  6. package/dist/{bn-DRJGV772.js → bn-5ANDRIU6.js} +4 -8
  7. package/dist/bn-5ANDRIU6.js.map +1 -0
  8. package/dist/chunk-O2MD7TGE.js +42 -0
  9. package/dist/chunk-O2MD7TGE.js.map +1 -0
  10. package/dist/chunk-QT7LYM72.js +234 -0
  11. package/dist/chunk-QT7LYM72.js.map +1 -0
  12. package/dist/{chunk-3Q3TUKWP.js → chunk-WHUGODTB.js} +31 -31
  13. package/dist/chunk-XUDKYAVC.js +21 -0
  14. package/dist/chunk-XUDKYAVC.js.map +1 -0
  15. package/dist/{chunk-K6BJDL2I.js → chunk-YBGN4ACY.js} +6 -2
  16. package/dist/{cs-PTWDM23V.js → cs-3RU7F4JX.js} +4 -8
  17. package/dist/cs-3RU7F4JX.js.map +1 -0
  18. package/dist/{da-KSNIKYSS.js → da-GSW5P5HG.js} +7 -11
  19. package/dist/da-GSW5P5HG.js.map +1 -0
  20. package/dist/{de-F2XBEWFY.js → de-JUAUYF4Z.js} +4 -8
  21. package/dist/de-JUAUYF4Z.js.map +1 -0
  22. package/dist/{el-DLD2GWAP.js → el-JNLWCKEC.js} +4 -8
  23. package/dist/el-JNLWCKEC.js.map +1 -0
  24. package/dist/{en-L45VK7BS.js → en-RBN2GUHF.js} +2 -2
  25. package/dist/{es-WLPYWGB5.js → es-WPOX225H.js} +12 -16
  26. package/dist/es-WPOX225H.js.map +1 -0
  27. package/dist/{fa-BAXHSDZG.js → fa-TAEP6N77.js} +4 -8
  28. package/dist/fa-TAEP6N77.js.map +1 -0
  29. package/dist/{fi-FCHSYVOT.js → fi-ZVZHANSP.js} +4 -8
  30. package/dist/fi-ZVZHANSP.js.map +1 -0
  31. package/dist/{fr-3UERBSL6.js → fr-VLZW7M4N.js} +4 -8
  32. package/dist/fr-VLZW7M4N.js.map +1 -0
  33. package/dist/{he-F6F3FV2K.js → he-QFAFYA77.js} +4 -8
  34. package/dist/he-QFAFYA77.js.map +1 -0
  35. package/dist/{hi-4BK6IK7Q.js → hi-AO3DQPCV.js} +4 -8
  36. package/dist/hi-AO3DQPCV.js.map +1 -0
  37. package/dist/{id-7ECCWP3J.js → id-GTXF42WM.js} +4 -8
  38. package/dist/id-GTXF42WM.js.map +1 -0
  39. package/dist/index.css +97 -0
  40. package/dist/index.css.map +1 -1
  41. package/dist/index.d.ts +52 -23
  42. package/dist/index.js +2835 -3015
  43. package/dist/index.js.map +1 -1
  44. package/dist/integrations/css-modules-helper.d.ts +118 -0
  45. package/dist/integrations/css-modules-helper.js +117 -0
  46. package/dist/integrations/css-modules-helper.js.map +1 -0
  47. package/dist/integrations/styled-components-theme.d.ts +258 -0
  48. package/dist/integrations/styled-components-theme.js +1774 -0
  49. package/dist/integrations/styled-components-theme.js.map +1 -0
  50. package/dist/{it-234Z6XK6.js → it-AS5GM232.js} +4 -8
  51. package/dist/it-AS5GM232.js.map +1 -0
  52. package/dist/{ja-PJWQI4OQ.js → ja-GZ4HLUOF.js} +4 -8
  53. package/dist/ja-GZ4HLUOF.js.map +1 -0
  54. package/dist/{ko-APUEW2RS.js → ko-A4XUXFGJ.js} +4 -8
  55. package/dist/ko-A4XUXFGJ.js.map +1 -0
  56. package/dist/{ms-PJBZWZWD.js → ms-YBAO3S6K.js} +4 -8
  57. package/dist/ms-YBAO3S6K.js.map +1 -0
  58. package/dist/{nl-L4C3ZBCU.js → nl-3TJGIIIU.js} +5 -12
  59. package/dist/nl-3TJGIIIU.js.map +1 -0
  60. package/dist/{no-QE5N5KNG.js → no-4AXIQPRQ.js} +21 -25
  61. package/dist/no-4AXIQPRQ.js.map +1 -0
  62. package/dist/{pl-5Q2D23PD.js → pl-5GP6GKXO.js} +4 -8
  63. package/dist/pl-5GP6GKXO.js.map +1 -0
  64. package/dist/{pt-AIGUOIOC.js → pt-26Y6JFG5.js} +119 -123
  65. package/dist/pt-26Y6JFG5.js.map +1 -0
  66. package/dist/{ro-T56CSHTY.js → ro-C7UXFRWJ.js} +4 -8
  67. package/dist/ro-C7UXFRWJ.js.map +1 -0
  68. package/dist/{sv-L4TJQ2UH.js → sv-44DVD76T.js} +44 -48
  69. package/dist/sv-44DVD76T.js.map +1 -0
  70. package/dist/test-utils.js +3 -3
  71. package/dist/test-utils.js.map +1 -1
  72. package/dist/{th-6O7Y6O2Q.js → th-GIQRTBOY.js} +4 -8
  73. package/dist/th-GIQRTBOY.js.map +1 -0
  74. package/dist/{tr-D4CQCSNO.js → tr-WMQWO4D6.js} +4 -8
  75. package/dist/tr-WMQWO4D6.js.map +1 -0
  76. package/dist/{uk-2HMQG6ND.js → uk-I7ML6RGG.js} +4 -8
  77. package/dist/uk-I7ML6RGG.js.map +1 -0
  78. package/dist/{vi-XVJ4RUEJ.js → vi-FGWECASQ.js} +4 -8
  79. package/dist/vi-FGWECASQ.js.map +1 -0
  80. package/dist/{zh-K2KDPGHK.js → zh-L5FN73XC.js} +4 -8
  81. package/dist/zh-L5FN73XC.js.map +1 -0
  82. package/package.json +7 -6
  83. package/src/components/ProtectedErrorBoundary.css +119 -0
  84. package/src/components/ProtectedErrorBoundary.tsx +18 -13
  85. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +1 -1
  86. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +1 -1
  87. package/src/components/resource/AnnotateView.tsx +35 -37
  88. package/src/components/resource/BrowseView.tsx +31 -31
  89. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +0 -1
  90. package/src/components/resource/__tests__/BrowseView.test.tsx +4 -8
  91. package/src/components/resource/__tests__/HistoryEvent.test.tsx +0 -1
  92. package/src/components/resource/__tests__/event-formatting.test.ts +1 -1
  93. package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
  94. package/src/components/resource/panels/JsonLdPanel.tsx +33 -16
  95. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +1 -1
  96. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -1
  97. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -1
  98. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -1
  99. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +1 -1
  100. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +95 -424
  101. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +1 -1
  102. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +1 -1
  103. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -1
  104. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +7 -10
  105. package/src/features/admin-exchange/__tests__/ImportProgress.test.tsx +38 -27
  106. package/src/features/admin-exchange/components/ImportProgress.tsx +28 -34
  107. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +11 -9
  108. package/src/features/resource-compose/components/ResourceComposePage.tsx +36 -9
  109. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -8
  110. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +7 -5
  111. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +37 -0
  112. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +9 -5
  113. package/src/styles/features/exchange.css +0 -30
  114. package/src/styles/index.css +1 -0
  115. package/translations/ar.json +1 -3
  116. package/translations/bn.json +1 -3
  117. package/translations/cs.json +1 -3
  118. package/translations/da.json +4 -6
  119. package/translations/de.json +1 -3
  120. package/translations/el.json +1 -3
  121. package/translations/es.json +9 -11
  122. package/translations/fa.json +1 -3
  123. package/translations/fi.json +1 -3
  124. package/translations/fr.json +1 -3
  125. package/translations/he.json +1 -3
  126. package/translations/hi.json +1 -3
  127. package/translations/id.json +1 -3
  128. package/translations/it.json +1 -3
  129. package/translations/ja.json +1 -3
  130. package/translations/ko.json +1 -3
  131. package/translations/ms.json +1 -3
  132. package/translations/nl.json +2 -7
  133. package/translations/no.json +18 -20
  134. package/translations/pl.json +1 -3
  135. package/translations/pt.json +116 -118
  136. package/translations/ro.json +1 -3
  137. package/translations/sv.json +41 -43
  138. package/translations/th.json +1 -3
  139. package/translations/tr.json +1 -3
  140. package/translations/uk.json +1 -3
  141. package/translations/vi.json +1 -3
  142. package/translations/zh.json +1 -3
  143. package/dist/PdfAnnotationCanvas.client-NIMALXNZ.js.map +0 -1
  144. package/dist/ar-U2EXWUMQ.js.map +0 -1
  145. package/dist/bn-DRJGV772.js.map +0 -1
  146. package/dist/cs-PTWDM23V.js.map +0 -1
  147. package/dist/da-KSNIKYSS.js.map +0 -1
  148. package/dist/de-F2XBEWFY.js.map +0 -1
  149. package/dist/el-DLD2GWAP.js.map +0 -1
  150. package/dist/es-WLPYWGB5.js.map +0 -1
  151. package/dist/fa-BAXHSDZG.js.map +0 -1
  152. package/dist/fi-FCHSYVOT.js.map +0 -1
  153. package/dist/fr-3UERBSL6.js.map +0 -1
  154. package/dist/he-F6F3FV2K.js.map +0 -1
  155. package/dist/hi-4BK6IK7Q.js.map +0 -1
  156. package/dist/id-7ECCWP3J.js.map +0 -1
  157. package/dist/it-234Z6XK6.js.map +0 -1
  158. package/dist/ja-PJWQI4OQ.js.map +0 -1
  159. package/dist/ko-APUEW2RS.js.map +0 -1
  160. package/dist/ms-PJBZWZWD.js.map +0 -1
  161. package/dist/nl-L4C3ZBCU.js.map +0 -1
  162. package/dist/no-QE5N5KNG.js.map +0 -1
  163. package/dist/pl-5Q2D23PD.js.map +0 -1
  164. package/dist/pt-AIGUOIOC.js.map +0 -1
  165. package/dist/ro-T56CSHTY.js.map +0 -1
  166. package/dist/sv-L4TJQ2UH.js.map +0 -1
  167. package/dist/th-6O7Y6O2Q.js.map +0 -1
  168. package/dist/tr-D4CQCSNO.js.map +0 -1
  169. package/dist/uk-2HMQG6ND.js.map +0 -1
  170. package/dist/vi-XVJ4RUEJ.js.map +0 -1
  171. package/dist/zh-K2KDPGHK.js.map +0 -1
  172. /package/dist/{chunk-3Q3TUKWP.js.map → chunk-WHUGODTB.js.map} +0 -0
  173. /package/dist/{chunk-K6BJDL2I.js.map → chunk-YBGN4ACY.js.map} +0 -0
  174. /package/dist/{en-L45VK7BS.js.map → en-RBN2GUHF.js.map} +0 -0
@@ -45,16 +45,12 @@ const createProps = (overrides?: Partial<AdminExchangePageProps>): AdminExchange
45
45
  },
46
46
  progress: {
47
47
  phaseStarted: 'Starting…',
48
- phaseEntityTypes: 'Entity types…',
49
- phaseResources: 'Resources…',
50
- phaseAnnotations: 'Annotations…',
51
48
  phaseComplete: 'Complete',
52
49
  phaseError: 'Failed',
53
- hashChainValid: 'Hash valid',
54
- hashChainInvalid: 'Hash invalid',
55
- streams: 'Streams',
56
- events: 'Events',
57
- blobs: 'Blobs',
50
+ statsEventsReplayed: 'Events replayed',
51
+ statsResourcesCreated: 'Resources created',
52
+ statsAnnotationsCreated: 'Annotations created',
53
+ statsEntityTypesAdded: 'Entity types added',
58
54
  },
59
55
  },
60
56
  ToolbarPanels: () => <div data-testid="toolbar-panels" />,
@@ -100,10 +96,11 @@ describe('AdminExchangePage', () => {
100
96
  it('renders ImportProgress with backup result on completion', () => {
101
97
  render(<AdminExchangePage {...createProps({
102
98
  importPhase: 'complete',
103
- importResult: { stats: { streams: 5, events: 42, blobs: 3 }, hashChainValid: true },
99
+ importResult: { stats: { eventsReplayed: 42, resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 } },
104
100
  })} />);
105
101
  expect(screen.getByText('Complete')).toBeInTheDocument();
106
- expect(screen.getByText('5')).toBeInTheDocument();
102
+ expect(screen.getByText('42')).toBeInTheDocument();
103
+ expect(screen.getByText('Events replayed')).toBeInTheDocument();
107
104
  });
108
105
 
109
106
  it('applies panel-open class when common panel is active', () => {
@@ -9,16 +9,12 @@ import type { ImportProgressProps } from '../components/ImportProgress';
9
9
 
10
10
  const translations: ImportProgressProps['translations'] = {
11
11
  phaseStarted: 'Starting restore…',
12
- phaseEntityTypes: 'Adding entity types…',
13
- phaseResources: 'Creating resources…',
14
- phaseAnnotations: 'Creating annotations…',
15
12
  phaseComplete: 'Restore complete',
16
13
  phaseError: 'Restore failed',
17
- hashChainValid: 'Hash chain valid',
18
- hashChainInvalid: 'Hash chain invalid',
19
- streams: 'Event streams',
20
- events: 'Events',
21
- blobs: 'Content blobs',
14
+ statsEventsReplayed: 'Events replayed',
15
+ statsResourcesCreated: 'Resources created',
16
+ statsAnnotationsCreated: 'Annotations created',
17
+ statsEntityTypesAdded: 'Entity types added',
22
18
  };
23
19
 
24
20
  describe('ImportProgress', () => {
@@ -33,8 +29,8 @@ describe('ImportProgress', () => {
33
29
  });
34
30
 
35
31
  it('renders message during active phases', () => {
36
- render(<ImportProgress phase="resources" message="Processing resource 3/10" translations={translations} />);
37
- expect(screen.getByText('Processing resource 3/10')).toBeInTheDocument();
32
+ render(<ImportProgress phase="started" message="Restoring backup..." translations={translations} />);
33
+ expect(screen.getByText('Restoring backup...')).toBeInTheDocument();
38
34
  });
39
35
 
40
36
  it('does not render message during complete phase', () => {
@@ -61,44 +57,59 @@ describe('ImportProgress', () => {
61
57
  expect(screen.getByText('Connection failed')).toBeInTheDocument();
62
58
  });
63
59
 
64
- it('renders backup result stats', () => {
60
+ it('renders backup restore stats nested under result.stats', () => {
65
61
  render(<ImportProgress
66
62
  phase="complete"
67
- result={{ stats: { streams: 5, events: 42, blobs: 3 } }}
63
+ result={{ stats: { eventsReplayed: 42, resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 } }}
68
64
  translations={translations}
69
65
  />);
70
- expect(screen.getByText('5')).toBeInTheDocument();
71
- expect(screen.getByText('Event streams')).toBeInTheDocument();
72
66
  expect(screen.getByText('42')).toBeInTheDocument();
73
- expect(screen.getByText('Events')).toBeInTheDocument();
67
+ expect(screen.getByText('Events replayed')).toBeInTheDocument();
68
+ expect(screen.getByText('5')).toBeInTheDocument();
69
+ expect(screen.getByText('Resources created')).toBeInTheDocument();
70
+ expect(screen.getByText('12')).toBeInTheDocument();
71
+ expect(screen.getByText('Annotations created')).toBeInTheDocument();
74
72
  expect(screen.getByText('3')).toBeInTheDocument();
75
- expect(screen.getByText('Content blobs')).toBeInTheDocument();
73
+ expect(screen.getByText('Entity types added')).toBeInTheDocument();
76
74
  });
77
75
 
78
- it('renders valid hash chain badge', () => {
79
- const { container } = render(<ImportProgress
76
+ it('renders linked-data import stats nested under result.stats', () => {
77
+ render(<ImportProgress
80
78
  phase="complete"
81
- result={{ hashChainValid: true }}
79
+ result={{ stats: { resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 } }}
82
80
  translations={translations}
83
81
  />);
84
- expect(screen.getByText('Hash chain valid')).toBeInTheDocument();
85
- expect(container.querySelector('.semiont-exchange__hash-badge--valid')).toBeInTheDocument();
82
+ expect(screen.getByText('5')).toBeInTheDocument();
83
+ expect(screen.getByText('Resources created')).toBeInTheDocument();
84
+ expect(screen.getByText('12')).toBeInTheDocument();
85
+ expect(screen.getByText('Annotations created')).toBeInTheDocument();
86
+ expect(screen.getByText('3')).toBeInTheDocument();
87
+ expect(screen.getByText('Entity types added')).toBeInTheDocument();
86
88
  });
87
89
 
88
- it('renders invalid hash chain badge', () => {
90
+ it('does not render result section when result lacks a stats object', () => {
89
91
  const { container } = render(<ImportProgress
90
92
  phase="complete"
91
- result={{ hashChainValid: false }}
93
+ result={{ resourcesCreated: 5 }}
94
+ translations={translations}
95
+ />);
96
+ expect(container.querySelector('.semiont-exchange__result')).not.toBeInTheDocument();
97
+ });
98
+
99
+ it('renders raw key for unknown stats', () => {
100
+ render(<ImportProgress
101
+ phase="complete"
102
+ result={{ stats: { somethingNew: 7 } }}
92
103
  translations={translations}
93
104
  />);
94
- expect(screen.getByText('Hash chain invalid')).toBeInTheDocument();
95
- expect(container.querySelector('.semiont-exchange__hash-badge--invalid')).toBeInTheDocument();
105
+ expect(screen.getByText('7')).toBeInTheDocument();
106
+ expect(screen.getByText('somethingNew')).toBeInTheDocument();
96
107
  });
97
108
 
98
109
  it('does not render result section during non-complete phases', () => {
99
110
  const { container } = render(<ImportProgress
100
- phase="resources"
101
- result={{ stats: { streams: 1, events: 5, blobs: 0 } }}
111
+ phase="started"
112
+ result={{ stats: { eventsReplayed: 42, resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 } }}
102
113
  translations={translations}
103
114
  />);
104
115
  expect(container.querySelector('.semiont-exchange__result')).not.toBeInTheDocument();
@@ -6,16 +6,12 @@
6
6
 
7
7
  export interface ImportProgressTranslations {
8
8
  phaseStarted: string;
9
- phaseEntityTypes: string;
10
- phaseResources: string;
11
- phaseAnnotations: string;
12
9
  phaseComplete: string;
13
10
  phaseError: string;
14
- hashChainValid: string;
15
- hashChainInvalid: string;
16
- streams: string;
17
- events: string;
18
- blobs: string;
11
+ statsEventsReplayed: string;
12
+ statsResourcesCreated: string;
13
+ statsAnnotationsCreated: string;
14
+ statsEntityTypesAdded: string;
19
15
  }
20
16
 
21
17
  export interface ImportProgressProps {
@@ -27,13 +23,17 @@ export interface ImportProgressProps {
27
23
 
28
24
  const PHASE_LABELS: Record<string, keyof ImportProgressTranslations> = {
29
25
  started: 'phaseStarted',
30
- 'entity-types': 'phaseEntityTypes',
31
- resources: 'phaseResources',
32
- annotations: 'phaseAnnotations',
33
26
  complete: 'phaseComplete',
34
27
  error: 'phaseError',
35
28
  };
36
29
 
30
+ const STAT_LABELS: Record<string, keyof ImportProgressTranslations> = {
31
+ eventsReplayed: 'statsEventsReplayed',
32
+ resourcesCreated: 'statsResourcesCreated',
33
+ annotationsCreated: 'statsAnnotationsCreated',
34
+ entityTypesAdded: 'statsEntityTypesAdded',
35
+ };
36
+
37
37
  export function ImportProgress({ phase, message, result, translations: t }: ImportProgressProps) {
38
38
  const labelKey = PHASE_LABELS[phase];
39
39
  const phaseLabel = labelKey ? t[labelKey] : phase;
@@ -41,6 +41,13 @@ export function ImportProgress({ phase, message, result, translations: t }: Impo
41
41
  const isComplete = phase === 'complete';
42
42
  const isError = phase === 'error';
43
43
 
44
+ const stats = result?.stats != null && typeof result.stats === 'object'
45
+ ? (result.stats as Record<string, unknown>)
46
+ : undefined;
47
+ const statEntries = stats
48
+ ? Object.entries(stats).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
49
+ : [];
50
+
44
51
  return (
45
52
  <div className="semiont-exchange__progress">
46
53
  <div className={`semiont-exchange__phase-label${isError ? ' semiont-exchange__phase-label--error' : isComplete ? ' semiont-exchange__phase-label--complete' : ''}`}>
@@ -51,30 +58,17 @@ export function ImportProgress({ phase, message, result, translations: t }: Impo
51
58
  <p className="semiont-exchange__progress-message">{message}</p>
52
59
  )}
53
60
 
54
- {isComplete && result && (
61
+ {isComplete && statEntries.length > 0 && (
55
62
  <div className="semiont-exchange__result">
56
- {result.stats != null && typeof result.stats === 'object' && (
57
- <>
58
- {Object.entries(result.stats as Record<string, number>).map(([key, value]) => {
59
- const label = key === 'streams' ? t.streams
60
- : key === 'events' ? t.events
61
- : key === 'blobs' ? t.blobs
62
- : key;
63
- return (
64
- <div key={key} className="semiont-exchange__result-stat">
65
- <span className="semiont-exchange__result-value">{value}</span>
66
- <span className="semiont-exchange__result-label">{label}</span>
67
- </div>
68
- );
69
- })}
70
- </>
71
- )}
72
-
73
- {result.hashChainValid !== undefined && (
74
- <div className={`semiont-exchange__hash-badge${result.hashChainValid ? ' semiont-exchange__hash-badge--valid' : ' semiont-exchange__hash-badge--invalid'}`}>
75
- {result.hashChainValid ? t.hashChainValid : t.hashChainInvalid}
76
- </div>
77
- )}
63
+ {statEntries.map(([key, value]) => {
64
+ const statKey = STAT_LABELS[key];
65
+ return (
66
+ <div key={key} className="semiont-exchange__result-stat">
67
+ <span className="semiont-exchange__result-value">{value}</span>
68
+ <span className="semiont-exchange__result-label">{statKey ? t[statKey] : key}</span>
69
+ </div>
70
+ );
71
+ })}
78
72
  </div>
79
73
  )}
80
74
 
@@ -45,16 +45,12 @@ const createProps = (overrides?: Partial<LinkedDataPageProps>): LinkedDataPagePr
45
45
  },
46
46
  progress: {
47
47
  phaseStarted: 'Starting…',
48
- phaseEntityTypes: 'Entity types…',
49
- phaseResources: 'Resources…',
50
- phaseAnnotations: 'Annotations…',
51
48
  phaseComplete: 'Complete',
52
49
  phaseError: 'Failed',
53
- hashChainValid: 'Verified',
54
- hashChainInvalid: 'Verification failed',
55
- streams: 'Resources',
56
- events: 'Annotations',
57
- blobs: 'Entity types',
50
+ statsEventsReplayed: 'Events replayed',
51
+ statsResourcesCreated: 'Resources created',
52
+ statsAnnotationsCreated: 'Annotations created',
53
+ statsEntityTypesAdded: 'Entity types added',
58
54
  },
59
55
  },
60
56
  ToolbarPanels: () => <div data-testid="toolbar-panels" />,
@@ -100,9 +96,15 @@ describe('LinkedDataPage', () => {
100
96
  it('renders ImportProgress with result on completion', () => {
101
97
  render(<LinkedDataPage {...createProps({
102
98
  importPhase: 'complete',
103
- importResult: { resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 },
99
+ importResult: { stats: { resourcesCreated: 5, annotationsCreated: 12, entityTypesAdded: 3 } },
104
100
  })} />);
105
101
  expect(screen.getByText('Complete')).toBeInTheDocument();
102
+ expect(screen.getByText('5')).toBeInTheDocument();
103
+ expect(screen.getByText('Resources created')).toBeInTheDocument();
104
+ expect(screen.getByText('12')).toBeInTheDocument();
105
+ expect(screen.getByText('Annotations created')).toBeInTheDocument();
106
+ expect(screen.getByText('3')).toBeInTheDocument();
107
+ expect(screen.getByText('Entity types added')).toBeInTheDocument();
106
108
  });
107
109
 
108
110
  it('applies panel-open class when common panel is active', () => {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React, { useState, useEffect } from 'react';
10
10
  import type { GatheredContext } from '@semiont/core';
11
- import { isImageMimeType, isPdfMimeType, LOCALES } from '@semiont/core';
11
+ import { capabilitiesOf, AUTHORABLE_MEDIA_TYPES, MEDIA_TYPES, mediaTypeForExtension, isSupportedMediaType, LOCALES } from '@semiont/core';
12
12
  import type { UploadProgress } from '@semiont/sdk';
13
13
  import { type CloneData, type ReferenceData } from '../state/compose-page-state-unit';
14
14
  import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
@@ -17,6 +17,29 @@ import { CodeMirrorRenderer } from '../../../components/CodeMirrorRenderer';
17
17
  import { useFormAnnouncements } from '../../../components/LiveRegion';
18
18
  import { UploadProgressBar } from './UploadProgressBar';
19
19
 
20
+ /**
21
+ * Big tent: every registry type is uploadable. The `accept` hint lists all
22
+ * known extensions — it's only a hint; `handleFileUpload` never rejects.
23
+ */
24
+ const UPLOAD_ACCEPT = Object.values(MEDIA_TYPES).map((c) => c.extension).join(',');
25
+
26
+ /**
27
+ * Detection chain for uploaded files (same spirit as the CLI):
28
+ * 1. the browser's `file.type`, if it names a registry member;
29
+ * 2. otherwise the filename extension (`file.type` is routinely empty for
30
+ * `.md` and friends);
31
+ * 3. otherwise `application/octet-stream` — itself a registry member, so
32
+ * under the big tent every file is uploadable.
33
+ */
34
+ function detectUploadMediaType(file: File): string {
35
+ if (file.type && isSupportedMediaType(file.type)) return file.type;
36
+ if (file.name.includes('.')) {
37
+ const byExt = mediaTypeForExtension(file.name.slice(file.name.lastIndexOf('.')));
38
+ if (byExt) return byExt;
39
+ }
40
+ return 'application/octet-stream';
41
+ }
42
+
20
43
  export interface ResourceComposePageProps {
21
44
  mode: 'new' | 'clone' | 'reference';
22
45
  cloneData?: CloneData | null;
@@ -170,8 +193,9 @@ export function ResourceComposePage({
170
193
  const file = e.target.files?.[0];
171
194
  if (!file) return;
172
195
 
196
+ const detectedMediaType = detectUploadMediaType(file);
173
197
  setUploadedFile(file);
174
- setFileMimeType(file.type);
198
+ setFileMimeType(detectedMediaType);
175
199
  setInputMethod('upload');
176
200
 
177
201
  // Set file name as default resource name if empty
@@ -180,8 +204,9 @@ export function ResourceComposePage({
180
204
  setNewResourceName(nameWithoutExt);
181
205
  }
182
206
 
183
- // For images and PDFs, create preview URL
184
- if (isImageMimeType(file.type) || isPdfMimeType(file.type)) {
207
+ // Images and PDFs get an object-URL preview; everything else reads as text.
208
+ const detectedRender = capabilitiesOf(detectedMediaType)?.render;
209
+ if (detectedRender === 'image' || detectedRender === 'pdf') {
185
210
  const previewUrl = URL.createObjectURL(file);
186
211
  setFilePreviewUrl(previewUrl);
187
212
  } else {
@@ -521,7 +546,7 @@ export function ResourceComposePage({
521
546
  <div className="semiont-form__upload-area">
522
547
  <input
523
548
  type="file"
524
- accept="text/plain,text/markdown,image/png,image/jpeg,application/pdf"
549
+ accept={UPLOAD_ACCEPT}
525
550
  onChange={handleFileUpload}
526
551
  className="semiont-form__upload-input"
527
552
  disabled={isCreating}
@@ -563,7 +588,7 @@ export function ResourceComposePage({
563
588
  )}
564
589
 
565
590
  {/* Image Preview */}
566
- {uploadedFile && filePreviewUrl && isImageMimeType(fileMimeType) && (
591
+ {uploadedFile && filePreviewUrl && capabilitiesOf(fileMimeType)?.render === 'image' && (
567
592
  <div className="semiont-form__image-preview">
568
593
  <p className="semiont-form__image-preview-label">Preview:</p>
569
594
  <div className="semiont-form__image-preview-container">
@@ -594,9 +619,11 @@ export function ResourceComposePage({
594
619
  disabled={isCreating}
595
620
  className="semiont-select"
596
621
  >
597
- <option value="text/markdown">Markdown (text/markdown)</option>
598
- <option value="text/plain">Plain Text (text/plain)</option>
599
- <option value="text/html">HTML (text/html)</option>
622
+ {AUTHORABLE_MEDIA_TYPES.map((mt) => (
623
+ <option key={mt} value={mt}>
624
+ {capabilitiesOf(mt)?.label ?? mt} ({mt})
625
+ </option>
626
+ ))}
600
627
  </select>
601
628
  </div>
602
629
  )}
@@ -1,11 +1,11 @@
1
1
  import { BehaviorSubject, type Observable, map } from 'rxjs';
2
- import type { GatheredContext, AnnotationId, ContentFormat, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
2
+ import type { GatheredContext, AnnotationId, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
3
3
  import { resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
4
4
  import { createDisposer } from '@semiont/sdk';
5
5
  import type { StateUnit } from '@semiont/sdk';
6
6
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
7
7
  import type { SemiontClient } from '@semiont/sdk';
8
- import { getPrimaryMediaType, decodeWithCharset } from '@semiont/core';
8
+ import { decodeWithCharset, extensionForMediaType } from '@semiont/core';
9
9
  import type { UploadProgress } from '@semiont/sdk';
10
10
 
11
11
  export type ComposeMode = 'new' | 'clone' | 'reference';
@@ -108,11 +108,8 @@ export function createComposePageStateUnit(
108
108
  const tokenResult = await client.yield.fromToken(params.token!);
109
109
  if (tokenResult && auth) {
110
110
  const rId = makeResourceId(tokenResult['@id']);
111
- const mediaType = getPrimaryMediaType(tokenResult) || 'text/plain';
112
- const { data } = await client.browse.resourceRepresentation(rId, {
113
- accept: mediaType as ContentFormat,
114
- });
115
- const content = decodeWithCharset(data, mediaType);
111
+ const { data, contentType } = await client.browse.resourceRepresentation(rId);
112
+ const content = decodeWithCharset(data, contentType);
116
113
  cloneData$.next({ sourceResource: tokenResult, sourceContent: content });
117
114
  }
118
115
  } catch {
@@ -143,7 +140,7 @@ export function createComposePageStateUnit(
143
140
  mimeType = saveParams.format ?? 'application/octet-stream';
144
141
  } else {
145
142
  const blob = new Blob([saveParams.content || ''], { type: saveParams.format ?? 'application/octet-stream' });
146
- const extension = saveParams.format === 'text/plain' ? '.txt' : saveParams.format === 'text/html' ? '.html' : '.md';
143
+ const extension = extensionForMediaType(saveParams.format ?? 'application/octet-stream');
147
144
  fileToUpload = new File([blob], saveParams.name + extension, { type: saveParams.format ?? 'application/octet-stream' });
148
145
  mimeType = saveParams.format ?? 'application/octet-stream';
149
146
  }
@@ -9,8 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react';
9
9
  import type { components, ResourceDescriptor, ResourceId, GatheredContext, EventMap } from '@semiont/core';
10
10
  import type { ConnectionState } from '@semiont/core';
11
11
  import { annotationId } from '@semiont/core';
12
- import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType } from '@semiont/core';
13
- import { getMimeCategory } from '@semiont/core';
12
+ import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType, capabilitiesOf } from '@semiont/core';
14
13
  import { ANNOTATORS } from '@semiont/react-ui';
15
14
  import { ErrorBoundary } from '@semiont/react-ui';
16
15
  import { AnnotationHistory } from '@semiont/react-ui';
@@ -141,9 +140,12 @@ export function ResourceViewerPage({
141
140
  const { hoverDelayMs } = useHoverDelay();
142
141
  const { triggerSparkleAnimation, clearNewAnnotationId } = useResourceAnnotations();
143
142
 
144
- // Determine MIME category to choose content path
143
+ // Render mode chooses the content path: 'text' decodes inline; 'image'
144
+ // and 'pdf' go through the media-token (binary) path. 'none'/registry-miss
145
+ // fall to the text path harmlessly — the viewer shows metadata + download.
145
146
  const resourceMediaType = getPrimaryMediaType(resource) || 'text/plain';
146
- const isBinary = getMimeCategory(resourceMediaType) === 'image';
147
+ const renderMode = capabilitiesOf(resourceMediaType)?.render;
148
+ const isBinary = renderMode === 'image' || renderMode === 'pdf';
147
149
 
148
150
  // Text path: fetch and decode representation (disabled for binary — mediaToken path handles those)
149
151
  const { content: textContent, loading: textLoading } = useResourceContent(rUri, resource, !isBinary);
@@ -577,7 +579,7 @@ export function ResourceViewerPage({
577
579
 
578
580
  {/* JSON-LD Panel */}
579
581
  {activePanel === 'jsonld' && (
580
- <JsonLdPanel resource={resource} />
582
+ <JsonLdPanel resourceId={rUri} />
581
583
  )}
582
584
  </ToolbarPanels>
583
585
 
@@ -150,6 +150,43 @@ describe('createResourceViewerPageStateUnit', () => {
150
150
  stateUnit.dispose();
151
151
  });
152
152
 
153
+ // isBinaryType keys off textExtractionOf(...) !== 'decode', not render mode:
154
+ // storage-tier binary (ZIP, gif/webp) must be fetched as bytes, never
155
+ // decoded as text — the client-side twin of the Phase 3a serving-side fix.
156
+ it('fetches storage-tier binary (ZIP) as bytes, never the text-decode path', async () => {
157
+ const mediaToken = vi.fn().mockResolvedValue({ token: 'tok-zip' });
158
+ const resourceRepresentation = vi.fn();
159
+ tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
160
+ const stateUnit = createResourceViewerPageStateUnit(
161
+ tc.client, RID, 'en', mockBrowse(),
162
+ { mediaType: 'application/zip' },
163
+ );
164
+
165
+ const token = await firstValueFrom(stateUnit.mediaToken$.pipe(filter((t) => t !== null)));
166
+ expect(token).toBe('tok-zip');
167
+ expect(resourceRepresentation).not.toHaveBeenCalled();
168
+
169
+ stateUnit.dispose();
170
+ });
171
+
172
+ it('decodes a registry-miss text/* subtype via the text path (RFC 2046)', async () => {
173
+ const mediaToken = vi.fn();
174
+ const resourceRepresentation = vi.fn().mockResolvedValue({
175
+ data: new TextEncoder().encode('hi').buffer,
176
+ contentType: 'text/x-custom',
177
+ });
178
+ tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
179
+ const stateUnit = createResourceViewerPageStateUnit(
180
+ tc.client, RID, 'en', mockBrowse(),
181
+ { mediaType: 'text/x-custom' },
182
+ );
183
+
184
+ expect(resourceRepresentation).toHaveBeenCalled();
185
+ expect(mediaToken).not.toHaveBeenCalled();
186
+
187
+ stateUnit.dispose();
188
+ });
189
+
153
190
  it('wizard initializes closed', async () => {
154
191
  tc = clientWithNamespaces();
155
192
  const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
@@ -9,7 +9,7 @@ import { createGatherStateUnit, type GatherStateUnit } from '@semiont/sdk';
9
9
  import { createMatchStateUnit } from '@semiont/sdk';
10
10
  import { createYieldStateUnit, type YieldStateUnit } from '@semiont/sdk';
11
11
  import type { SemiontClient } from '@semiont/sdk';
12
- import { decodeWithCharset } from '@semiont/core';
12
+ import { decodeWithCharset, textExtractionOf } from '@semiont/core';
13
13
  import { isHighlight, isComment, isAssessment, isReference, isTag } from '@semiont/core';
14
14
  import type { ReferencedByEntry } from '@semiont/sdk';
15
15
 
@@ -113,13 +113,17 @@ export function createResourceViewerPageStateUnit(
113
113
  const mediaToken$ = new BehaviorSubject<string | null>(null);
114
114
 
115
115
  const mediaType = options?.mediaType || 'text/plain';
116
- const isBinaryType = mediaType.startsWith('image/') || mediaType === 'application/pdf';
116
+ // "Fetch raw bytes or decode as text?" — binary iff the registry says this
117
+ // type does not decode to text. Storage-tier images (gif/webp) are
118
+ // render:'none' but still binary, and a ZIP must avoid the text path; a
119
+ // mechanical render-mode check would mis-route both into mojibake.
120
+ const isBinaryType = textExtractionOf(mediaType) !== 'decode';
117
121
 
118
122
  if (!isBinaryType && mediaType) {
119
123
  contentLoading$.next(true);
120
- client.browse.resourceRepresentation(resourceId, { accept: mediaType })
121
- .then(({ data }) => {
122
- content$.next(decodeWithCharset(data, mediaType));
124
+ client.browse.resourceRepresentation(resourceId)
125
+ .then(({ data, contentType }) => {
126
+ content$.next(decodeWithCharset(data, contentType));
123
127
  contentLoading$.next(false);
124
128
  })
125
129
  .catch(() => { contentLoading$.next(false); });
@@ -362,36 +362,6 @@
362
362
  color: var(--semiont-color-gray-400);
363
363
  }
364
364
 
365
- /* Hash chain badge */
366
- .semiont-exchange__hash-badge {
367
- display: inline-flex;
368
- align-items: center;
369
- padding: 0.25rem 0.75rem;
370
- border-radius: 9999px;
371
- font-size: 0.75rem;
372
- font-weight: 500;
373
- }
374
-
375
- .semiont-exchange__hash-badge--valid {
376
- background-color: var(--semiont-color-green-100);
377
- color: var(--semiont-color-green-800);
378
- }
379
-
380
- [data-theme="dark"] .semiont-exchange__hash-badge--valid {
381
- background-color: rgba(34, 197, 94, 0.15);
382
- color: var(--semiont-color-green-300);
383
- }
384
-
385
- .semiont-exchange__hash-badge--invalid {
386
- background-color: var(--semiont-color-red-100);
387
- color: var(--semiont-color-red-800);
388
- }
389
-
390
- [data-theme="dark"] .semiont-exchange__hash-badge--invalid {
391
- background-color: rgba(239, 68, 68, 0.15);
392
- color: var(--semiont-color-red-300);
393
- }
394
-
395
365
  /* Error message */
396
366
  .semiont-exchange__error-message {
397
367
  font-size: 0.875rem;
@@ -91,6 +91,7 @@
91
91
  @import '../components/annotation/references.css';
92
92
  @import '../components/Toast.css';
93
93
  @import '../components/StatusDisplay.css';
94
+ @import '../components/ProtectedErrorBoundary.css';
94
95
  @import '../components/loading-states/loading.css';
95
96
  @import '../components/error-states/errors.css';
96
97
  @import '../features/auth/auth.css';
@@ -71,6 +71,7 @@
71
71
  "annotationRemoved": "تمت إزالة التعليق التوضيحي",
72
72
  "annotationBodyUpdated": "تم تحديث التعليق التوضيحي",
73
73
  "jobEvent": "حدث مهمة",
74
+ "embeddingComputed": "تم حساب التضمين",
74
75
  "justNow": "الآن",
75
76
  "minutesAgo": "منذ {{count}}د",
76
77
  "hoursAgo": "منذ {{count}}س",
@@ -356,8 +357,5 @@
356
357
  "semanticScoringHelp": "استخدام الذكاء الاصطناعي لتقييم النتائج حسب الصلة الدلالية",
357
358
  "searching": "جاري البحث...",
358
359
  "search": "بحث"
359
- },
360
- "History": {
361
- "embeddingComputed": "تم حساب التضمين"
362
360
  }
363
361
  }
@@ -71,6 +71,7 @@
71
71
  "annotationRemoved": "টীকা অপসারিত হয়েছে",
72
72
  "annotationBodyUpdated": "টীকা আপডেট হয়েছে",
73
73
  "jobEvent": "কাজের ঘটনা",
74
+ "embeddingComputed": "এমবেডিং গণনা করা হয়েছে",
74
75
  "justNow": "এইমাত্র",
75
76
  "minutesAgo": "{{count}} মি. আগে",
76
77
  "hoursAgo": "{{count}} ঘ. আগে",
@@ -356,8 +357,5 @@
356
357
  "semanticScoringHelp": "অর্থগত প্রাসঙ্গিকতা অনুযায়ী ফলাফল স্কোর করতে AI ব্যবহার করুন",
357
358
  "searching": "অনুসন্ধান করা হচ্ছে...",
358
359
  "search": "অনুসন্ধান"
359
- },
360
- "History": {
361
- "embeddingComputed": "এমবেডিং গণনা করা হয়েছে"
362
360
  }
363
361
  }
@@ -71,6 +71,7 @@
71
71
  "annotationRemoved": "Anotace odebrána",
72
72
  "annotationBodyUpdated": "Anotace aktualizována",
73
73
  "jobEvent": "Událost úlohy",
74
+ "embeddingComputed": "Embedding vypočten",
74
75
  "justNow": "právě teď",
75
76
  "minutesAgo": "před {{count}} min",
76
77
  "hoursAgo": "před {{count}} hod",
@@ -356,8 +357,5 @@
356
357
  "semanticScoringHelp": "Použít AI k hodnocení výsledků podle sémantické relevance",
357
358
  "searching": "Vyhledávání...",
358
359
  "search": "Hledat"
359
- },
360
- "History": {
361
- "embeddingComputed": "Embedding vypočten"
362
360
  }
363
361
  }