@ossy/resources 3.3.0 → 3.4.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.
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.4.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.4.0",
29
+ "@ossy/schema": "^3.4.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": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
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
  )
@@ -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
  )
@@ -0,0 +1,281 @@
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
+
16
+ const TILE_WIDTH = 160
17
+
18
+ function useSchemaMap() {
19
+ const templates = useSchemas()
20
+ return useMemo(
21
+ () => templates.reduce((acc, template) => ({ ...acc, [template.id]: template }), {}),
22
+ [templates],
23
+ )
24
+ }
25
+
26
+ export const ResourceGrid = ({
27
+ resources = [],
28
+ onClick = () => {},
29
+ inlineFolder = null,
30
+ }) => {
31
+ const templateMap = useSchemaMap()
32
+
33
+ return (
34
+ <View
35
+ layout="row-wrap"
36
+ gap="m"
37
+ inset="m"
38
+ alignItems="stretch"
39
+ style={{ alignContent: 'flex-start' }}
40
+ >
41
+ {inlineFolder && <InlineFolderTile {...inlineFolder} />}
42
+ {resources.map(resource => (
43
+ <ResourceGridItem
44
+ key={resource.id}
45
+ resource={resource}
46
+ templateMap={templateMap}
47
+ onItemClick={onClick}
48
+ />
49
+ ))}
50
+ </View>
51
+ )
52
+ }
53
+
54
+ function InlineFolderTile({ name, onChange, onSave, onCancel }) {
55
+ const inputRef = useRef(null)
56
+ const handledRef = useRef(false)
57
+
58
+ useEffect(() => {
59
+ if (inputRef.current) {
60
+ inputRef.current.focus()
61
+ inputRef.current.select()
62
+ }
63
+ }, [])
64
+
65
+ const handleKeyDown = (event) => {
66
+ if (event.key === 'Enter') {
67
+ handledRef.current = true
68
+ onSave(event.target.value)
69
+ } else if (event.key === 'Escape') {
70
+ handledRef.current = true
71
+ onCancel()
72
+ }
73
+ }
74
+
75
+ const handleBlur = (event) => {
76
+ if (handledRef.current) return
77
+ onSave(event.target.value)
78
+ }
79
+
80
+ return (
81
+ <View
82
+ surface="primary"
83
+ roundness="m"
84
+ inset="m"
85
+ gap="s"
86
+ style={{
87
+ width: TILE_WIDTH,
88
+ border: '1px dashed var(--separator-primary)',
89
+ boxSizing: 'border-box',
90
+ }}
91
+ >
92
+ <View layout="row" alignItems="center" justifyContent="center" style={{ height: 96 }}>
93
+ <Icon size="m" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />
94
+ </View>
95
+ <input
96
+ ref={inputRef}
97
+ value={name}
98
+ onChange={event => onChange(event.target.value)}
99
+ onKeyDown={handleKeyDown}
100
+ onBlur={handleBlur}
101
+ style={{
102
+ width: '100%',
103
+ border: 'none',
104
+ background: 'transparent',
105
+ outline: 'none',
106
+ fontSize: 'var(--text-small-font-size, 14px)',
107
+ fontFamily: 'var(--text-default-font-family, sans-serif)',
108
+ fontWeight: 'var(--text-default-font-weight, 400)',
109
+ color: 'var(--text-default-color, CanvasText)',
110
+ padding: 0,
111
+ minWidth: 0,
112
+ boxSizing: 'border-box',
113
+ }}
114
+ />
115
+ </View>
116
+ )
117
+ }
118
+
119
+ const ResourceGridItem = memo(function ResourceGridItem({
120
+ resource,
121
+ templateMap,
122
+ onItemClick,
123
+ }) {
124
+ const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
125
+ const [menuOpen, setMenuOpen] = useState(false)
126
+ const [menuPosition, setMenuPosition] = useState(null)
127
+
128
+ const handleClick = useCallback(() => {
129
+ onItemClick(resource)
130
+ onClick?.(resource)
131
+ }, [onItemClick, onClick, resource])
132
+
133
+ const handleContextMenu = useCallback((event) => {
134
+ if (!actions) return
135
+ event.preventDefault()
136
+ event.stopPropagation()
137
+ setMenuPosition({ top: event.clientY, left: event.clientX })
138
+ setMenuOpen(true)
139
+ }, [actions])
140
+
141
+ const handleMenuOpenChange = useCallback((open) => {
142
+ setMenuOpen(open)
143
+ if (!open) setMenuPosition(null)
144
+ }, [])
145
+
146
+ const handleActionsTriggerClick = useCallback(() => {
147
+ setMenuPosition(null)
148
+ }, [])
149
+
150
+ let Container = ({ children }) => <>{children}</>
151
+
152
+ if (onDrop || onFilesDrop) {
153
+ Container = DropZone
154
+ } else if (dragData) {
155
+ Container = DropZone.Dragable
156
+ }
157
+
158
+ const tile = (
159
+ <View
160
+ as={href ? 'a' : 'div'}
161
+ href={href}
162
+ onClick={handleClick}
163
+ onContextMenu={handleContextMenu}
164
+ selectable
165
+ surface="primary"
166
+ roundness="m"
167
+ inset="m"
168
+ gap="s"
169
+ data-resource-id={resource.id}
170
+ style={{
171
+ width: TILE_WIDTH,
172
+ textDecoration: 'none',
173
+ color: 'inherit',
174
+ cursor: 'pointer',
175
+ border: '1px solid var(--separator-primary)',
176
+ boxSizing: 'border-box',
177
+ position: 'relative',
178
+ }}
179
+ >
180
+ {actions ? (
181
+ <View
182
+ style={{
183
+ position: 'absolute',
184
+ top: 'var(--space-xs, 4px)',
185
+ right: 'var(--space-xs, 4px)',
186
+ zIndex: 1,
187
+ }}
188
+ onClick={event => event.stopPropagation()}
189
+ >
190
+ <RowActions
191
+ actions={actions}
192
+ open={menuOpen}
193
+ onOpenChange={handleMenuOpenChange}
194
+ position={menuPosition}
195
+ onTriggerClick={handleActionsTriggerClick}
196
+ />
197
+ </View>
198
+ ) : null}
199
+ <View layout="row" alignItems="center" justifyContent="center" style={{ height: 96 }}>
200
+ <ResourceThumb type={type} content={content} templateMap={templateMap} />
201
+ </View>
202
+ <Text
203
+ variant="small"
204
+ style={{
205
+ fontWeight: 'bold',
206
+ overflow: 'hidden',
207
+ textOverflow: 'ellipsis',
208
+ whiteSpace: 'nowrap',
209
+ }}
210
+ >
211
+ {typeof name === 'string' ? name : ''}
212
+ </Text>
213
+ <Size content={content} />
214
+ </View>
215
+ )
216
+
217
+ return (
218
+ <Container onDrop={onDrop} onFilesDrop={onFilesDrop} dragData={dragData}>
219
+ {tile}
220
+ </Container>
221
+ )
222
+ })
223
+
224
+ const ResourceThumb = memo(function ResourceThumb({ type, content, templateMap }) {
225
+ if (type === '@ossy/platform/schema/directory' || type === 'directory') {
226
+ return (
227
+ <Icon size="l" name="folder" style={{ fill: 'hsl(0, 0%, 60%)' }} />
228
+ )
229
+ }
230
+
231
+ const mediaType = content?.ContentType || type
232
+ if (typeof mediaType === 'string' && mediaType.startsWith('image')) {
233
+ return (
234
+ <Image
235
+ src={content?.sizes?.thumbnailSmall || content?.src}
236
+ placeholderSrc={content?.sizes?.['loader-square-blurred-after'] || content?.src}
237
+ style={{
238
+ width: 96,
239
+ height: 96,
240
+ objectFit: 'cover',
241
+ borderRadius: 'var(--roundness-s, 8px)',
242
+ }}
243
+ />
244
+ )
245
+ }
246
+
247
+ return (
248
+ <Icon size="l" name={templateMap[type]?.icon || 'file'} style={{ fill: 'hsl(0, 0%, 80%)' }} />
249
+ )
250
+ })
251
+
252
+ function Size({ content }) {
253
+ if (content?.ContentLength) {
254
+ return (
255
+ <Text variant="small" style={{ color: 'var(--text-secondary-color, hsl(0, 0%, 50%))' }}>
256
+ {formatBytes(content.ContentLength)}
257
+ </Text>
258
+ )
259
+ }
260
+
261
+ return null
262
+ }
263
+
264
+ function RowActions({ actions, open, onOpenChange, position, onTriggerClick }) {
265
+ if (!actions) return null
266
+
267
+ return (
268
+ <Dropdown
269
+ trigger={<Button prefix="more-vertical-alt" variant="command" onClick={onTriggerClick} />}
270
+ open={open}
271
+ onOpenChange={onOpenChange}
272
+ position={position}
273
+ >
274
+ <View inset="xs" surface="primary" roundness="s">
275
+ <ContextMenu roundness="s" surface="primary">
276
+ {actions}
277
+ </ContextMenu>
278
+ </View>
279
+ </Dropdown>
280
+ )
281
+ }
@@ -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,
@@ -104,12 +104,31 @@ const ResourceListItem = memo(function ResourceListItem({
104
104
  onItemClick,
105
105
  }) {
106
106
  const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
107
+ const [menuOpen, setMenuOpen] = useState(false)
108
+ const [menuPosition, setMenuPosition] = useState(null)
107
109
 
108
110
  const handleClick = useCallback(() => {
109
111
  onItemClick(resource)
110
112
  onClick?.(resource)
111
113
  }, [onItemClick, onClick, resource])
112
114
 
115
+ const handleContextMenu = useCallback((event) => {
116
+ if (!actions) return
117
+ event.preventDefault()
118
+ event.stopPropagation()
119
+ setMenuPosition({ top: event.clientY, left: event.clientX })
120
+ setMenuOpen(true)
121
+ }, [actions])
122
+
123
+ const handleMenuOpenChange = useCallback((open) => {
124
+ setMenuOpen(open)
125
+ if (!open) setMenuPosition(null)
126
+ }, [])
127
+
128
+ const handleActionsTriggerClick = useCallback(() => {
129
+ setMenuPosition(null)
130
+ }, [])
131
+
113
132
  let Container = ({ children }) => <>{children}</>
114
133
 
115
134
  if (onDrop || onFilesDrop) {
@@ -123,10 +142,19 @@ const ResourceListItem = memo(function ResourceListItem({
123
142
  <List.Item
124
143
  href={href}
125
144
  onClick={handleClick}
145
+ onContextMenu={handleContextMenu}
126
146
  selectable
127
147
  leading={<ResourceIcon type={type} content={content} templateMap={templateMap} />}
128
148
  meta={<Size content={content} />}
129
- trailing={<RowActions actions={actions} />}
149
+ trailing={(
150
+ <RowActions
151
+ actions={actions}
152
+ open={menuOpen}
153
+ onOpenChange={handleMenuOpenChange}
154
+ position={menuPosition}
155
+ onTriggerClick={handleActionsTriggerClick}
156
+ />
157
+ )}
130
158
  data-resource-id={resource.id}
131
159
  >
132
160
  {typeof name === 'string' ? name : ''}
@@ -149,6 +177,9 @@ const ResourceIcon = memo(function ResourceIcon({ type, content, templateMap })
149
177
  <Image
150
178
  src={content?.sizes?.thumbnailSmall || content?.src}
151
179
  placeholderSrc={content?.sizes?.['loader-square-blurred-after'] || content?.src}
180
+ blurhash={content?.blurhash}
181
+ width={content?.width}
182
+ height={content?.height}
152
183
  style={{ width: '24px', height: '24px', borderRadius: '25%' }}
153
184
  />
154
185
  )
@@ -171,11 +202,16 @@ function Size({ content }) {
171
202
  return null
172
203
  }
173
204
 
174
- function RowActions({ actions }) {
205
+ function RowActions({ actions, open, onOpenChange, position, onTriggerClick }) {
175
206
  if (!actions) return null
176
207
 
177
208
  return (
178
- <Dropdown trigger={<Button prefix="more-vertical-alt" variant="command" /> } >
209
+ <Dropdown
210
+ trigger={<Button prefix="more-vertical-alt" variant="command" onClick={onTriggerClick} />}
211
+ open={open}
212
+ onOpenChange={onOpenChange}
213
+ position={position}
214
+ >
179
215
  <View inset="xs" surface="primary" roundness="s">
180
216
  <ContextMenu roundness="s" surface="primary">
181
217
  {actions}
@@ -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
  />
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ export { metadata as ListResources } from './list.action.js'
4
4
  export { metadata as SearchResources } from './search.action.js'
5
5
  export { metadata as DeleteResource } from './delete.action.js'
6
6
  export { metadata as UpdateResourceContent } from './update-content.action.js'
7
+ export { metadata as PatchResourceContent } from './patch-content.action.js'
7
8
  export { metadata as UpdateResourceLocation } from './update-location.action.js'
8
9
  export { metadata as UpdateResourceName } from './update-name.action.js'
9
10
  export { metadata as UpdateResourceAccess } from './update-access.action.js'
@@ -28,6 +29,7 @@ export * from './GenericResourceForm.jsx'
28
29
  export * from './GenericResourceCard.jsx'
29
30
  export * from './ResourceGenericView.jsx'
30
31
  export * from './ResourceList.jsx'
32
+ export * from './ResourceGrid.jsx'
31
33
  export * from './ResourcePage.jsx'
32
34
  export * from './ResourcePanel.jsx'
33
35
  export * from './ResourceTags.jsx'
@@ -0,0 +1 @@
1
+ export const metadata = { id: '@ossy/resources/actions/patch-content', access: 'workspace' }
@@ -0,0 +1,28 @@
1
+ import { ResourcesEvents } from './resources.events.js'
2
+ import { mutateResource } from './resource-stream.helpers.js'
3
+ import { resolveResourceId } from './resource-read.helpers.js'
4
+ import { mediaContextFromRun, withResourceMedia } from './resources.action-media.js'
5
+
6
+ export const metadata = { id: '@ossy/resources/tasks/patch-content' }
7
+
8
+ /**
9
+ * Deep-merge patch into resource `content` (ADR 0008 `Patched`).
10
+ * Prefer this over update-content when adding fields like blurhash/sizes
11
+ * without replacing the full content document.
12
+ */
13
+ export async function run ({ payload, req }) {
14
+ const createdBy = payload?.userId ?? req?.userId
15
+ const resourceId = resolveResourceId({ payload, req })
16
+ const content = payload?.content
17
+
18
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
19
+ if (!content || typeof content !== 'object' || Array.isArray(content)) {
20
+ throw Object.assign(new Error('content is required'), { status: 400 })
21
+ }
22
+
23
+ const resource = await mutateResource(
24
+ resourceId,
25
+ ResourcesEvents.Patched({ createdBy, content }),
26
+ )
27
+ return withResourceMedia(resource, mediaContextFromRun({ payload, req }))
28
+ }