@ossy/resources 3.3.0 → 3.5.0

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 (42) hide show
  1. package/package.json +6 -6
  2. package/src/ImageResource.jsx +11 -3
  3. package/src/PlatformFileField.jsx +120 -85
  4. package/src/PlatformReferenceField.jsx +216 -0
  5. package/src/ResourceContentPage.jsx +5 -2
  6. package/src/ResourceGrid.jsx +321 -0
  7. package/src/ResourceGrid.stories.jsx +95 -0
  8. package/src/ResourceList.jsx +78 -8
  9. package/src/ResourcePanel.jsx +19 -2
  10. package/src/ResourceSelectionCheckbox.jsx +68 -0
  11. package/src/ResourcesPage.jsx +3 -0
  12. package/src/SchemaDetailView.jsx +18 -18
  13. package/src/SchemaPresenter.jsx +6 -4
  14. package/src/Upload.jsx +28 -13
  15. package/src/create.task.js +4 -0
  16. package/src/en.translations.json +4 -1
  17. package/src/get-resource.api.js +15 -0
  18. package/src/index.js +4 -0
  19. package/src/markdown.component.jsx +20 -0
  20. package/src/markdown.schema.js +13 -0
  21. package/src/markdown.spec.js +43 -0
  22. package/src/patch-content.action.js +1 -0
  23. package/src/patch-content.task.js +28 -0
  24. package/src/platform-reference-field.component.jsx +5 -0
  25. package/src/reference-field.helpers.js +57 -0
  26. package/src/reference-field.helpers.spec.js +100 -0
  27. package/src/resource-download.js +194 -0
  28. package/src/resource-download.spec.js +93 -0
  29. package/src/resource-read.helpers.js +5 -1
  30. package/src/resource-selection.js +91 -0
  31. package/src/resource-selection.spec.js +77 -0
  32. package/src/resource-upload-access.js +63 -0
  33. package/src/resource-upload-access.spec.js +100 -0
  34. package/src/resource.helpers.js +30 -5
  35. package/src/resources.attach-reference-links.js +100 -0
  36. package/src/resources.attach-reference-links.spec.js +118 -0
  37. package/src/resources.validate-references.js +104 -0
  38. package/src/resources.validate-references.spec.js +182 -0
  39. package/src/schema-field-display.js +12 -0
  40. package/src/server.js +13 -0
  41. package/src/sv.translations.json +4 -1
  42. package/src/update-content.task.js +4 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/resources",
3
3
  "description": "Resource domain — aggregate and events for the Ossy resource model",
4
- "version": "3.3.0",
4
+ "version": "3.5.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -23,10 +23,10 @@
23
23
  "src": "./src"
24
24
  },
25
25
  "dependencies": {
26
- "@ossy/event-store": "^3.2.0",
27
- "@ossy/fold": "^3.0.9",
28
- "@ossy/platform": "^3.3.0",
29
- "@ossy/schema": "^3.0.9"
26
+ "@ossy/event-store": "^3.4.0",
27
+ "@ossy/fold": "^3.4.0",
28
+ "@ossy/platform": "^3.5.0",
29
+ "@ossy/schema": "^3.5.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@ossy/design-system": ">=1.0.0",
@@ -49,5 +49,5 @@
49
49
  "/src",
50
50
  "README.md"
51
51
  ],
52
- "gitHead": "19e8bae4c1e9df79270218f28c0c9391f4f9871c"
52
+ "gitHead": "23167600f991596f122765a53f2b9df08314e115"
53
53
  }
@@ -14,9 +14,17 @@ export const ImageResource = ({
14
14
  return (
15
15
  <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-m) var(--space-s) var(--space-xl)' }}>
16
16
  <Image
17
- src={resource?.content?.sizes?.galleryLarge || resource.content.src}
18
- placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource.content.src}
19
- style={{ width: 'auto', height: '400px', margin: 'var(--space-m) auto var(--space-l)' }}
17
+ src={resource?.content?.sizes?.galleryLarge || resource?.content?.src}
18
+ placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
19
+ blurhash={resource?.content?.blurhash}
20
+ width={resource?.content?.width}
21
+ height={resource?.content?.height}
22
+ style={{
23
+ width: '100%',
24
+ maxWidth: '960px',
25
+ height: 'auto',
26
+ margin: 'var(--space-m) auto var(--space-l)',
27
+ }}
20
28
  />
21
29
  </div>
22
30
  )
