@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
@@ -0,0 +1,321 @@
1
+ 'use client'
2
+ import React, { useRef, useEffect, useMemo, useCallback, memo, useState } from 'react'
3
+ import { useSchemas } from './useSchemas.js'
4
+ import {
5
+ Dropdown,
6
+ DropZone,
7
+ Icon,
8
+ Image,
9
+ View,
10
+ Text,
11
+ Button,
12
+ ContextMenu,
13
+ } from '@ossy/design-system'
14
+ import { formatBytes } from './utils/format-bytes.js'
15
+ import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
16
+
17
+ const TILE_WIDTH = 160
18
+
19
+ function useSchemaMap() {
20
+ const templates = useSchemas()
21
+ return useMemo(
22
+ () => templates.reduce((acc, template) => ({ ...acc, [template.id]: template }), {}),
23
+ [templates],
24
+ )
25
+ }
26
+
27
+ export const ResourceGrid = ({
28
+ resources = [],
29
+ onClick = () => {},
30
+ onToggleSelect,
31
+ inlineFolder = null,
32
+ selectedIds,
33
+ showSelectionControls = false,
34
+ }) => {
35
+ const templateMap = useSchemaMap()
36
+ const selectedSet = useMemo(() => {
37
+ if (selectedIds instanceof Set) return selectedIds
38
+ return new Set(selectedIds || [])
39
+ }, [selectedIds])
40
+
41
+ return (
42
+ <View
43
+ layout="row-wrap"
44
+ gap="m"
45
+ inset="m"
46
+ alignItems="stretch"
47
+ style={{ alignContent: 'flex-start' }}
48
+ >
49
+ {inlineFolder && <InlineFolderTile {...inlineFolder} />}
50
+ {resources.map(resource => (
51
+ <ResourceGridItem
52
+ key={resource.id}
53
+ resource={resource}
54
+ templateMap={templateMap}
55
+ onItemClick={onClick}
56
+ onToggleSelect={onToggleSelect}
57
+ showSelectionControls={showSelectionControls}
58
+ selected={selectedSet.has(resource.id) || Boolean(resource.selected)}
59
+ />
60
+ ))}
61
+ </View>
62
+ )
63
+ }
64
+
65
+ function InlineFolderTile({ name, onChange, onSave, onCancel }) {
66
+ const inputRef = useRef(null)
67
+ const handledRef = useRef(false)
68
+
69
+ useEffect(() => {
70
+ if (inputRef.current) {
71
+ inputRef.current.focus()
72
+ inputRef.current.select()
73
+ }
74
+ }, [])
75
+
76
+ const handleKeyDown = (event) => {
77
+ if (event.key === 'Enter') {
78
+ handledRef.current = true
79
+ onSave(event.target.value)
80
+ } else if (event.key === 'Escape') {
81
+ handledRef.current = true
82
+ onCancel()
83
+ }
84
+ }
85
+
86
+ const handleBlur = (event) => {
87
+ if (handledRef.current) return
88
+ onSave(event.target.value)
89
+ }
90
+
91
+ return (
92
+ <View
93
+ surface="primary"
94
+ roundness="m"
95
+ inset="m"
96
+ gap="s"
97
+ style={{
98
+ width: TILE_WIDTH,
99
+ border: '1px dashed var(--separator-primary)',
100
+ boxSizing: 'border-box',
101
+ }}
102
+ >
103
+ <View layout="row" alignItems="center" justifyContent="center" style={{ height: 96 }}>
104
+ <Icon size="m" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />
105
+ </View>
106
+ <input
107
+ ref={inputRef}
108
+ value={name}
109
+ onChange={event => onChange(event.target.value)}
110
+ onKeyDown={handleKeyDown}
111
+ onBlur={handleBlur}
112
+ style={{
113
+ width: '100%',
114
+ border: 'none',
115
+ background: 'transparent',
116
+ outline: 'none',
117
+ fontSize: 'var(--text-small-font-size, 14px)',
118
+ fontFamily: 'var(--text-default-font-family, sans-serif)',
119
+ fontWeight: 'var(--text-default-font-weight, 400)',
120
+ color: 'var(--text-default-color, CanvasText)',
121
+ padding: 0,
122
+ minWidth: 0,
123
+ boxSizing: 'border-box',
124
+ }}
125
+ />
126
+ </View>
127
+ )
128
+ }
129
+
130
+ const ResourceGridItem = memo(function ResourceGridItem({
131
+ resource,
132
+ templateMap,
133
+ onItemClick,
134
+ onToggleSelect,
135
+ showSelectionControls = false,
136
+ selected = false,
137
+ }) {
138
+ const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
139
+ const [menuOpen, setMenuOpen] = useState(false)
140
+ const [menuPosition, setMenuPosition] = useState(null)
141
+
142
+ const handleClick = useCallback((event) => {
143
+ if (event?.metaKey || event?.ctrlKey || event?.shiftKey) {
144
+ event.preventDefault?.()
145
+ }
146
+ onItemClick?.(resource, event)
147
+ onClick?.(resource, event)
148
+ }, [onItemClick, onClick, resource])
149
+
150
+ const handleToggleSelect = useCallback((event) => {
151
+ onToggleSelect?.(resource, event)
152
+ }, [onToggleSelect, resource])
153
+
154
+ const handleContextMenu = useCallback((event) => {
155
+ if (!actions) return
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ setMenuPosition({ top: event.clientY, left: event.clientX })
159
+ setMenuOpen(true)
160
+ }, [actions])
161
+
162
+ const handleMenuOpenChange = useCallback((open) => {
163
+ setMenuOpen(open)
164
+ if (!open) setMenuPosition(null)
165
+ }, [])
166
+
167
+ const handleActionsTriggerClick = useCallback(() => {
168
+ setMenuPosition(null)
169
+ }, [])
170
+
171
+ let Container = ({ children }) => <>{children}</>
172
+
173
+ if (onDrop || onFilesDrop) {
174
+ Container = DropZone
175
+ } else if (dragData) {
176
+ Container = DropZone.Dragable
177
+ }
178
+
179
+ const tile = (
180
+ <View
181
+ as={href ? 'a' : 'div'}
182
+ href={href}
183
+ onClick={handleClick}
184
+ onContextMenu={handleContextMenu}
185
+ selectable
186
+ data-selected={selected || undefined}
187
+ surface="primary"
188
+ roundness="m"
189
+ inset="m"
190
+ gap="s"
191
+ data-resource-id={resource.id}
192
+ style={{
193
+ width: TILE_WIDTH,
194
+ textDecoration: 'none',
195
+ color: 'inherit',
196
+ cursor: 'pointer',
197
+ border: selected
198
+ ? '1px solid var(--foreground-active, var(--separator-primary))'
199
+ : '1px solid var(--separator-primary)',
200
+ boxSizing: 'border-box',
201
+ position: 'relative',
202
+ }}
203
+ >
204
+ {(showSelectionControls || selected) && (
205
+ <View
206
+ style={{
207
+ position: 'absolute',
208
+ top: 'var(--space-xs, 4px)',
209
+ left: 'var(--space-xs, 4px)',
210
+ zIndex: 1,
211
+ }}
212
+ onClick={event => event.stopPropagation()}
213
+ >
214
+ <ResourceSelectionCheckbox
215
+ selected={selected}
216
+ onToggle={handleToggleSelect}
217
+ />
218
+ </View>
219
+ )}
220
+ {actions ? (
221
+ <View
222
+ style={{
223
+ position: 'absolute',
224
+ top: 'var(--space-xs, 4px)',
225
+ right: 'var(--space-xs, 4px)',
226
+ zIndex: 1,
227
+ }}
228
+ onClick={event => event.stopPropagation()}
229
+ >
230
+ <RowActions
231
+ actions={actions}
232
+ open={menuOpen}
233
+ onOpenChange={handleMenuOpenChange}
234
+ position={menuPosition}
235
+ onTriggerClick={handleActionsTriggerClick}
236
+ />
237
+ </View>
238
+ ) : null}
239
+ <View layout="row" alignItems="center" justifyContent="center" style={{ height: 96 }}>
240
+ <ResourceThumb type={type} content={content} templateMap={templateMap} />
241
+ </View>
242
+ <Text
243
+ variant="small"
244
+ style={{
245
+ fontWeight: 'bold',
246
+ overflow: 'hidden',
247
+ textOverflow: 'ellipsis',
248
+ whiteSpace: 'nowrap',
249
+ }}
250
+ >
251
+ {typeof name === 'string' ? name : ''}
252
+ </Text>
253
+ <Size content={content} />
254
+ </View>
255
+ )
256
+
257
+ return (
258
+ <Container onDrop={onDrop} onFilesDrop={onFilesDrop} dragData={dragData}>
259
+ {tile}
260
+ </Container>
261
+ )
262
+ })
263
+
264
+ const ResourceThumb = memo(function ResourceThumb({ type, content, templateMap }) {
265
+ if (type === '@ossy/platform/schema/directory' || type === 'directory') {
266
+ return (
267
+ <Icon size="l" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />
268
+ )
269
+ }
270
+
271
+ const mediaType = content?.ContentType || type
272
+ if (typeof mediaType === 'string' && mediaType.startsWith('image')) {
273
+ return (
274
+ <Image
275
+ src={content?.sizes?.thumbnailSmall || content?.src}
276
+ placeholderSrc={content?.sizes?.['loader-square-blurred-after'] || content?.src}
277
+ style={{
278
+ width: 96,
279
+ height: 96,
280
+ objectFit: 'cover',
281
+ borderRadius: 'var(--roundness-s, 8px)',
282
+ }}
283
+ />
284
+ )
285
+ }
286
+
287
+ return (
288
+ <Icon size="l" name={templateMap[type]?.icon || 'file'} style={{ fill: 'hsl(0, 0%, 80%)' }} />
289
+ )
290
+ })
291
+
292
+ function Size({ content }) {
293
+ if (content?.ContentLength) {
294
+ return (
295
+ <Text variant="small" style={{ color: 'var(--text-secondary-color, hsl(0, 0%, 50%))' }}>
296
+ {formatBytes(content.ContentLength)}
297
+ </Text>
298
+ )
299
+ }
300
+
301
+ return null
302
+ }
303
+
304
+ function RowActions({ actions, open, onOpenChange, position, onTriggerClick }) {
305
+ if (!actions) return null
306
+
307
+ return (
308
+ <Dropdown
309
+ trigger={<Button prefix="more-vertical-alt" variant="command" onClick={onTriggerClick} />}
310
+ open={open}
311
+ onOpenChange={onOpenChange}
312
+ position={position}
313
+ >
314
+ <View inset="xs" surface="primary" roundness="s">
315
+ <ContextMenu roundness="s" surface="primary">
316
+ {actions}
317
+ </ContextMenu>
318
+ </View>
319
+ </Dropdown>
320
+ )
321
+ }
@@ -0,0 +1,95 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { Button, ContextMenu, Dropdown, View } from '@ossy/design-system'
3
+ import { WorkspaceProvider } from '@ossy/sdk-react'
4
+ import { ResourceGrid } from './ResourceGrid.jsx'
5
+ import { ResourceList } from './ResourceList.jsx'
6
+
7
+ const mockSdk = {
8
+ invoke: async () => [],
9
+ }
10
+
11
+ const sampleResources = [
12
+ {
13
+ id: 'folder-1',
14
+ name: 'Projects',
15
+ type: '@ossy/platform/schema/directory',
16
+ content: {},
17
+ actions: [
18
+ <ContextMenu.Item key="open" prefix="mail-forward">Open</ContextMenu.Item>,
19
+ <ContextMenu.Separator key="sep" />,
20
+ <ContextMenu.Item key="remove" variant="command-danger" prefix="trash-empty">Remove</ContextMenu.Item>,
21
+ ],
22
+ },
23
+ {
24
+ id: 'file-1',
25
+ name: 'brief.pdf',
26
+ type: '@ossy/platform/schema/file',
27
+ content: { ContentType: 'application/pdf', ContentLength: 245760 },
28
+ actions: [
29
+ <ContextMenu.Item key="view" prefix="details-more">View</ContextMenu.Item>,
30
+ <ContextMenu.Item key="remove" variant="command-danger" prefix="trash-empty">Remove</ContextMenu.Item>,
31
+ ],
32
+ },
33
+ {
34
+ id: 'file-2',
35
+ name: 'notes.txt',
36
+ type: '@ossy/platform/schema/file',
37
+ content: { ContentType: 'text/plain', ContentLength: 1024 },
38
+ actions: [
39
+ <ContextMenu.Item key="view" prefix="details-more">View</ContextMenu.Item>,
40
+ <ContextMenu.Item key="remove" variant="command-danger" prefix="trash-empty">Remove</ContextMenu.Item>,
41
+ ],
42
+ },
43
+ ]
44
+
45
+ function ViewToggleDemo() {
46
+ const [viewMode, setViewMode] = useState('list')
47
+ const resources = useMemo(() => sampleResources, [])
48
+
49
+ return (
50
+ <WorkspaceProvider sdk={mockSdk}>
51
+ <View gap="m">
52
+ <Dropdown
53
+ trigger={(
54
+ <Button
55
+ prefix={viewMode === 'grid' ? 'layout-grid' : 'layout-list'}
56
+ variant="command"
57
+ aria-label="Layout"
58
+ />
59
+ )}
60
+ >
61
+ <View inset="xs" surface="primary" roundness="s">
62
+ <ContextMenu roundness="s" surface="primary">
63
+ <ContextMenu.Item
64
+ prefix="layout-list"
65
+ suffix={viewMode === 'list' ? 'check' : undefined}
66
+ onClick={() => setViewMode('list')}
67
+ >
68
+ List view
69
+ </ContextMenu.Item>
70
+ <ContextMenu.Item
71
+ prefix="layout-grid"
72
+ suffix={viewMode === 'grid' ? 'check' : undefined}
73
+ onClick={() => setViewMode('grid')}
74
+ >
75
+ Grid view
76
+ </ContextMenu.Item>
77
+ </ContextMenu>
78
+ </View>
79
+ </Dropdown>
80
+ {viewMode === 'grid'
81
+ ? <ResourceGrid resources={resources} />
82
+ : <ResourceList resources={resources} />}
83
+ </View>
84
+ </WorkspaceProvider>
85
+ )
86
+ }
87
+
88
+ export default {
89
+ title: 'Resources/Resource views',
90
+ component: ResourceGrid,
91
+ }
92
+
93
+ export const ListAndGridToggle = {
94
+ render: () => <ViewToggleDemo />,
95
+ }
@@ -1,5 +1,5 @@
1
1
  'use client'
