@ossy/resources 3.8.0 → 3.9.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.8.0",
4
+ "version": "3.9.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "dependencies": {
26
26
  "@ossy/event-store": "^3.8.0",
27
27
  "@ossy/fold": "^3.4.0",
28
- "@ossy/platform": "^3.8.0",
28
+ "@ossy/platform": "^3.9.0",
29
29
  "@ossy/schema": "^3.8.0"
30
30
  },
31
31
  "peerDependencies": {
@@ -49,5 +49,5 @@
49
49
  "/src",
50
50
  "README.md"
51
51
  ],
52
- "gitHead": "14548dfc2019e7258e8c75db527acbf66fe829bd"
52
+ "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
53
53
  }
@@ -8,12 +8,13 @@ export const metadata = {
8
8
  sv: '/resources/create/mapp',
9
9
  en: '/resources/create/directory',
10
10
  },
11
+ layout: '@ossy/app/layout/blank',
11
12
  }
12
13
 
13
14
 
14
15
  export const CreateDirectoryPage = () => {
15
16
  return (
16
- <View layout="off-center-s" style={{ height: '100%' }}>
17
+ <View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
17
18
  <View data-region="content" surface="primary" roundness="m" inset="m">
18
19
  <CreateDirectory/>
19
20
  </View>
@@ -94,13 +94,13 @@ export const CreateDocument = () => {
94
94
 
95
95
  return (
96
96
  <>
97
+ {error && <Alert variant="danger">{error}</Alert>}
97
98
  <GenericResourceForm
98
99
  schemaId={schemaId}
99
100
  mode="create"
100
101
  onSubmit={onSubmit}
101
102
  onCancel={onDone}
102
103
  />
103
- {error && <Alert>{error}</Alert>}
104
104
  </>
105
105
  )
106
106
  }
@@ -8,12 +8,13 @@ export const metadata = {
8
8
  sv: '/resources/create/dokument',
9
9
  en: '/resources/create/document',
10
10
  },
11
+ layout: '@ossy/app/layout/blank',
11
12
  }
12
13
 
13
14
 
14
15
  export const CreateDocumentPage = () => {
15
16
  return (
16
- <View layout="off-center-m" style={{ height: '100%' }}>
17
+ <View surface="base" layout="off-center-m" inset="s" style={{ minHeight: '100dvh' }}>
17
18
  <View data-region="content" surface='primary' roundness='m' inset="m">
18
19
  <CreateDocument />
19
20
  </View>
@@ -33,27 +33,33 @@ function ResourceCreateFormFallback ({
33
33
  const [content, setContent] = useState(initialContent)
34
34
  const [error, setError] = useState()
35
35
 
36
- const handleSubmit = () => {
37
- if (!name?.trim()) {
36
+ const handleSubmit = (event) => {
37
+ event?.preventDefault?.()
38
+ const title = name?.trim() || (typeof content?.name === 'string' ? content.name.trim() : '')
39
+ if (!title) {
38
40
  setError(t('resources.form.nameRequired'))
39
41
  return
40
42
  }
41
- if (template) {
42
- const result = validateContent(engine, template, content)
43
- if (!result.ok) {
44
- setError(result.message)
43
+ try {
44
+ if (template) {
45
+ const result = validateContent(engine, template, content)
46
+ if (!result.ok) {
47
+ setError(result.message)
48
+ return
49
+ }
50
+ setError(undefined)
51
+ onSubmit?.({ name: title, content: result.data, type: schemaId })
45
52
  return
46
53
  }
47
54
  setError(undefined)
48
- onSubmit?.({ name: name.trim(), content: result.data, type: schemaId })
49
- return
55
+ onSubmit?.({ name: title, content, type: schemaId })
56
+ } catch (err) {
57
+ setError(err?.message || t('resources.form.createFailed'))
50
58
  }
51
- setError(undefined)
52
- onSubmit?.({ name: name.trim(), content, type: schemaId })
53
59
  }
54
60
 
55
61
  return (
56
- <View stack gap="m">
62
+ <View as="form" stack gap="m" onSubmit={handleSubmit}>
57
63
  <InputTitle
58
64
  id="resource-name"
59
65
  type="text"
@@ -73,7 +79,7 @@ function ResourceCreateFormFallback ({
73
79
  {error && <Alert variant="danger">{error}</Alert>}
74
80
  <View layout="row" gap="s" justifyContent="flex-end">
75
81
  {onCancel && <Button variant="link" onClick={onCancel} label="design-system.cancel" />}
76
- <Button variant="cta" onClick={handleSubmit}>
82
+ <Button type="submit" variant="cta">
77
83
  {submitLabel || (template?.name ? `${t('design-system.add')} ${template.name}` : t('resources.form.createResource'))}
78
84
  </Button>
79
85
  </View>
@@ -1,6 +1,5 @@
1
- import React, { useState } from 'react'
1
+ import React from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
- import { updateResourceAccess } from './resource.helpers.js'
4
3
  import { useSdk } from '@ossy/sdk-react'
5
4
  import { useSchema } from './useSchemas.js'
6
5
  import { Text, View } from '@ossy/design-system'
@@ -10,8 +9,6 @@ export const ResourceDetails = ({ resourceId }) => {
10
9
  const sdk = useSdk()
11
10
  const { data: resource } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
12
11
  const template = useSchema(resource?.type)
13
- const [accessBusy, setAccessBusy] = useState(false)
14
- const [accessError, setAccessError] = useState(null)
15
12
 
16
13
  const details = {
17
14
  Id: resource?.id,
@@ -22,19 +19,6 @@ export const ResourceDetails = ({ resourceId }) => {
22
19
  Created: resource?.created && new Date(resource?.created).toLocaleString(),
23
20
  }
24
21
 
25
- const showAccessToggle = resource?.id
26
- && resource?.type !== '@ossy/platform/schema/directory'
27
- && resource?.type !== 'directory'
28
-
29
- const onAccessChange = (event) => {
30
- const next = event.target.checked ? 'public' : 'restricted'
31
- setAccessError(null)
32
- setAccessBusy(true)
33
- updateResourceAccess(sdk, { id: resourceId, access: next })
34
- .catch(() => setAccessError('Could not update access'))
35
- .finally(() => setAccessBusy(false))
36
- }
37
-
38
22
  return (
39
23
  <View gap="s" style={{ maxWidth: '320px' }}>
40
24
  {
@@ -48,32 +32,6 @@ export const ResourceDetails = ({ resourceId }) => {
48
32
  </View>
49
33
  ))
50
34
  }
51
- {showAccessToggle && (
52
- <View gap="xs" style={{ paddingTop: 'var(--space-s)' }}>
53
- <View layout="row" gap="m" alignItems="center">
54
- <Text variant="small" style={{ fontWeight: 'bold', flexShrink: 0 }}>
55
- Public media link
56
- </Text>
57
- <span style={{ flexGrow: 1 }} />
58
- <input
59
- type="checkbox"
60
- checked={(resource?.access || 'restricted') === 'public'}
61
- disabled={accessBusy}
62
- onChange={onAccessChange}
63
- aria-label="Allow anyone with the link to view media"
64
- />
65
- </View>
66
- <Text variant="small" style={{ opacity: 0.85, lineHeight: 1.35 }}>
67
- When on, image and file URLs use the CDN and work without signing in.
68
- When off, only workspace members get time-limited download links.
69
- </Text>
70
- {accessError && (
71
- <Text variant="small" style={{ color: 'var(--palette-danger, #c00)' }}>
72
- {accessError}
73
- </Text>
74
- )}
75
- </View>
76
- )}
77
35
  </View>
78
36
  )
79
37
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from '@ossy/design-system'
14
14
  import { formatBytes } from './utils/format-bytes.js'
15
15
  import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
16
+ import { resourceAccessIcon, isShareableResource } from './resource-share.js'
16
17
 
17
18
  const TILE_WIDTH = 160
18
19
 
@@ -189,6 +190,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
189
190
  inset="m"
190
191
  gap="s"
191
192
  data-resource-id={resource.id}
193
+ data-resource-access={resource.access || 'restricted'}
192
194
  style={{
193
195
  width: TILE_WIDTH,
194
196
  textDecoration: 'none',
@@ -217,6 +219,23 @@ const ResourceGridItem = memo(function ResourceGridItem({
217
219
  />
218
220
  </View>
219
221
  )}
222
+ {isShareableResource(resource) && (
223
+ <View
224
+ style={{
225
+ position: 'absolute',
226
+ top: 'var(--space-xs, 4px)',
227
+ left: showSelectionControls ? 'calc(var(--space-xs, 4px) + 28px)' : 'var(--space-xs, 4px)',
228
+ zIndex: 1,
229
+ }}
230
+ aria-hidden="true"
231
+ >
232
+ <Icon
233
+ size="s"
234
+ name={resourceAccessIcon(resource)}
235
+ style={{ fill: 'hsl(0, 0%, 55%)' }}
236
+ />
237
+ </View>
238
+ )}
220
239
  {actions ? (
221
240
  <View
222
241
  style={{
@@ -14,6 +14,7 @@ import {
14
14
  } from '@ossy/design-system'
15
15
  import { formatBytes } from './utils/format-bytes.js'
16
16
  import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
17
+ import { resourceAccessIcon, isShareableResource } from './resource-share.js'
17
18
 
18
19
  function useSchemaMap() {
19
20
  const templates = useSchemas()
@@ -179,7 +180,19 @@ const ResourceListItem = memo(function ResourceListItem({
179
180
  selectable
180
181
  selected={selected}
181
182
  leading={leading}
182
- meta={<Size content={content} />}
183
+ meta={(
184
+ <View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
185
+ {isShareableResource(resource) && (
186
+ <Icon
187
+ size="s"
188
+ name={resourceAccessIcon(resource)}
189
+ aria-hidden="true"
190
+ style={{ fill: 'hsl(0, 0%, 55%)', flexShrink: 0 }}
191
+ />
192
+ )}
193
+ <Size content={content} />
194
+ </View>
195
+ )}
183
196
  trailing={(
184
197
  <RowActions
185
198
  actions={actions}
@@ -190,6 +203,7 @@ const ResourceListItem = memo(function ResourceListItem({
190
203
  />
191
204
  )}
192
205
  data-resource-id={resource.id}
206
+ data-resource-access={resource.access || 'restricted'}
193
207
  >
194
208
  {typeof name === 'string' ? name : ''}
195
209
  </List.Item>
@@ -5,18 +5,36 @@ import {
5
5
  removeResource,
6
6
  renameResource,
7
7
  resourceDownloadHref,
8
+ updateResourceAccess,
8
9
  updateResourceContent,
9
10
  } from './resource.helpers.js'
10
11
  import { useSdk } from '@ossy/sdk-react'
11
- import { Switch, View, Text, Button, InputTitle, Alert, useInputValue, useLocale } from '@ossy/design-system'
12
+ import {
13
+ Switch,
14
+ View,
15
+ Text,
16
+ Button,
17
+ InputTitle,
18
+ Alert,
19
+ useInputValue,
20
+ useLocale,
21
+ Dropdown,
22
+ ContextMenu,
23
+ } from '@ossy/design-system'
12
24
  import { ResourceFactory } from './ResourceFactory.jsx'
13
25
  import { ResourceDetails } from './ResourceDetails.jsx'
14
26
  import { ResourceTags } from './ResourceTags.jsx'
15
27
  import { ResourceDescription } from './ResourceDescription.jsx'
28
+ import { ResourceShareDialog } from './ResourceShareDialog.jsx'
16
29
  import { useForm } from './useForm.js'
17
30
  import { useSchema } from './useSchemas.js'
18
31
  import { useSchemaEngine } from './useSchemaEngine.js'
19
32
  import { validateContent } from '@ossy/schema'
33
+ import {
34
+ isShareableResource,
35
+ resourceAccessIcon,
36
+ resourceAccessOf,
37
+ } from './resource-share.js'
20
38
 
21
39
  const ViewMode = {
22
40
  Closed: 'Closed',
@@ -42,14 +60,28 @@ export const ResourcePanel = memo(function ResourcePanel ({
42
60
  const [contentError, setContentError] = useState()
43
61
  const [resourceName, setResourceName] = useInputValue('')
44
62
  const [panelViewModes, setPanelViewModes] = useState(DefaultViewModes)
63
+ const [accessBusy, setAccessBusy] = useState(false)
64
+ const [accessError, setAccessError] = useState(null)
65
+ const [shareOpen, setShareOpen] = useState(false)
45
66
 
46
67
  const { data: resource = {} } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
47
68
  const template = useSchema(resource?.type)
48
69
  const engine = useSchemaEngine()
49
70
  const downloadHref = isDownloadableResource(resource) ? resourceDownloadHref(resource) : null
71
+ const shareable = isShareableResource(resource)
72
+ const access = resourceAccessOf(resource)
50
73
 
51
74
  const form = useForm({ defaultData: resource.content })
52
75
 
76
+ const onVisibilityChange = (nextAccess) => {
77
+ if (!resourceId || accessBusy || nextAccess === access) return
78
+ setAccessError(null)
79
+ setAccessBusy(true)
80
+ updateResourceAccess(sdk, { id: resourceId, access: nextAccess })
81
+ .catch(() => setAccessError(t('resources.share.accessError')))
82
+ .finally(() => setAccessBusy(false))
83
+ }
84
+
53
85
  const onCloseResource = () => {
54
86
  if (!_onClose) return
55
87
  setPanelViewModes(DefaultViewModes)
@@ -179,6 +211,43 @@ export const ResourcePanel = memo(function ResourcePanel ({
179
211
  <Text as="h3" variant="small" style={{ fontWeight: 'bold' }}>{resourceName}</Text>
180
212
  </View.Item>
181
213
 
214
+ {shareable && (
215
+ <Dropdown
216
+ trigger={(
217
+ <Button
218
+ prefix={resourceAccessIcon(resource)}
219
+ variant="command"
220
+ disabled={accessBusy}
221
+ aria-label={t('resources.share.visibility')}
222
+ title={t(`resources.share.access.${access}`)}
223
+ />
224
+ )}
225
+ >
226
+ <View inset="xs" surface="primary" roundness="s">
227
+ <ContextMenu roundness="s" surface="primary">
228
+ {['public', 'workspace', 'restricted'].map((value) => (
229
+ <ContextMenu.Item
230
+ key={value}
231
+ prefix={value === 'public' ? 'globe' : 'lock'}
232
+ suffix={access === value ? 'check' : undefined}
233
+ disabled={accessBusy}
234
+ onClick={() => onVisibilityChange(value)}
235
+ >
236
+ {t(`resources.share.access.${value}`)}
237
+ </ContextMenu.Item>
238
+ ))}
239
+ </ContextMenu>
240
+ </View>
241
+ </Dropdown>
242
+ )}
243
+ {shareable && (
244
+ <Button
245
+ prefix="share"
246
+ variant="command"
247
+ aria-label={t('resources.share.title')}
248
+ onClick={() => setShareOpen(true)}
249
+ />
250
+ )}
182
251
  {downloadHref && (
183
252
  <Button
184
253
  prefix="software-download"
@@ -263,6 +332,18 @@ export const ResourcePanel = memo(function ResourcePanel ({
263
332
 
264
333
  </View.Item>
265
334
 
335
+ {accessError && (
336
+ <View.Item>
337
+ <Alert variant="danger">{accessError}</Alert>
338
+ </View.Item>
339
+ )}
340
+
341
+ <ResourceShareDialog
342
+ resourceId={resourceId}
343
+ isVisible={shareOpen}
344
+ onClose={() => setShareOpen(false)}
345
+ />
346
+
266
347
  </View>
267
348
  )
268
349
  })
@@ -0,0 +1,183 @@
1
+ 'use client'
2
+ import React, { useEffect, useState } from 'react'
3
+ import { Overlay, View, Text, Button, Guide, useLocale } from '@ossy/design-system'
4
+ import { AsyncStatus, useSdk } from '@ossy/sdk-react'
5
+ import { metadata as GetResource } from './get.action.js'
6
+ import { updateResourceAccess } from './resource.helpers.js'
7
+ import {
8
+ getResourceShareUrl,
9
+ isPublicResource,
10
+ isShareableResource,
11
+ } from './resource-share.js'
12
+
13
+ /**
14
+ * Confirm making a resource public and copy its share link.
15
+ *
16
+ * @param {{
17
+ * resourceId?: string | null,
18
+ * isVisible?: boolean,
19
+ * onClose?: () => void,
20
+ * }} props
21
+ */
22
+ export function ResourceShareDialog ({
23
+ resourceId,
24
+ isVisible = false,
25
+ onClose = () => {},
26
+ }) {
27
+ const sdk = useSdk()
28
+ const { t } = useLocale()
29
+ const { status, data: resource = {} } = sdk.read(
30
+ GetResource,
31
+ { resourceId },
32
+ { enabled: Boolean(resourceId) && isVisible },
33
+ )
34
+
35
+ const [busy, setBusy] = useState(false)
36
+ const [error, setError] = useState(null)
37
+ const [linkCopied, setLinkCopied] = useState(false)
38
+ const [madePublic, setMadePublic] = useState(false)
39
+
40
+ const loading = status !== AsyncStatus.Success && status !== AsyncStatus.Error
41
+ const shareable = status === AsyncStatus.Success && isShareableResource(resource)
42
+ const isPublic = madePublic || isPublicResource(resource)
43
+ const shareUrl = getResourceShareUrl(resourceId || resource)
44
+
45
+ useEffect(() => {
46
+ if (!isVisible) {
47
+ setBusy(false)
48
+ setError(null)
49
+ setLinkCopied(false)
50
+ setMadePublic(false)
51
+ }
52
+ }, [isVisible])
53
+
54
+ const copyLink = async (url) => {
55
+ if (!url) return
56
+ try {
57
+ await navigator.clipboard.writeText(url)
58
+ setLinkCopied(true)
59
+ setTimeout(() => setLinkCopied(false), 2000)
60
+ } catch {
61
+ setError(t('resources.share.copyFailed'))
62
+ }
63
+ }
64
+
65
+ const onConfirmShare = async () => {
66
+ if (!resourceId || busy) return
67
+ setError(null)
68
+ setBusy(true)
69
+ try {
70
+ if (!isPublic) {
71
+ await updateResourceAccess(sdk, { id: resourceId, access: 'public' })
72
+ setMadePublic(true)
73
+ }
74
+ const url = getResourceShareUrl(resourceId)
75
+ await copyLink(url)
76
+ } catch {
77
+ setError(t('resources.share.publishError'))
78
+ } finally {
79
+ setBusy(false)
80
+ }
81
+ }
82
+
83
+ if (!isVisible || !resourceId) return null
84
+
85
+ return (
86
+ <Overlay isVisible={true} onClose={onClose}>
87
+ <View layout="off-center-s" style={{ height: '100%' }}>
88
+ <View data-region="content" style={{ width: 'min(420px, 92vw)' }}>
89
+ <View surface="primary" roundness="s">
90
+ <View surface="primary" inset="l" roundness="s" gap="m">
91
+ {loading ? (
92
+ <Guide
93
+ title={t('resources.share.title')}
94
+ text={t('resources.share.sharing')}
95
+ actions={[
96
+ {
97
+ label: t('resources.share.close'),
98
+ variant: 'command',
99
+ onClick: onClose,
100
+ },
101
+ ]}
102
+ />
103
+ ) : !shareable ? (
104
+ <Guide
105
+ title={t('resources.share.title')}
106
+ text={t('resources.share.notShareable')}
107
+ actions={[
108
+ {
109
+ label: t('resources.share.close'),
110
+ variant: 'command',
111
+ onClick: onClose,
112
+ },
113
+ ]}
114
+ />
115
+ ) : (
116
+ <>
117
+ <Guide
118
+ title={t('resources.share.title')}
119
+ text={isPublic
120
+ ? t('resources.share.alreadyPublic')
121
+ : t('resources.share.confirmPublic')}
122
+ />
123
+
124
+ {shareUrl && isPublic && (
125
+ <View gap="xs">
126
+ <Text
127
+ variant="small"
128
+ style={{
129
+ wordBreak: 'break-all',
130
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
131
+ }}
132
+ >
133
+ {shareUrl}
134
+ </Text>
135
+ </View>
136
+ )}
137
+
138
+ {error && (
139
+ <Text
140
+ variant="small"
141
+ style={{ color: 'var(--palette-danger, #c00)' }}
142
+ >
143
+ {error}
144
+ </Text>
145
+ )}
146
+
147
+ <View layout="row" gap="s" style={{ justifyContent: 'flex-end', flexWrap: 'wrap' }}>
148
+ <Button variant="command" onClick={onClose} disabled={busy}>
149
+ {t('resources.share.close')}
150
+ </Button>
151
+ {isPublic ? (
152
+ <Button
153
+ variant="cta"
154
+ prefix="link"
155
+ disabled={busy || !shareUrl}
156
+ onClick={() => copyLink(shareUrl)}
157
+ >
158
+ {linkCopied
159
+ ? t('resources.share.linkCopied')
160
+ : t('resources.share.copyLink')}
161
+ </Button>
162
+ ) : (
163
+ <Button
164
+ variant="cta"
165
+ prefix="share"
166
+ disabled={busy || !resource?.id}
167
+ onClick={onConfirmShare}
168
+ >
169
+ {busy
170
+ ? t('resources.share.sharing')
171
+ : t('resources.share.makePublic')}
172
+ </Button>
173
+ )}
174
+ </View>
175
+ </>
176
+ )}
177
+ </View>
178
+ </View>
179
+ </View>
180
+ </View>
181
+ </Overlay>
182
+ )
183
+ }
@@ -8,12 +8,13 @@ export const metadata = {
8
8
  sv: '/resources/ladda-upp',
9
9
  en: '/resources/upload',
10
10
  },
11
+ layout: '@ossy/app/layout/blank',
11
12
  }
12
13
 
13
14
 
14
15
  export const UploadPage = () => {
15
16
  return (
16
- <View layout="off-center-s" style={{ height: '100%' }}>
17
+ <View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
17
18
  <View data-region="content" surface="primary" roundness="m" inset="m">
18
19
  <UploadResources/>
19
20
  </View>
@@ -76,3 +76,22 @@ export function safeReturnTo (value) {
76
76
  if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null
77
77
  return trimmed
78
78
  }
79
+
80
+ /**
81
+ * Path + search from a router href or window URL, if it is a safe returnTo.
82
+ * @param {unknown} href
83
+ * @returns {string | null}
84
+ */
85
+ export function pathFromHref (href) {
86
+ if (typeof href !== 'string' || !href) return null
87
+ const trimmed = href.trim()
88
+ if (trimmed.startsWith('//')) return null
89
+ try {
90
+ const url = /^[a-z][a-z0-9+.-]*:/i.test(trimmed)
91
+ ? new URL(trimmed)
92
+ : new URL(trimmed, 'http://ossy.invalid')
93
+ return safeReturnTo(`${url.pathname}${url.search}`)
94
+ } catch {
95
+ return safeReturnTo(trimmed)
96
+ }
97
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from '@jest/globals'
2
- import { isGenericCreateSchema, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
2
+ import { isGenericCreateSchema, pathFromHref, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
3
3
 
4
4
  describe('document-create helpers', () => {
5
5
  it('excludes platform schemas and field-less types', () => {
@@ -65,7 +65,15 @@ describe('document-create helpers', () => {
65
65
 
66
66
  it('only allows same-origin relative returnTo paths', () => {
67
67
  expect(safeReturnTo('/forecasts')).toBe('/forecasts')
68
+ expect(safeReturnTo('/en/forecasts?x=1')).toBe('/en/forecasts?x=1')
68
69
  expect(safeReturnTo('//evil.example')).toBe(null)
69
70
  expect(safeReturnTo('https://evil.example')).toBe(null)
70
71
  })
72
+
73
+ it('extracts a safe returnTo from a router href', () => {
74
+ expect(pathFromHref('http://localhost:3006/en/forecasts')).toBe('/en/forecasts')
75
+ expect(pathFromHref('/sv/prognoser')).toBe('/sv/prognoser')
76
+ expect(pathFromHref('https://evil.example/en/forecasts')).toBe('/en/forecasts')
77
+ expect(pathFromHref('//evil.example')).toBe(null)
78
+ })
71
79
  })
@@ -50,6 +50,22 @@
50
50
  "resources.createDirectory.errorEmpty": "Directory name cannot be empty",
51
51
  "resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy",
52
52
  "resources.panel.download": "Download",
53
+ "resources.share.title": "Share link",
54
+ "resources.share.confirmPublic": "Make this resource public so anyone with the link can open it. You can copy the link after confirming.",
55
+ "resources.share.alreadyPublic": "This resource is public. Copy the link below to share it.",
56
+ "resources.share.makePublic": "Make public & copy link",
57
+ "resources.share.copyLink": "Copy link",
58
+ "resources.share.linkCopied": "Link copied",
59
+ "resources.share.copyFailed": "Could not copy the link",
60
+ "resources.share.publishError": "Could not update sharing settings",
61
+ "resources.share.accessError": "Could not update visibility",
62
+ "resources.share.sharing": "Sharing…",
63
+ "resources.share.close": "Close",
64
+ "resources.share.notShareable": "Folders cannot be shared with a public link.",
65
+ "resources.share.visibility": "Visibility",
66
+ "resources.share.access.public": "Public",
67
+ "resources.share.access.workspace": "Workspace",
68
+ "resources.share.access.restricted": "Private",
53
69
  "@ossy/resources/schema/markdown.body.label": "Body",
54
70
  "@ossy/resources/schema/markdown.body.description": "Markdown content (headings, lists, links, code)."
55
71
  }
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ export * from './ResourceContentPage.jsx'
23
23
  export * from './ResourceDescription.jsx'
24
24
  export * from './ResourceDetails.jsx'
25
25
  export * from './ResourceDialogMove.jsx'
26
+ export * from './ResourceShareDialog.jsx'
26
27
  export * from './ResourceFactory.jsx'
27
28
  export * from './GenericResourceDetail.jsx'
28
29
  export * from './GenericResourceForm.jsx'
@@ -4,6 +4,7 @@ import { useSdk } from '@ossy/sdk-react'
4
4
  import { View, useLocale } from '@ossy/design-system'
5
5
  import { useRouter } from '@ossy/router-react'
6
6
  import { GenericResourceForm } from './GenericResourceForm.jsx'
7
+ import { safeReturnTo } from './document-create.helpers.js'
7
8
 
8
9
  export const metadata = {
9
10
  id: 'resources/create/generic',
@@ -11,6 +12,7 @@ export const metadata = {
11
12
  en: '/resources/create/:schemaId',
12
13
  sv: '/resources/skapa/:schemaId',
13
14
  },
15
+ layout: '@ossy/app/layout/blank',
14
16
  }
15
17
 
16
18
  /** Generic resource create page — slot lookup with template Fields fallback. */
@@ -19,10 +21,15 @@ export default function GenericResourceCreatePage () {
19
21
  const { t } = useLocale()
20
22
  const schemaId = decodeURIComponent(router.params.schemaId || '')
21
23
  const location = router.searchParams.location
24
+ const returnTo = safeReturnTo(router.searchParams.returnTo)
22
25
  const sdk = useSdk()
23
26
  const [error, setError] = useState()
24
27
 
25
28
  const onCancel = () => {
29
+ if (returnTo) {
30
+ router.navigate(returnTo)
31
+ return
32
+ }
26
33
  const search = location ? new URLSearchParams({ location }).toString() : ''
27
34
  router.navigate(`@storage/home${search ? `?${search}` : ''}`)
28
35
  }
@@ -34,7 +41,7 @@ export default function GenericResourceCreatePage () {
34
41
  }
35
42
 
36
43
  return (
37
- <View layout="off-center-m" style={{ height: '100%' }}>
44
+ <View surface="base" layout="off-center-m" inset="s" style={{ minHeight: '100dvh' }}>
38
45
  <View data-region="content" surface="primary" roundness="m" inset="m">
39
46
  <GenericResourceForm
40
47
  schemaId={schemaId}
@@ -16,7 +16,7 @@ export default function ResourceDetailPage () {
16
16
  const resourceId = router.params.resourceId
17
17
 
18
18
  return (
19
- <View layout="off-center-m" style={{ height: '100%' }}>
19
+ <View layout="off-center-l" style={{ height: '100%' }}>
20
20
  <View data-region="content" surface="primary" roundness="m" inset="m">
21
21
  <GenericResourceDetail resourceId={resourceId} />
22
22
  </View>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Share / visibility helpers for workspace resources.
3
+ *
4
+ * Public files are readable at `/r/{id}` without signing in.
5
+ */
6
+
7
+ export const RESOURCE_ACCESS_VALUES = Object.freeze(['public', 'workspace', 'restricted'])
8
+
9
+ /**
10
+ * @param {object | null | undefined} resource
11
+ * @returns {'public' | 'workspace' | 'restricted'}
12
+ */
13
+ export function resourceAccessOf (resource) {
14
+ const access = resource?.access
15
+ if (RESOURCE_ACCESS_VALUES.includes(access)) return access
16
+ return 'restricted'
17
+ }
18
+
19
+ /**
20
+ * @param {object | null | undefined} resource
21
+ * @returns {boolean}
22
+ */
23
+ export function isPublicResource (resource) {
24
+ return resourceAccessOf(resource) === 'public'
25
+ }
26
+
27
+ /**
28
+ * Icon name for list/grid visibility affordance.
29
+ *
30
+ * @param {object | null | undefined} resource
31
+ * @returns {'globe' | 'lock'}
32
+ */
33
+ export function resourceAccessIcon (resource) {
34
+ return isPublicResource(resource) ? 'globe' : 'lock'
35
+ }
36
+
37
+ /**
38
+ * Absolute (or app-relative) share URL for a resource.
39
+ *
40
+ * @param {string | { id?: string }} resourceOrId
41
+ * @param {{ origin?: string }} [options]
42
+ * @returns {string | null}
43
+ */
44
+ export function getResourceShareUrl (resourceOrId, { origin } = {}) {
45
+ const id = typeof resourceOrId === 'string' ? resourceOrId : resourceOrId?.id
46
+ if (!id) return null
47
+ const path = `/r/${encodeURIComponent(id)}`
48
+ const resolvedOrigin = origin
49
+ ?? (typeof window !== 'undefined' ? window.location?.origin : undefined)
50
+ if (resolvedOrigin) {
51
+ return `${resolvedOrigin}${path}`
52
+ }
53
+ return path
54
+ }
55
+
56
+ /**
57
+ * Whether the resource can expose a public share link (non-directories).
58
+ *
59
+ * @param {object | null | undefined} resource
60
+ * @returns {boolean}
61
+ */
62
+ export function isShareableResource (resource) {
63
+ if (!resource?.id) return false
64
+ const type = resource.type
65
+ if (type === '@ossy/platform/schema/directory' || type === 'directory') return false
66
+ return true
67
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ getResourceShareUrl,
4
+ isPublicResource,
5
+ isShareableResource,
6
+ resourceAccessIcon,
7
+ resourceAccessOf,
8
+ } from './resource-share.js'
9
+
10
+ describe('resource-share', () => {
11
+ describe('resourceAccessOf', () => {
12
+ it('defaults to restricted', () => {
13
+ expect(resourceAccessOf(undefined)).toBe('restricted')
14
+ expect(resourceAccessOf({})).toBe('restricted')
15
+ expect(resourceAccessOf({ access: 'nope' })).toBe('restricted')
16
+ })
17
+
18
+ it('returns known access values', () => {
19
+ expect(resourceAccessOf({ access: 'public' })).toBe('public')
20
+ expect(resourceAccessOf({ access: 'workspace' })).toBe('workspace')
21
+ expect(resourceAccessOf({ access: 'restricted' })).toBe('restricted')
22
+ })
23
+ })
24
+
25
+ describe('isPublicResource / resourceAccessIcon', () => {
26
+ it('treats public as public + globe', () => {
27
+ expect(isPublicResource({ access: 'public' })).toBe(true)
28
+ expect(resourceAccessIcon({ access: 'public' })).toBe('globe')
29
+ })
30
+
31
+ it('treats non-public as private + lock', () => {
32
+ expect(isPublicResource({ access: 'workspace' })).toBe(false)
33
+ expect(isPublicResource({ access: 'restricted' })).toBe(false)
34
+ expect(resourceAccessIcon({ access: 'workspace' })).toBe('lock')
35
+ expect(resourceAccessIcon({})).toBe('lock')
36
+ })
37
+ })
38
+
39
+ describe('getResourceShareUrl', () => {
40
+ it('builds /r/{id} and absolutizes with origin', () => {
41
+ expect(getResourceShareUrl('abc')).toBe('/r/abc')
42
+ expect(getResourceShareUrl({ id: 'res_1' }, { origin: 'https://ossy.se' }))
43
+ .toBe('https://ossy.se/r/res_1')
44
+ })
45
+
46
+ it('encodes the id', () => {
47
+ expect(getResourceShareUrl('a/b')).toBe('/r/a%2Fb')
48
+ })
49
+
50
+ it('returns null without an id', () => {
51
+ expect(getResourceShareUrl(null)).toBe(null)
52
+ expect(getResourceShareUrl({})).toBe(null)
53
+ })
54
+ })
55
+
56
+ describe('isShareableResource', () => {
57
+ it('allows files and documents', () => {
58
+ expect(isShareableResource({ id: '1', type: '@ossy/platform/schema/file' })).toBe(true)
59
+ expect(isShareableResource({ id: '1', type: 'image/png' })).toBe(true)
60
+ })
61
+
62
+ it('rejects directories and missing ids', () => {
63
+ expect(isShareableResource({ id: '1', type: 'directory' })).toBe(false)
64
+ expect(isShareableResource({ id: '1', type: '@ossy/platform/schema/directory' })).toBe(false)
65
+ expect(isShareableResource({ type: 'file' })).toBe(false)
66
+ })
67
+ })
68
+ })
@@ -19,6 +19,15 @@ export {
19
19
  triggerResourceDownloads,
20
20
  } from './resource-download.js'
21
21
 
22
+ export {
23
+ getResourceShareUrl,
24
+ isPublicResource,
25
+ isShareableResource,
26
+ resourceAccessIcon,
27
+ resourceAccessOf,
28
+ RESOURCE_ACCESS_VALUES,
29
+ } from './resource-share.js'
30
+
22
31
  export {
23
32
  applyResourceSelectionGesture,
24
33
  idsInRange,
@@ -50,6 +50,22 @@
50
50
  "resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
51
51
  "resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
52
52
  "resources.panel.download": "Ladda ner",
53
+ "resources.share.title": "Dela länk",
54
+ "resources.share.confirmPublic": "Gör den här resursen publik så att alla med länken kan öppna den. Du kan kopiera länken efter bekräftelse.",
55
+ "resources.share.alreadyPublic": "Den här resursen är publik. Kopiera länken nedan för att dela den.",
56
+ "resources.share.makePublic": "Gör publik & kopiera länk",
57
+ "resources.share.copyLink": "Kopiera länk",
58
+ "resources.share.linkCopied": "Länk kopierad",
59
+ "resources.share.copyFailed": "Kunde inte kopiera länken",
60
+ "resources.share.publishError": "Kunde inte uppdatera delningsinställningar",
61
+ "resources.share.accessError": "Kunde inte uppdatera synlighet",
62
+ "resources.share.sharing": "Delar…",
63
+ "resources.share.close": "Stäng",
64
+ "resources.share.notShareable": "Mappar kan inte delas med en publik länk.",
65
+ "resources.share.visibility": "Synlighet",
66
+ "resources.share.access.public": "Publik",
67
+ "resources.share.access.workspace": "Workspace",
68
+ "resources.share.access.restricted": "Privat",
53
69
  "@ossy/resources/schema/markdown.body.label": "Innehåll",
54
70
  "@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
55
71
  }