@ossy/resources 1.12.2 → 3.0.1

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 (89) hide show
  1. package/package.json +16 -4
  2. package/src/AudioResource.jsx +2 -2
  3. package/src/CreateDirectory.jsx +2 -10
  4. package/src/CreateDirectory.page.jsx +1 -1
  5. package/src/CreateDocument.jsx +2 -2
  6. package/src/CreateDocument.page.jsx +1 -1
  7. package/src/Definition.js +3 -2
  8. package/src/DocumentEdit.jsx +10 -38
  9. package/src/DocumentView.jsx +11 -22
  10. package/src/GenericResourceCard.jsx +25 -7
  11. package/src/GenericResourceDetail.jsx +6 -60
  12. package/src/GenericResourceForm.jsx +171 -37
  13. package/src/ImageResource.jsx +1 -1
  14. package/src/PDFResource.jsx +1 -1
  15. package/src/PlatformFileField.jsx +239 -0
  16. package/src/ResourceContentPage.jsx +5 -5
  17. package/src/ResourceDescription.jsx +1 -1
  18. package/src/ResourceDetails.jsx +3 -4
  19. package/src/ResourceDialogMove.jsx +3 -3
  20. package/src/ResourceFactory.jsx +11 -64
  21. package/src/ResourceGenericView.jsx +1 -1
  22. package/src/ResourceList.jsx +89 -106
  23. package/src/ResourcePanel.jsx +40 -57
  24. package/src/ResourceTags.jsx +1 -1
  25. package/src/ResourcesPage.jsx +26 -35
  26. package/src/SchemaDetailView.jsx +97 -0
  27. package/src/SchemaFormDefault.jsx +86 -0
  28. package/src/SchemaPresenter.jsx +207 -0
  29. package/src/Upload.jsx +6 -25
  30. package/src/Upload.page.jsx +1 -1
  31. package/src/UploadResources.jsx +2 -2
  32. package/src/VideoResource.jsx +1 -1
  33. package/src/access-filter.spec.js +1 -1
  34. package/src/create-page-view.action.js +1 -1
  35. package/src/create-page-view.task.js +10 -9
  36. package/src/create.action.js +1 -1
  37. package/src/create.task.js +27 -29
  38. package/src/delete.action.js +1 -1
  39. package/src/delete.task.js +9 -13
  40. package/src/en.translations.json +25 -24
  41. package/src/get-page-view-stats.action.js +1 -1
  42. package/src/get-page-view-stats.task.js +3 -4
  43. package/src/get-resource-alias.api.js +8 -0
  44. package/src/get-resource-info-alias.api.js +8 -0
  45. package/src/get-resource-info.api.js +36 -0
  46. package/src/get-resource-variant.api.js +8 -0
  47. package/src/get-resource.api.js +51 -0
  48. package/src/get.action.js +1 -1
  49. package/src/get.task.js +6 -6
  50. package/src/index.js +10 -0
  51. package/src/list.action.js +1 -1
  52. package/src/list.task.js +1 -1
  53. package/src/platform-file-field.component.jsx +5 -0
  54. package/src/platform-image-field.component.jsx +5 -0
  55. package/src/resource-create.page.jsx +5 -5
  56. package/src/resource-detail.page.jsx +1 -1
  57. package/src/resource-read.helpers.js +151 -0
  58. package/src/resource-stream.helpers.js +20 -0
  59. package/src/resource-stream.js +108 -0
  60. package/src/resource.helpers.js +9 -9
  61. package/src/resources.attach-media-urls.js +9 -3
  62. package/src/resources.events.js +42 -81
  63. package/src/{resources.spec.js → resources.integration.spec.js} +19 -19
  64. package/src/resources.queries.js +5 -2
  65. package/src/schema-detail.component.jsx +24 -0
  66. package/src/schema-form-default.component.jsx +14 -0
  67. package/src/schemaFormSlots.js +6 -0
  68. package/src/schemaViewSlots.js +6 -0
  69. package/src/search.action.js +1 -1
  70. package/src/search.task.js +1 -1
  71. package/src/server.js +2 -1
  72. package/src/sv.translations.json +25 -24
  73. package/src/update-access.action.js +1 -1
  74. package/src/update-access.task.js +9 -8
  75. package/src/update-content.action.js +1 -1
  76. package/src/update-content.task.js +25 -27
  77. package/src/update-location.action.js +1 -1
  78. package/src/update-location.task.js +9 -8
  79. package/src/update-name.action.js +1 -1
  80. package/src/update-name.task.js +9 -8
  81. package/src/upload-named-version.action.js +1 -1
  82. package/src/upload-named-version.task.js +16 -22
  83. package/src/useActivePath.jsx +3 -8
  84. package/src/useDocumentValidator.js +16 -0
  85. package/src/useSchemaEngine.js +9 -0
  86. package/src/useSchemas.js +29 -0
  87. package/src/useSelectedResourceId.jsx +36 -0
  88. package/src/ResourceContentPage.stories.jsx +0 -19
  89. package/src/resource.aggregate.js +0 -86