2
- import React, { useRef, useEffect, useMemo, useCallback, memo } from 'react'
2
+ import React, { useRef, useEffect, useMemo, useCallback, memo, useState } from 'react'
3
3
  import { useSchemas } from './useSchemas.js'
4
4
  import {
5
5
  Dropdown,
@@ -13,6 +13,7 @@ import {
13
13
  List,
14
14
  } from '@ossy/design-system'
15
15
  import { formatBytes } from './utils/format-bytes.js'
16
+ import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
16
17
 
17
18
  function useSchemaMap() {
18
19
  const templates = useSchemas()
@@ -25,9 +26,16 @@ function useSchemaMap() {
25
26
  export const ResourceList = ({
26
27
  resources = [],
27
28
  onClick = () => {},
29
+ onToggleSelect,
28
30
  inlineFolder = null,
31
+ selectedIds,
32
+ showSelectionControls = false,
29
33
  }) => {
30
34
  const templateMap = useSchemaMap()
35
+ const selectedSet = useMemo(() => {
36
+ if (selectedIds instanceof Set) return selectedIds
37
+ return new Set(selectedIds || [])
38
+ }, [selectedIds])
31
39
 
32
40
  return (
33
41
  <List>
@@ -38,6 +46,9 @@ export const ResourceList = ({
38
46
  resource={resource}
39
47
  templateMap={templateMap}
40
48
  onItemClick={onClick}
49
+ onToggleSelect={onToggleSelect}
50
+ showSelectionControls={showSelectionControls}
51
+ selected={selectedSet.has(resource.id) || Boolean(resource.selected)}
41
52
  />
42
53
  ))}
43
54
  </List>
@@ -102,14 +113,43 @@ const ResourceListItem = memo(function ResourceListItem({
102
113
  resource,
103
114
  templateMap,
104
115
  onItemClick,
116
+ onToggleSelect,
117
+ showSelectionControls = false,
118
+ selected = false,
105
119
  }) {
106
120
  const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
121
+ const [menuOpen, setMenuOpen] = useState(false)
122
+ const [menuPosition, setMenuPosition] = useState(null)
107
123
 
108
- const handleClick = useCallback(() => {
109
- onItemClick(resource)
110
- onClick?.(resource)
124
+ const handleClick = useCallback((event) => {
125
+ if (event?.metaKey || event?.ctrlKey || event?.shiftKey) {
126
+ event.preventDefault?.()
127
+ }
128
+ onItemClick?.(resource, event)
129
+ onClick?.(resource, event)
111
130
  }, [onItemClick, onClick, resource])
112
131
 
132
+ const handleToggleSelect = useCallback((event) => {
133
+ onToggleSelect?.(resource, event)
134
+ }, [onToggleSelect, resource])
135
+
136
+ const handleContextMenu = useCallback((event) => {
137
+ if (!actions) return
138
+ event.preventDefault()
139
+ event.stopPropagation()
140
+ setMenuPosition({ top: event.clientY, left: event.clientX })
141
+ setMenuOpen(true)
142
+ }, [actions])
143
+
144
+ const handleMenuOpenChange = useCallback((open) => {
145
+ setMenuOpen(open)
146
+ if (!open) setMenuPosition(null)
147
+ }, [])
148
+
149
+ const handleActionsTriggerClick = useCallback(() => {
150
+ setMenuPosition(null)
151
+ }, [])
152
+
113
153
  let Container = ({ children }) => <>{children}</>
114
154
 
115
155
  if (onDrop || onFilesDrop) {
@@ -118,15 +158,37 @@ const ResourceListItem = memo(function ResourceListItem({
118
158
  Container = DropZone.Dragable
119
159
  }
120
160
 
161
+ const leading = (
162
+ <View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
163
+ {(showSelectionControls || selected) && (
164
+ <ResourceSelectionCheckbox
165
+ selected={selected}
166
+ onToggle={handleToggleSelect}
167
+ />
168
+ )}
169
+ <ResourceIcon type={type} content={content} templateMap={templateMap} />
170
+ </View>
171
+ )
172
+
121
173
  return (
122
174
  <Container onDrop={onDrop} onFilesDrop={onFilesDrop} dragData={dragData}>
123
175
  <List.Item
124
176
  href={href}
125
177
  onClick={handleClick}
178
+ onContextMenu={handleContextMenu}
126
179
  selectable
127
- leading={<ResourceIcon type={type} content={content} templateMap={templateMap} />}
180
+ selected={selected}
181
+ leading={leading}
128
182
  meta={<Size content={content} />}
129
- trailing={<RowActions actions={actions} />}
183
+ trailing={(
184
+ <RowActions
185
+ actions={actions}
186
+ open={menuOpen}
187
+ onOpenChange={handleMenuOpenChange}
188
+ position={menuPosition}
189
+ onTriggerClick={handleActionsTriggerClick}
190
+ />
191
+ )}
130
192
  data-resource-id={resource.id}
131
193
  >
132
194
  {typeof name === 'string' ? name : ''}
@@ -149,6 +211,9 @@ const ResourceIcon = memo(function ResourceIcon({ type, content, templateMap })
149
211
  <Image
150
212
  src={content?.sizes?.thumbnailSmall || content?.src}
151
213
  placeholderSrc={content?.sizes?.['loader-square-blurred-after'] || content?.src}
214
+ blurhash={content?.blurhash}
215
+ width={content?.width}
216
+ height={content?.height}
152
217
  style={{ width: '24px', height: '24px', borderRadius: '25%' }}
153
218
  />
154
219
  )
@@ -171,11 +236,16 @@ function Size({ content }) {
171
236
  return null
172
237
  }
173
238
 
174
- function RowActions({ actions }) {
239
+ function RowActions({ actions, open, onOpenChange, position, onTriggerClick }) {
175
240
  if (!actions) return null
176
241
 
177
242
  return (
178
- <Dropdown trigger={<Button prefix="more-vertical-alt" variant="command" /> } >
243
+ <Dropdown
244
+ trigger={<Button prefix="more-vertical-alt" variant="command" onClick={onTriggerClick} />}
245
+ open={open}
246
+ onOpenChange={onOpenChange}
247
+ position={position}
248
+ >
179
249
  <View inset="xs" surface="primary" roundness="s">
180
250
  <ContextMenu roundness="s" surface="primary">
181
251
  {actions}
@@ -1,8 +1,14 @@
1
1
  import React, { useState, useEffect, memo } from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
- import { removeResource, updateResourceContent, renameResource } from './resource.helpers.js'
3
+ import {
4
+ isDownloadableResource,
5
+ removeResource,
6
+ renameResource,
7
+ resourceDownloadHref,
8
+ updateResourceContent,
9
+ } from './resource.helpers.js'
4
10
  import { useSdk } from '@ossy/sdk-react'
5
- import { Switch, View, Text, Button, InputTitle, Alert, useInputValue } from '@ossy/design-system'
11
+ import { Switch, View, Text, Button, InputTitle, Alert, useInputValue, useLocale } from '@ossy/design-system'
6
12
  import { ResourceFactory } from './ResourceFactory.jsx'
7
13
  import { ResourceDetails } from './ResourceDetails.jsx'
8
14
  import { ResourceTags } from './ResourceTags.jsx'
@@ -32,6 +38,7 @@ export const ResourcePanel = memo(function ResourcePanel ({
32
38
  extraPanels = [],
33
39
  }) {
34
40
  const sdk = useSdk()
41
+ const { t } = useLocale()
35
42
  const [contentError, setContentError] = useState()
36
43
  const [resourceName, setResourceName] = useInputValue('')
37
44
  const [panelViewModes, setPanelViewModes] = useState(DefaultViewModes)
@@ -39,6 +46,7 @@ export const ResourcePanel = memo(function ResourcePanel ({
39
46
  const { data: resource = {} } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
40
47
  const template = useSchema(resource?.type)
41
48
  const engine = useSchemaEngine()
49
+ const downloadHref = isDownloadableResource(resource) ? resourceDownloadHref(resource) : null
42
50
 
43
51
  const form = useForm({ defaultData: resource.content })
44
52
 
@@ -171,6 +179,15 @@ export const ResourcePanel = memo(function ResourcePanel ({
171
179
  <Text as="h3" variant="breadcrumb">{resourceName}</Text>
172
180
  </View.Item>
173
181
 
182
+ {downloadHref && (
183
+ <Button
184
+ prefix="software-download"
185
+ variant="command"
186
+ href={downloadHref}
187
+ download={resource.name}
188
+ aria-label={t('resources.panel.download')}
189
+ />
190
+ )}
174
191
  <Button prefix="trash-empty" variant="command-danger" onClick={onRemoveResource}/>
175
192
  <Button prefix="pen" variant="command" onClick={() => setPanelViewModes(x => ({ ...x, 'panel-header': ViewMode.Edit }))} />
176
193
  { !!_onClose && (<Button prefix="close" variant="command" onClick={onCloseResource} /> ) }
@@ -0,0 +1,68 @@
1
+ import React from 'react'
2
+ import { View } from '@ossy/design-system'
3
+
4
+ /**
5
+ * Checkbox affordance for multi-select rows/tiles.
6
+ * Click toggles selection; does not navigate or open the resource panel.
7
+ */
8
+ export function ResourceSelectionCheckbox ({
9
+ selected = false,
10
+ onToggle,
11
+ 'aria-label': ariaLabel = 'Select',
12
+ }) {
13
+ return (
14
+ <View
15
+ role="checkbox"
16
+ aria-checked={selected}
17
+ aria-label={ariaLabel}
18
+ tabIndex={0}
19
+ onClick={(event) => {
20
+ event.preventDefault()
21
+ event.stopPropagation()
22
+ onToggle?.(event)
23
+ }}
24
+ onKeyDown={(event) => {
25
+ if (event.key !== 'Enter' && event.key !== ' ') return
26
+ event.preventDefault()
27
+ event.stopPropagation()
28
+ onToggle?.(event)
29
+ }}
30
+ style={{
31
+ boxSizing: 'border-box',
32
+ width: 18,
33
+ height: 18,
34
+ flexShrink: 0,
35
+ borderRadius: 4,
36
+ border: selected
37
+ ? '1px solid var(--foreground-active, var(--foreground))'
38
+ : '1px solid var(--separator, hsl(0, 0%, 50%))',
39
+ background: selected
40
+ ? 'var(--foreground-active, var(--foreground))'
41
+ : 'transparent',
42
+ display: 'inline-flex',
43
+ alignItems: 'center',
44
+ justifyContent: 'center',
45
+ cursor: 'pointer',
46
+ }}
47
+ >
48
+ {selected ? (
49
+ <svg
50
+ width="12"
51
+ height="12"
52
+ viewBox="0 0 12 12"
53
+ aria-hidden="true"
54
+ style={{ display: 'block' }}
55
+ >
56
+ <path
57
+ d="M2.5 6.2L4.8 8.5L9.5 3.5"
58
+ fill="none"
59
+ stroke="var(--background, Canvas)"
60
+ strokeWidth="1.75"
61
+ strokeLinecap="round"
62
+ strokeLinejoin="round"
63
+ />
64
+ </svg>
65
+ ) : null}
66
+ </View>
67
+ )
68
+ }
@@ -41,6 +41,9 @@ export const ResourcesPage= () => {
41
41
  href={router.getHref({ id: "@resource", params: { resourceId: resource?.id } })}
42
42
  src={resource?.content?.sizes?.galleryMedium || resource?.content?.src}
43
43
  placeholderSrc={resource?.content?.sizes?.['loader-square-blurred-after'] || resource?.content?.src}
44
+ blurhash={resource?.content?.blurhash}
45
+ width={resource?.content?.width}
46
+ height={resource?.content?.height}
44
47
  style={{ pageBreakInside: 'avoid', cursor: 'pointer' }}
45
48
  key={resource.id}
46
49
  />