@@ -6,7 +6,9 @@ import {
6
6
  Button,
7
7
  UploadInput,
8
8
  LocalFilePreview,
9
+ DropZone,
9
10
  acceptsFile,
11
+ partitionAcceptedFiles,
10
12
  } from '@ossy/design-system'
11
13
  import { uploadFile } from './resource.helpers.js'
12
14
  import { metadata as GetResource } from './get.action.js'
@@ -37,7 +39,7 @@ function resourceIdsFromValue(value) {
37
39
  }
38
40
 
39
41
  /**
40
- * Platform file field — eager upload on pick via SDK create + PUT.
42
+ * Platform file field — eager upload on pick or drop via SDK create + PUT.
41
43
  * Register as `@ossy/design-system/input/file` / `@ossy/design-system/input/image` through app component slots.
42
44
  */
43
45
  export function PlatformFileField({
@@ -63,6 +65,7 @@ export function PlatformFileField({
63
65
 
64
66
  const resourceId = multi ? null : resourceIdFromValue(value)
65
67
  const resourceIds = multi ? resourceIdsFromValue(value) : []
68
+ const busy = disabled || uploading
66
69
 
67
70
  useEffect(() => {
68
71
  if (!resourceId || localFile) {
@@ -105,6 +108,29 @@ export function PlatformFileField({
105
108
  [name, onChange, sdk, uploadLocation],
106
109
  )
107
110
 
111
+ const uploadMany = useCallback(
112
+ async files => {
113
+ const remaining = max - resourceIds.length
114
+ const batch = files.slice(0, remaining)
115
+ if (!batch.length) return
116
+ setUploading(true)
117
+ setError(null)
118
+ try {
119
+ const uploaded = []
120
+ for (const file of batch) {
121
+ const resource = await uploadFile(sdk, uploadLocation, file)
122
+ uploaded.push({ resourceId: resource.id })
123
+ }
124
+ commitResourceValue(name, [...resourceIds.map(id => ({ resourceId: id })), ...uploaded], onChange)
125
+ } catch (err) {
126
+ setError(err?.message || 'Upload failed')
127
+ } finally {
128
+ setUploading(false)
129
+ }
130
+ },
131
+ [max, name, onChange, resourceIds, sdk, uploadLocation],
132
+ )
133
+
108
134
  const onSinglePick = useCallback(
109
135
  e => {
110
136
  const file = e.target.files?.[0]
@@ -120,33 +146,38 @@ export function PlatformFileField({
120
146
  )
121
147
 
122
148
  const onMultiPick = useCallback(
123
- async e => {
149
+ e => {
124
150
  const picked = Array.from(e.target.files || [])
125
151
  e.target.value = ''
126
152
  if (!picked.length || uploading) return
127
- const valid = picked.filter(f => acceptsFile(f, accept))
128
- if (valid.length !== picked.length) {
153
+ const { accepted, rejected } = partitionAcceptedFiles(picked, accept)
154
+ if (rejected.length) {
129
155
  setError('Some files were rejected by accept filter')
130
156
  return
131
157
  }
132
- const remaining = max - resourceIds.length
133
- const batch = valid.slice(0, remaining)
134
- setUploading(true)
135
- setError(null)
136
- try {
137
- const uploaded = []
138
- for (const file of batch) {
139
- const resource = await uploadFile(sdk, uploadLocation, file)
140
- uploaded.push({ resourceId: resource.id })
141
- }
142
- commitResourceValue(name, [...resourceIds.map(id => ({ resourceId: id })), ...uploaded], onChange)
143
- } catch (err) {
144
- setError(err?.message || 'Upload failed')
145
- } finally {
146
- setUploading(false)
158
+ uploadMany(accepted)
159
+ },
160
+ [accept, uploadMany, uploading],
161
+ )
162
+
163
+ const onFilesDrop = useCallback(
164
+ files => {
165
+ if (busy) return
166
+ const { accepted, rejected } = partitionAcceptedFiles(files, accept)
167
+ if (!accepted.length) {
168
+ if (rejected.length) setError('File type not accepted')
169
+ return
147
170
  }
171
+ if (rejected.length) {
172
+ setError('Some files were rejected by accept filter')
173
+ }
174
+ if (multi) {
175
+ uploadMany(accepted)
176
+ return
177
+ }
178
+ uploadSingle(accepted[0])
148
179
  },
149
- [accept, max, name, onChange, resourceIds, sdk, uploadLocation, uploading],
180
+ [accept, busy, multi, uploadMany, uploadSingle],
150
181
  )
151
182
 
152
183
  const removeMulti = useCallback(
@@ -163,77 +194,81 @@ export function PlatformFileField({
163
194
  if (multi) {
164
195
  const canAdd = resourceIds.length < max
165
196
  return (
166
- <View gap="s" style={style} {...rest}>
167
- {resourceIds.map(id => (
168
- <View key={id} layout="row" gap="s" alignItems="center">
169
- <View
170
- as="img"
171
- src={`/r/${id}`}
172
- alt=""
173
- width="32px"
174
- height="32px"
175
- style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
197
+ <DropZone onFilesDrop={canAdd ? onFilesDrop : undefined}>
198
+ <View gap="s" style={style} data-ossy-file-dropzone {...rest}>
199
+ {resourceIds.map(id => (
200
+ <View key={id} layout="row" gap="s" alignItems="center">
201
+ <View
202
+ as="img"
203
+ src={`/r/${id}`}
204
+ alt=""
205
+ width="32px"
206
+ height="32px"
207
+ style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
208
+ />
209
+ <Text style={{ flex: 1, fontSize: 14 }}>{id}</Text>
210
+ <Button
211
+ type="button"
212
+ variant="command-danger"
213
+ prefix="close"
214
+ size="s"
215
+ disabled={busy}
216
+ onClick={() => removeMulti(id)}
217
+ />
218
+ </View>
219
+ ))}
220
+ {canAdd && (
221
+ <UploadInput
222
+ id={name}
223
+ name={name}
224
+ accept={accept}
225
+ disabled={busy}
226
+ required={required && resourceIds.length === 0}
227
+ multiple
228
+ onChange={onMultiPick}
176
229
  />
177
- <Text style={{ flex: 1, fontSize: 14 }}>{id}</Text>
178
- <Button
179
- type="button"
180
- variant="command-danger"
181
- prefix="close"
182
- size="s"
183
- disabled={disabled || uploading}
184
- onClick={() => removeMulti(id)}
185
- />
186
- </View>
187
- ))}
188
- {canAdd && (
189
- <UploadInput
190
- id={name}
191
- name={name}
192
- accept={accept}
193
- disabled={disabled || uploading}
194
- required={required && resourceIds.length === 0}
195
- multiple
196
- onChange={onMultiPick}
197
- />
198
- )}
199
- {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
200
- {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
201
- </View>
230
+ )}
231
+ {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
232
+ {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
233
+ </View>
234
+ </DropZone>
202
235
  )
203
236
  }
204
237
 
205
238
  const previewSrc = localFile ? null : previewUrl
206
239
 
207
240
  return (
208
- <View gap="s" style={style} {...rest}>
209
- {(localFile || previewSrc || resourceId) && (
210
- <View layout="row" gap="s" alignItems="center">
211
- {localFile && <LocalFilePreview file={localFile} size="48px" />}
212
- {!localFile && previewSrc && (
213
- <View
214
- as="img"
215
- src={previewSrc}
216
- alt={label || name}
217
- width="48px"
218
- height="48px"
219
- style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
220
- />
221
- )}
222
- {localFile && (
223
- <Text style={{ flex: 1, fontSize: 14 }}>{localFile.name}</Text>
224
- )}
225
- {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
226
- </View>
227
- )}
228
- <UploadInput
229
- id={name}
230
- name={name}
231
- accept={accept}
232
- disabled={disabled || uploading}
233
- required={required && !resourceId}
234
- onChange={onSinglePick}
235
- />
236
- {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
237
- </View>
241
+ <DropZone onFilesDrop={onFilesDrop}>
242
+ <View gap="s" style={style} data-ossy-file-dropzone {...rest}>
243
+ {(localFile || previewSrc || resourceId) && (
244
+ <View layout="row" gap="s" alignItems="center">
245
+ {localFile && <LocalFilePreview file={localFile} size="48px" />}
246
+ {!localFile && previewSrc && (
247
+ <View
248
+ as="img"
249
+ src={previewSrc}
250
+ alt={label || name}
251
+ width="48px"
252
+ height="48px"
253
+ style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
254
+ />
255
+ )}
256
+ {localFile && (
257
+ <Text style={{ flex: 1, fontSize: 14 }}>{localFile.name}</Text>
258
+ )}
259
+ {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
260
+ </View>
261
+ )}
262
+ <UploadInput
263
+ id={name}
264
+ name={name}
265
+ accept={accept}
266
+ disabled={busy}
267
+ required={required && !resourceId}
268
+ onChange={onSinglePick}
269
+ />
270
+ {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
271
+ </View>
272
+ </DropZone>
238
273
  )
239
274
  }
@@ -0,0 +1,216 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import { View, Text, Button, Select } from '@ossy/design-system'
3
+ import { useSdk, AsyncStatus } from '@ossy/sdk-react'
4
+ import { metadata as SearchResources } from './search.action.js'
5
+ import {
6
+ commitResourceValue,
7
+ resourceIdFromValue,
8
+ resourceIdsFromValue,
9
+ resourcePickerLabel,
10
+ sortResourcesForPicker,
11
+ toReferenceArray,
12
+ } from './reference-field.helpers.js'
13
+ import { useSchema } from './useSchemas.js'
14
+
15
+ /**
16
+ * Platform document-link field — stores `{ resourceId }` (or `{ resourceId }[]` when
17
+ * `max > 1`) pointing at other documents. When `of` is set, lists matching workspace
18
+ * resources via SearchResources for picking.
19
+ * Register as `@ossy/design-system/input/reference` through app component slots.
20
+ *
21
+ * @param {object} props
22
+ * @param {string} [props.of] Target schema id (e.g. `@ossy/profile/schema/profile`)
23
+ */
24
+ export function PlatformReferenceField({
25
+ name,
26
+ label,
27
+ value,
28
+ onChange,
29
+ of: targetSchemaId,
30
+ max = 1,
31
+ disabled,
32
+ required,
33
+ style = {},
34
+ ...rest
35
+ }) {
36
+ const sdk = useSdk()
37
+ const targetSchema = useSchema(targetSchemaId)
38
+ const targetLabel = targetSchema?.name || targetSchemaId
39
+ const multi = max > 1
40
+ const resourceId = multi ? null : resourceIdFromValue(value)
41
+ const resourceIds = multi ? resourceIdsFromValue(value) : []
42
+
43
+ const searchPayload = useMemo(
44
+ () => (targetSchemaId ? { type: targetSchemaId } : null),
45
+ [targetSchemaId],
46
+ )
47
+
48
+ const { status, data: resources = [] } = searchPayload
49
+ ? sdk.read(SearchResources, searchPayload)
50
+ : { status: AsyncStatus.Success, data: [] }
51
+
52
+ const options = useMemo(() => sortResourcesForPicker(resources), [resources])
53
+
54
+ const labelForId = useCallback(
55
+ id => {
56
+ const match = options.find(resource => resource.id === id)
57
+ return match ? resourcePickerLabel(match) : id
58
+ },
59
+ [options],
60
+ )
61
+
62
+ const selectResourceSingle = useCallback(
63
+ event => {
64
+ const next = event.target.value
65
+ if (!next) {
66
+ commitResourceValue(name, null, onChange)
67
+ return
68
+ }
69
+ commitResourceValue(name, { resourceId: next }, onChange)
70
+ },
71
+ [name, onChange],
72
+ )
73
+
74
+ const clearSingle = useCallback(() => {
75
+ commitResourceValue(name, null, onChange)
76
+ }, [name, onChange])
77
+
78
+ const addMulti = useCallback(
79
+ nextId => {
80
+ const id = typeof nextId === 'string' ? nextId.trim() : ''
81
+ if (!id || resourceIds.includes(id) || resourceIds.length >= max) return
82
+ commitResourceValue(name, toReferenceArray([...resourceIds, id]), onChange)
83
+ },
84
+ [max, name, onChange, resourceIds],
85
+ )
86
+
87
+ const selectResourceMulti = useCallback(
88
+ event => {
89
+ const next = event.target.value
90
+ event.target.value = ''
91
+ addMulti(next)
92
+ },
93
+ [addMulti],
94
+ )
95
+
96
+ const removeMulti = useCallback(
97
+ id => {
98
+ commitResourceValue(
99
+ name,
100
+ toReferenceArray(resourceIds.filter(rid => rid !== id)),
101
+ onChange,
102
+ )
103
+ },
104
+ [name, onChange, resourceIds],
105
+ )
106
+
107
+ if (!targetSchemaId) {
108
+ return (
109
+ <Text variant="small" color="neutral-600">
110
+ Reference fields require a target schema (`of`).
111
+ </Text>
112
+ )
113
+ }
114
+
115
+ const loading =
116
+ status === AsyncStatus.Loading || status === AsyncStatus.NotInitialized
117
+
118
+ if (multi) {
119
+ const canAdd = resourceIds.length < max
120
+ const availableOptions = options.filter(resource => !resourceIds.includes(resource.id))
121
+
122
+ return (
123
+ <View gap="s" style={style} data-ossy-reference-field={name} data-ossy-reference-multi="">
124
+ <Text variant="small" color="neutral-600">
125
+ Links to {targetLabel} (up to {max})
126
+ </Text>
127
+
128
+ {resourceIds.map(id => (
129
+ <View
130
+ key={id}
131
+ layout="row"
132
+ gap="s"
133
+ style={{ alignItems: 'center' }}
134
+ data-ossy-reference-selected={id}
135
+ >
136
+ <Text variant="small">{labelForId(id)}</Text>
137
+ <Button
138
+ type="button"
139
+ variant="command"
140
+ prefix="remove"
141
+ aria-label="Remove"
142
+ disabled={disabled}
143
+ onClick={() => removeMulti(id)}
144
+ data-ossy-reference-remove={id}
145
+ />
146
+ </View>
147
+ ))}
148
+
149
+ {canAdd ? (
150
+ <Select
151
+ {...rest}
152
+ id={name}
153
+ name={name}
154
+ value=""
155
+ disabled={disabled || loading}
156
+ required={required && resourceIds.length === 0}
157
+ aria-label={label || name}
158
+ onChange={selectResourceMulti}
159
+ data-ossy-reference-picker={name}
160
+ style={{ minWidth: '50%' }}
161
+ >
162
+ <option value="">
163
+ {loading ? 'Loading…' : 'Add a document…'}
164
+ </option>
165
+ {availableOptions.map(resource => (
166
+ <option key={resource.id} value={resource.id}>
167
+ {resourcePickerLabel(resource)}
168
+ </option>
169
+ ))}
170
+ </Select>
171
+ ) : null}
172
+ </View>
173
+ )
174
+ }
175
+
176
+ const selectedInOptions = options.some(resource => resource.id === resourceId)
177
+
178
+ return (
179
+ <View gap="s" style={style} data-ossy-reference-field={name}>
180
+ <Text variant="small" color="neutral-600">
181
+ Links to {targetLabel}
182
+ </Text>
183
+
184
+ <Select
185
+ {...rest}
186
+ id={name}
187
+ name={name}
188
+ value={selectedInOptions ? resourceId : ''}
189
+ disabled={disabled || loading}
190
+ required={required && !resourceId}
191
+ aria-label={label || name}
192
+ onChange={selectResourceSingle}
193
+ data-ossy-reference-picker={name}
194
+ style={{ minWidth: '50%' }}
195
+ >
196
+ <option value="">
197
+ {loading ? 'Loading…' : 'Select a document…'}
198
+ </option>
199
+ {options.map(resource => (
200
+ <option key={resource.id} value={resource.id}>
201
+ {resourcePickerLabel(resource)}
202
+ </option>
203
+ ))}
204
+ {resourceId && !selectedInOptions ? (
205
+ <option value={resourceId}>{resourceId}</option>
206
+ ) : null}
207
+ </Select>
208
+
209
+ {resourceId ? (
210
+ <Button type="button" variant="neutral" disabled={disabled} onClick={clearSingle}>
211
+ Clear
212
+ </Button>
213
+ ) : null}
214
+ </View>
215
+ )
216
+ }
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
+ import { resourceDownloadHref } from './resource-download.js'
3
4
  import { AsyncStatus, useSdk } from '@ossy/sdk-react'
4
5
  import { Switch, View, DelayedRender, Button, Icon, PageSection, Text, Tags, ImageCard } from '@ossy/design-system'
5
6
  import { useRouter } from '@ossy/router-react'
@@ -94,8 +95,7 @@ export const ResourceContentPage = (props) => {
94
95
  <View layout="row" gap="m">
95
96
  <Button
96
97
  variant="cta"
97
- target="_blank"
98
- href={resource?.content?.src}
98
+ href={resourceDownloadHref(resource)}
99
99
  download={resource?.name}
100
100
  suffix="software-download"
101
101
  >Download
@@ -115,6 +115,9 @@ export const ResourceContentPage = (props) => {
115
115
  onClick={() => { }}
116
116
  src={resource?.content?.sizes?.galleryLarge || resource?.content?.src}
117
117
  placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
118
+ blurhash={resource?.content?.blurhash}
119
+ width={resource?.content?.width}
120
+ height={resource?.content?.height}
118
121
  style={{ pageBreakInside: 'avoid', order: 1 }}
119
122
  />
120
123
  )