@@ -1,7 +1,6 @@
1
1
  'use client'
2
- import React, { useRef, useEffect } from 'react'
3
- import { useSdk } from '@ossy/sdk-react'
4
- import { GetWorkspace } from '@ossy/workspaces'
2
+ import React, { useRef, useEffect, useMemo, useCallback, memo } from 'react'
3
+ import { useSchemas } from './useSchemas.js'
5
4
  import {
6
5
  Dropdown,
7
6
  DropZone,
@@ -11,29 +10,37 @@ import {
11
10
  Text,
12
11
  Button,
13
12
  ContextMenu,
13
+ List,
14
14
  } from '@ossy/design-system'
15
15
  import { formatBytes } from './utils/format-bytes.js'
16
16
 
17
+ function useSchemaMap() {
18
+ const templates = useSchemas()
19
+ return useMemo(
20
+ () => templates.reduce((acc, template) => ({ ...acc, [template.id]: template }), {}),
21
+ [templates],
22
+ )
23
+ }
24
+
17
25
  export const ResourceList = ({
18
26
  resources = [],
19
27
  onClick = () => {},
20
28
  inlineFolder = null,
21
29
  }) => {
30
+ const templateMap = useSchemaMap()
22
31
 
23
32
  return (
24
- <>
33
+ <List>
25
34
  {inlineFolder && <InlineFolderRow {...inlineFolder} />}
26
35
  {resources.map(resource => (
27
36
  <ResourceListItem
28
- {...resource}
29
37
  key={resource.id}
30
- onClick={() => {
31
- onClick(resource)
32
- resource?.onClick?.(resource)
33
- }}
38
+ resource={resource}
39
+ templateMap={templateMap}
40
+ onItemClick={onClick}
34
41
  />
35
42
  ))}
36
- </>
43
+ </List>
37
44
  )
38
45
  }
39
46
 
@@ -64,51 +71,47 @@ function InlineFolderRow({ name, onChange, onSave, onCancel }) {
64
71
  }
65
72
 
66
73
  return (
67
- <View
68
- layout="row"
69
- gap="m"
70
- style={{
71
- height: '56px',
72
- flexShrink: 0,
73
- borderBottom: '1px solid var(--separator-primary)',
74
- }}
74
+ <List.Item
75
+ leading={<Icon size="s" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />}
75
76
  >
76
- <View gap="m" layout="row" alignItems="center" style={{ flexGrow: 1, padding: '12px 0 12px 20px' }}>
77
- <Icon size="s" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />
78
- <input
79
- ref={inputRef}
80
- value={name}
81
- onChange={event => onChange(event.target.value)}
82
- onKeyDown={handleKeyDown}
83
- onBlur={handleBlur}
84
- style={{
85
- flexGrow: 1,
86
- border: 'none',
87
- background: 'transparent',
88
- outline: 'none',
89
- fontSize: 'var(--text-default-font-size, 16px)',
90
- fontFamily: 'var(--text-default-font-family, sans-serif)',
91
- fontWeight: 'var(--text-default-font-weight, 400)',
92
- color: 'var(--text-default-color, CanvasText)',
93
- padding: 0,
94
- minWidth: 0,
95
- }}
96
- />
97
- </View>
98
- </View>
77
+ <input
78
+ ref={inputRef}
79
+ value={name}
80
+ onChange={event => onChange(event.target.value)}
81
+ onKeyDown={handleKeyDown}
82
+ onBlur={handleBlur}
83
+ style={{
84
+ flexGrow: 1,
85
+ border: 'none',
86
+ background: 'transparent',
87
+ outline: 'none',
88
+ fontSize: 'var(--text-default-font-size, 16px)',
89
+ fontFamily: 'var(--text-default-font-family, sans-serif)',
90
+ fontWeight: 'var(--text-default-font-weight, 400)',
91
+ color: 'var(--text-default-color, CanvasText)',
92
+ padding: 0,
93
+ minWidth: 0,
94
+ width: '100%',
95
+ }}
96
+ />
97
+ </List.Item>
99
98
  )
100
99
  }
101
100
 
102
- function ResourceListItem({
103
- onClick,
104
- href,
105
- onDrop,
106
- dragData,
107
- ...resource
101
+ const ResourceListItem = memo(function ResourceListItem({
102
+ resource,
103
+ templateMap,
104
+ onItemClick,
108
105
  }) {
109
-
106
+ const { onClick, href, onDrop, dragData, actions, name, type, content } = resource
107
+
108
+ const handleClick = useCallback(() => {
109
+ onItemClick(resource)
110
+ onClick?.(resource)
111
+ }, [onItemClick, onClick, resource])
112
+
110
113
  let Container = ({ children }) => <>{children}</>
111
-
114
+
112
115
  if (onDrop) {
113
116
  Container = DropZone
114
117
  } else if (dragData) {
@@ -117,77 +120,58 @@ function ResourceListItem({
117
120
 
118
121
  return (
119
122
  <Container onDrop={onDrop} dragData={dragData}>
120
- <View
121
- layout="row"
123
+ <List.Item
124
+ href={href}
125
+ onClick={handleClick}
122
126
  selectable
123
- gap="m"
124
- style={{
125
- height: '56px',
126
- flexShrink: 0,
127
- borderBottom: '1px solid var(--separator-primary)',
128
- }}
127
+ leading={<ResourceIcon type={type} content={content} templateMap={templateMap} />}
128
+ meta={<Size content={content} />}
129
+ trailing={<RowActions actions={actions} />}
130
+ data-resource-id={resource.id}
129
131
  >
130
- <View as={href ? 'a' : undefined} gap="m" layout='row' alignItems="center" style={{ flexGrow: 1, padding: '12px 0 12px 20px', }} href={href} onClick={onClick}>
131
- <ResourceIcon {...resource} />
132
- <Text>{typeof resource.name === 'string' && resource.name}</Text>
133
- <View style={{ flexGrow: 1 }}/>
134
- <Size {...resource} />
135
- </View>
136
-
137
- <View style={{ padding: '12px 8px 12px 0'}}>
138
- <RowActions {...resource} />
139
- </View>
140
- </View>
132
+ {typeof name === 'string' ? name : ''}
133
+ </List.Item>
141
134
  </Container>
142
135
  )
143
-
144
- }
145
-
146
- function Size(resource) {
147
-
148
- if (resource.content?.ContentLength) {
149
- return (
150
- <Text variant="small">
151
- {formatBytes(resource.content.ContentLength)}
152
- </Text>
153
- )
154
- }
155
-
156
- return <></>
157
- }
158
-
159
- function ResourceIcon({ ...resource }) {
160
- const sdk = useSdk()
161
- const { data: workspace } = sdk.read(GetWorkspace)
162
-
163
- const resourceTemplates = (workspace.resourceTemplates || [])
164
- .reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {})
165
-
166
- if (resource?.type === 'directory') {
136
+ })
137
+
138
+ const ResourceIcon = memo(function ResourceIcon({ type, content, templateMap }) {
139
+ if (type === 'directory') {
167
140
  return (
168
141
  <Icon size="s" name="folder" style={{ fill: 'hsl(0, 0%, 60%)'}} />
169
142
  )
170
143
  }
171
-
172
- else if (resource.type.startsWith('image')) {
144
+
145
+ if (type?.startsWith('image')) {
173
146
  return (
174
147
  <Image
175
- src={resource?.content?.sizes?.thumbnailSmall || resource?.content?.src}
176
- placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
148
+ src={content?.sizes?.thumbnailSmall || content?.src}
149
+ placeholderSrc={content?.sizes?.['loader-square-blurred-after'] || content?.src}
177
150
  style={{ width: '24px', height: '24px', borderRadius: '25%' }}
178
151
  />
179
152
  )
180
153
  }
181
-
154
+
182
155
  return (
183
- <Icon size="s" name={resourceTemplates[resource.type]?.icon || 'file'} style={{ fill: 'hsl(0, 0%, 80%)'}} />
156
+ <Icon size="s" name={templateMap[type]?.icon || 'file'} style={{ fill: 'hsl(0, 0%, 80%)'}} />
184
157
  )
185
- }
186
-
187
- function RowActions({actions}) {
188
-
189
- if (!actions) return <></>
190
-
158
+ })
159
+
160
+ function Size({ content }) {
161
+ if (content?.ContentLength) {
162
+ return (
163
+ <Text variant="small">
164
+ {formatBytes(content.ContentLength)}
165
+ </Text>
166
+ )
167
+ }
168
+
169
+ return null
170
+ }
171
+
172
+ function RowActions({ actions }) {
173
+ if (!actions) return null
174
+
191
175
  return (
192
176
  <Dropdown trigger={<Button prefix="more-vertical-alt" variant="command" /> } >
193
177
  <View inset="xs" surface="primary" roundness="s">
@@ -197,5 +181,4 @@ function ResourceListItem({
197
181
  </View>
198
182
  </Dropdown>
199
183
  )
200
-
201
- }
184
+ }
@@ -1,24 +1,16 @@
1
- import React, { useState, useEffect } from 'react'
1
+ import React, { useState, useEffect, memo } from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
3
  import { removeResource, updateResourceContent, renameResource } from './resource.helpers.js'
4
4
  import { useSdk } from '@ossy/sdk-react'
5
- import {
6
- Title,
7
- Overlay,
8
- Switch,
9
- Guide,
10
- View,
11
- Text,
12
- Button,
13
- InputTitle,
14
- useInputValue,
15
- } from '@ossy/design-system'
5
+ import { Switch, View, Text, Button, InputTitle, Alert, useInputValue } from '@ossy/design-system'
16
6
  import { ResourceFactory } from './ResourceFactory.jsx'
17
- import { useRouter } from '@ossy/router-react'
18
7
  import { ResourceDetails } from './ResourceDetails.jsx'
19
8
  import { ResourceTags } from './ResourceTags.jsx'
20
9
  import { ResourceDescription } from './ResourceDescription.jsx'
21
10
  import { useForm } from './useForm.js'
11
+ import { useSchema } from './useSchemas.js'
12
+ import { useSchemaEngine } from './useSchemaEngine.js'
13
+ import { validateContent } from '@ossy/schema'
22
14
 
23
15
  const ViewMode = {
24
16
  Closed: 'Closed',
@@ -26,11 +18,6 @@ const ViewMode = {
26
18
  Edit: 'Edit'
27
19
  }
28
20
 
29
- const Overlays = {
30
- None: 'None',
31
- Removedirectory : 'RemoveDirectory'
32
- }
33
-
34
21
  const DefaultViewModes = {
35
22
  'panel-resource-content': ViewMode.View,
36
23
  'panel-header': ViewMode.View,
@@ -39,19 +26,19 @@ const DefaultViewModes = {
39
26
  'panel-resource-tags': ViewMode.Closed,
40
27
  }
41
28
 
42
- export const ResourcePanel = ({
29
+ export const ResourcePanel = memo(function ResourcePanel ({
43
30
  resourceId,
44
31
  onClose: _onClose,
45
32
  extraPanels = [],
46
- }) => {
47
- const router = useRouter()
33
+ }) {
48
34
  const sdk = useSdk()
49
- const [overlay, setOverlay] = useState(Overlays.None)
50
- const [error] = useState()
35
+ const [contentError, setContentError] = useState()
51
36
  const [resourceName, setResourceName] = useInputValue('')
52
37
  const [panelViewModes, setPanelViewModes] = useState(DefaultViewModes)
53
38
 
54
- const { data: resource = {} } = sdk.read(GetResource, { id: resourceId }, { enabled: !!resourceId })
39
+ const { data: resource = {} } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
40
+ const template = useSchema(resource?.type)
41
+ const engine = useSchemaEngine()
55
42
 
56
43
  const form = useForm({ defaultData: resource.content })
57
44
 
@@ -69,6 +56,18 @@ export const ResourcePanel = ({
69
56
  }
70
57
 
71
58
  const onUpdateResource = () => {
59
+ if (template) {
60
+ const result = validateContent(engine, template, form.data)
61
+ if (!result.ok) {
62
+ setContentError(result.message)
63
+ return
64
+ }
65
+ setContentError(undefined)
66
+ updateResourceContent(sdk, resourceId, result.data)
67
+ .then(() => setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.View })))
68
+ .catch(() => alert('Something went wrong!'))
69
+ return
70
+ }
72
71
  updateResourceContent(sdk, resourceId, form.data)
73
72
  .then(() => setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.View })))
74
73
  .catch(() => alert('Something went wrong!'))
@@ -91,16 +90,26 @@ export const ResourcePanel = ({
91
90
  setResourceName(resource.name)
92
91
  }, [resource])
93
92
 
93
+ useEffect(() => {
94
+ setContentError(undefined)
95
+ }, [form.data])
96
+
94
97
  const panels = [
95
98
  {
96
99
  id: 'panel-resource-content',
97
100
  label: 'View',
98
101
  suffix: panelViewModes['panel-resource-content'] === ViewMode.Edit
99
102
  ? [
100
- { prefix: 'close', variant: 'command', onClick: () => setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.View })) },
103
+ { prefix: 'close', variant: 'command', onClick: () => {
104
+ setContentError(undefined)
105
+ setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.View }))
106
+ } },
101
107
  { prefix: 'check', variant: 'command', onClick: onUpdateResource }
102
108
  ]
103
- : [{ prefix: 'pen', variant: 'command', onClick: () => setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.Edit })) }],
109
+ : [{ prefix: 'pen', variant: 'command', onClick: () => {
110
+ setContentError(undefined)
111
+ setPanelViewModes(x => ({ ...x, 'panel-resource-content': ViewMode.Edit }))
112
+ } }],
104
113
  fill: true,
105
114
  content: ResourceFactory
106
115
  },
@@ -143,9 +152,9 @@ export const ResourcePanel = ({
143
152
  `}</style>
144
153
 
145
154
  {
146
- !!error && (
155
+ !!contentError && (
147
156
  <View.Item>
148
- {error}
157
+ <Alert variant="danger">{contentError}</Alert>
149
158
  </View.Item>
150
159
  )
151
160
  }
@@ -159,7 +168,7 @@ export const ResourcePanel = ({
159
168
  <Switch.Case match={[ViewMode.View]}>
160
169
 
161
170
  <View.Item fill surface="primary" style={{ padding: '4px 8px' }}>
162
- <Title as="h3" variant="tertiary">{resourceName}</Title>
171
+ <Text as="h3" variant="breadcrumb">{resourceName}</Text>
163
172
  </View.Item>
164
173
 
165
174
  <Button prefix="trash-empty" variant="command-danger" onClick={onRemoveResource}/>
@@ -174,7 +183,7 @@ export const ResourcePanel = ({
174
183
  <InputTitle
175
184
  id="document-name"
176
185
  type="text"
177
- style={{ display: 'block', width: '100%', padding: '16px 8px' }}
186
+ style={{ display: 'block', width: '100%' }}
178
187
  value={resourceName}
179
188
  onChange={setResourceName}
180
189
  onBlur={onRenameResource}
@@ -237,32 +246,6 @@ export const ResourcePanel = ({
237
246
 
238
247
  </View.Item>
239
248
 
240
- {
241
- overlay === Overlays.RemoveDirectory && (
242
- <Overlay isVisible={true} onClose={() => setOverlay(Overlays.None)}>
243
- <View layout="off-center-s" style={{ height: '100%' }}>
244
- <View slot="content">
245
- <View surface="primary" roundness="s">
246
- <View surface="primary" inset="l" roundness="s">
247
- <Guide
248
- title="Remove directory"
249
- text="Are you sure you want to remove this directory and everything in it?"
250
- actions={[
251
- { label: 'Cancel', variant: "command", onClick: () => setOverlay(Overlays.None) },
252
- { label: 'Remove', variant: 'command-danger', onClick: () => {
253
- removeResource(sdk, resourceId, resource?.location)
254
- setOverlay(Overlays.None)
255
- }}
256
- ]}
257
- />
258
- </View>
259
- </View>
260
- </View>
261
- </View>
262
- </Overlay>
263
- )
264
- }
265
-
266
249
  </View>
267
250
  )
268
- }
251
+ })
@@ -5,7 +5,7 @@ import { View, Tags, Text } from '@ossy/design-system'
5
5
 
6
6
  export const ResourceTags = ({ resourceId }) => {
7
7
  const sdk = useSdk()
8
- const { data: resource } = sdk.read(GetResource, { id: resourceId }, { enabled: !!resourceId })
8
+ const { data: resource } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
9
9
 
10
10
  let resourceContentTags = resource?.content?.Tags || []
11
11
 
@@ -2,7 +2,7 @@ import React from 'react'
2
2
  import { SDK } from '@ossy/sdk'
3
3
  import { metadata as ListResources } from './list.action.js'
4
4
  import { AsyncStatus, WorkspaceProvider, useSdk } from '@ossy/sdk-react'
5
- import { Title, Text, Switch, View, ImageCard, Tags, useLocale } from '@ossy/design-system'
5
+ import { Text, Switch, View, ImageCard, Tags, Page, useLocale } from '@ossy/design-system'
6
6
  import { useRouter } from '@ossy/router-react'
7
7
 
8
8
  const sdk = SDK.of({
@@ -27,41 +27,32 @@ export const ResourcesPage= () => {
27
27
  return (
28
28
  <Switch on={status}>
29
29
  <Switch.Case match={[AsyncStatus.Success]}>
30
-
31
- <main style={{
32
- height: '100%',
33
- gridArea: 'main',
34
- padding: '32px 16px 0'
35
- }}>
36
- <View gap="l" style={{ margin: '0 auto' }}>
37
-
38
- <View gap="m">
39
- <Title>{t('resources.home.title')}</Title>
40
- <Text style={{ maxWidth: '900px'}}>{t('resources.home.description')}</Text>
41
- </View>
42
-
43
- <WorkspaceProvider sdk={sdk}>
44
- <section style={freeResourcesGridStyles}>
45
- { resources.map(resource => (
46
- <ImageCard
47
- href={router.getHref({ id: "@resource", params: { resourceId: resource?.id } })}
48
- src={resource?.content?.sizes?.galleryMedium || resource?.content?.src}
49
- placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
50
- style={{ pageBreakInside: 'avoid', cursor: 'pointer' }}
51
- key={resources.id}
52
- />
53
- ))}
54
- </section>
55
- </WorkspaceProvider>
56
-
57
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-xxs)', marginTop: '24px' }}>
58
- <Tags tags={Object.entries(tagsCount).map(([tag, count]) => `${count} ${tag}`)} />
59
- </div>
30
+ <Page
31
+ maxWidth="xl"
32
+ title="resources.home.title"
33
+ description="resources.home.description"
34
+ descriptionMaxWidth="m"
35
+ >
36
+ <View gap="m">
37
+ <WorkspaceProvider sdk={sdk}>
38
+ <section style={freeResourcesGridStyles}>
39
+ { resources.map(resource => (
40
+ <ImageCard
41
+ href={router.getHref({ id: "@resource", params: { resourceId: resource?.id } })}
42
+ src={resource?.content?.sizes?.galleryMedium || resource?.content?.src}
43
+ placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
44
+ style={{ pageBreakInside: 'avoid', cursor: 'pointer' }}
45
+ key={resource.id}
46
+ />
47
+ ))}
48
+ </section>
49
+ </WorkspaceProvider>
50
+
51
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-xxs)', marginTop: '24px' }}>
52
+ <Tags tags={Object.entries(tagsCount).map(([tag, count]) => `${count} ${tag}`)} />
53
+ </div>
60
54
  </View>
61
-
62
-
63
- </main>
64
-
55
+ </Page>
65
56
  </Switch.Case>
66
57
  </Switch>
67
58
  )
@@ -0,0 +1,97 @@
1
+ import React from 'react'
2
+ import { Text, View, useLocale, hasTranslation } from '@ossy/design-system'
3
+
4
+ const labelStyle = { fontWeight: 600, opacity: 0.65 }
5
+ const valueStyle = { whiteSpace: 'pre-wrap' }
6
+
7
+ /**
8
+ * Generic read-only detail projection for schema-backed resources.
9
+ *
10
+ * @param {{
11
+ * schemaId?: string,
12
+ * fields?: { name: string, label?: string, derived?: boolean }[],
13
+ * data?: Record<string, unknown>,
14
+ * title?: string,
15
+ * emptyMessage?: string,
16
+ * }} props
17
+ */
18
+ export function SchemaDetailView ({
19
+ schemaId,
20
+ fields: fieldsProp,
21
+ data = {},
22
+ title,
23
+ emptyMessage = 'No data',
24
+ }) {
25
+ const { messages } = useLocale()
26
+ const fields = (fieldsProp ?? []).filter((field) => !field.derived)
27
+ const displayTitle = title ?? data.name ?? 'Preview'
28
+
29
+ if (!fields.length) {
30
+ const entries = Object.entries(data)
31
+
32
+ return (
33
+ <View stack gap="m">
34
+ {displayTitle && (
35
+ <Text variant="heading-default" as="h1">{displayTitle}</Text>
36
+ )}
37
+ {entries.length
38
+ ? entries.map(([name, value]) => (
39
+ <View gap="xs" key={name}>
40
+ <Text variant="small" style={labelStyle}>{name}</Text>
41
+ <Text variant="small" style={valueStyle}>
42
+ {value == null || value === ''
43
+ ? '—'
44
+ : typeof value === 'boolean'
45
+ ? (value ? 'Yes' : 'No')
46
+ : typeof value === 'object'
47
+ ? JSON.stringify(value, null, 2)
48
+ : String(value)}
49
+ </Text>
50
+ </View>
51
+ ))
52
+ : <Text variant="small" style={{ opacity: 0.6 }}>{emptyMessage}</Text>}
53
+ </View>
54
+ )
55
+ }
56
+
57
+ return (
58
+ <View stack gap="m">
59
+ {displayTitle && (
60
+ <Text variant="heading-default" as="h1">{displayTitle}</Text>
61
+ )}
62
+ {fields.map((field) => {
63
+ const value = data[field.name]
64
+ const labelKey = schemaId && !field.label ? `${schemaId}.${field.name}.label` : null
65
+
66
+ return (
67
+ <View gap="xs" key={field.name}>
68
+ {field.label
69
+ ? (
70
+ <Text variant="small" style={labelStyle}>{field.label}</Text>
71
+ )
72
+ : labelKey && hasTranslation(messages, labelKey)
73
+ ? (
74
+ <Text
75
+ variant="small"
76
+ style={labelStyle}
77
+ text={labelKey}
78
+ />
79
+ )
80
+ : (
81
+ <Text variant="small" style={labelStyle}>{field.name}</Text>
82
+ )}
83
+ <Text variant="small" style={valueStyle}>
84
+ {value == null || value === ''
85
+ ? '—'
86
+ : typeof value === 'boolean'
87
+ ? (value ? 'Yes' : 'No')
88
+ : typeof value === 'object'
89
+ ? JSON.stringify(value, null, 2)
90
+ : String(value)}
91
+ </Text>
92
+ </View>
93
+ )
94
+ })}
95
+ </View>
96
+ )
97
+ }