@ossy/resources 3.7.1 → 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.7.1",
4
+ "version": "3.9.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.7.0",
26
+ "@ossy/event-store": "^3.8.0",
27
27
  "@ossy/fold": "^3.4.0",
28
- "@ossy/platform": "^3.7.1",
29
- "@ossy/schema": "^3.6.1"
28
+ "@ossy/platform": "^3.9.0",
29
+ "@ossy/schema": "^3.8.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": "1ee220424da6b065034a7f52f52aa4a6ce7c88d5"
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>
@@ -1,38 +1,106 @@
1
- import React, { useState } from 'react'
1
+ import React, { useMemo, useState } from 'react'
2
2
  import { createDocument } from './resource.helpers.js'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
- import { Alert, useLocale } from '@ossy/design-system'
4
+ import { Alert, Button, Text, View, useLocale } from '@ossy/design-system'
5
5
  import { useRouter } from '@ossy/router-react'
6
6
  import { GenericResourceForm } from './GenericResourceForm.jsx'
7
+ import { useSchemas } from './useSchemas.js'
8
+ import { safeReturnTo, schemasForGenericCreate } from './document-create.helpers.js'
7
9
 
8
10
  export const CreateDocument = () => {
9
11
  const { t } = useLocale()
10
12
  const router = useRouter()
11
13
  const schemaId = router.searchParams.schemaId
12
14
  const location = router.searchParams.location
15
+ const category = router.searchParams.category
13
16
  const sdk = useSdk()
17
+ const schemas = useSchemas()
14
18
  const [error, setError] = useState()
15
19
 
16
- const onCancel = () => {
17
- const search = new URLSearchParams({ location }).toString()
18
- router.navigate(`@storage/home?${search}`)
20
+ const returnTo = safeReturnTo(router.searchParams.returnTo)
21
+
22
+ const creatable = useMemo(
23
+ () => schemasForGenericCreate(schemas, { category: category || undefined }),
24
+ [schemas, category],
25
+ )
26
+
27
+ const grouped = useMemo(() => {
28
+ const groups = new Map()
29
+ for (const schema of creatable) {
30
+ const name = schema.categoryName || t('resources.form.otherCategory')
31
+ const list = groups.get(name) || []
32
+ list.push(schema)
33
+ groups.set(name, list)
34
+ }
35
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
36
+ }, [creatable, t])
37
+
38
+ const onDone = () => {
39
+ if (returnTo) {
40
+ router.navigate(returnTo)
41
+ return
42
+ }
43
+ const search = location ? new URLSearchParams({ location }).toString() : ''
44
+ router.navigate(`@storage/home${search ? `?${search}` : ''}`)
19
45
  }
20
46
 
21
47
  const onSubmit = ({ name, content, type }) => {
22
48
  createDocument(sdk, { type, location, name, content })
23
- .then(() => onCancel())
49
+ .then(() => onDone())
24
50
  .catch((err) => setError(err?.message || t('resources.form.createFailed')))
25
51
  }
26
52
 
53
+ const selectSchema = (id) => {
54
+ const params = new URLSearchParams()
55
+ if (location) params.set('location', location)
56
+ if (category) params.set('category', category)
57
+ if (returnTo) params.set('returnTo', returnTo)
58
+ params.set('schemaId', id)
59
+ router.navigate(`@create-document?${params.toString()}`)
60
+ }
61
+
62
+ if (!schemaId) {
63
+ return (
64
+ <View gap="m" style={{ minWidth: 'min(28rem, calc(100vw - 2rem))' }}>
65
+ <View gap="xs">
66
+ <Text variant="heading-tertiary" as="h3" text="resources.createDocument.title" />
67
+ <Text color="secondary" text="resources.createDocument.pickType" />
68
+ </View>
69
+ {grouped.length === 0 ? (
70
+ <Text color="secondary" text="resources.createDocument.noTypes" />
71
+ ) : grouped.map(([categoryName, items]) => (
72
+ <View key={categoryName} gap="s">
73
+ <Text variant="small" weight="medium">{categoryName}</Text>
74
+ <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
75
+ {items.map((schema) => (
76
+ <Button
77
+ key={schema.id}
78
+ variant="neutral"
79
+ prefix={schema.icon || 'add'}
80
+ onClick={() => selectSchema(schema.id)}
81
+ >
82
+ {schema.name || schema.id}
83
+ </Button>
84
+ ))}
85
+ </View>
86
+ </View>
87
+ ))}
88
+ <View layout="row" justifyContent="flex-end">
89
+ <Button variant="link" onClick={onDone} label="design-system.cancel" />
90
+ </View>
91
+ </View>
92
+ )
93
+ }
94
+
27
95
  return (
28
96
  <>
97
+ {error && <Alert variant="danger">{error}</Alert>}
29
98
  <GenericResourceForm
30
99
  schemaId={schemaId}
31
100
  mode="create"
32
101
  onSubmit={onSubmit}
33
- onCancel={onCancel}
102
+ onCancel={onDone}
34
103
  />
35
- {error && <Alert>{error}</Alert>}
36
104
  </>
37
105
  )
38
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',
@@ -201,7 +203,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
201
203
  position: 'relative',
202
204
  }}
203
205
  >
204
- {(showSelectionControls || selected) && (
206
+ {showSelectionControls && (
205
207
  <View
206
208
  style={{
207
209
  position: 'absolute',
@@ -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()
@@ -160,7 +161,7 @@ const ResourceListItem = memo(function ResourceListItem({
160
161
 
161
162
  const leading = (
162
163
  <View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
163
- {(showSelectionControls || selected) && (
164
+ {showSelectionControls && (
164
165
  <ResourceSelectionCheckbox
165
166
  selected={selected}
166
167
  onToggle={handleToggleSelect}
@@ -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)
@@ -176,9 +208,46 @@ export const ResourcePanel = memo(function ResourcePanel ({
176
208
  <Switch.Case match={[ViewMode.View]}>
177
209
 
178
210
  <View.Item fill surface="primary" style={{ padding: '4px 8px' }}>
179
- <Text as="h3" variant="breadcrumb">{resourceName}</Text>
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>
@@ -1,7 +1,6 @@
1
- import React from 'react'
1
+ import React from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
- import { View } from '@ossy/design-system'
5
4
 
6
5
  export const VideoResource = ({
7
6
  resourceId
@@ -10,17 +9,17 @@ export const VideoResource = ({
10
9
  const { data: resource } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
11
10
 
12
11
  return (
13
- <View stack bordered>
14
- <View.Item fill style={{ padding: '16px 8px' }}>
15
-
16
- <div style={{ display: 'flex', justifyContent: 'center' }}>
17
- <video
18
- controls
19
- src={resource?.content?.src}
20
- style={{ width: 'auto', height: '400px', margin: 'var(--space-l) auto' }}
21
- />
22
- </div>
23
- </View.Item>
24
- </View>
12
+ <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-m) var(--space-s) var(--space-xl)' }}>
13
+ <video
14
+ controls
15
+ src={resource?.content?.src}
16
+ style={{
17
+ width: '100%',
18
+ maxWidth: '960px',
19
+ height: 'auto',
20
+ margin: 'var(--space-m) auto var(--space-l)',
21
+ }}
22
+ />
23
+ </div>
25
24
  )
26
25
  }
@@ -0,0 +1,97 @@
1
+ import { parseSchemaId } from '@ossy/schema'
2
+
3
+ /**
4
+ * Document types that can be created from the generic create-document GUI.
5
+ * Platform internals and capability schemas are excluded.
6
+ *
7
+ * @param {object} schema
8
+ * @returns {boolean}
9
+ */
10
+ export function isGenericCreateSchema (schema) {
11
+ if (!schema?.id || !Array.isArray(schema.fields) || schema.fields.length === 0) return false
12
+ const category = schema.categoryName ?? ''
13
+ if (/platform/i.test(category)) return false
14
+ return true
15
+ }
16
+
17
+ function kebabConcept (value) {
18
+ return String(value)
19
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
20
+ .replace(/_/g, '-')
21
+ .toLowerCase()
22
+ }
23
+
24
+ /**
25
+ * Treat canonical ADR 0006 ids and pre-migration `@provider/feature/concept` ids
26
+ * as the same create-document type (e.g. employee vs schema/employee).
27
+ *
28
+ * @param {string} [schemaId]
29
+ * @returns {string | undefined}
30
+ */
31
+ export function schemaCreateIdentity (schemaId) {
32
+ if (typeof schemaId !== 'string' || !schemaId) return schemaId
33
+ const parsed = parseSchemaId(schemaId)
34
+ if (parsed) return `${parsed.provider}/${parsed.feature}/${kebabConcept(parsed.concept)}`
35
+ const legacy = /^@([^/]+)\/([^/]+)\/([^/]+)$/.exec(schemaId)
36
+ if (legacy) return `${legacy[1]}/${legacy[2]}/${kebabConcept(legacy[3])}`
37
+ return schemaId
38
+ }
39
+
40
+ function schemaNameKey (schema) {
41
+ const name = schema?.name
42
+ if (!name) return null
43
+ return `name:${schema.categoryName ?? ''}:${name}`
44
+ }
45
+
46
+ /**
47
+ * @param {object[]} schemas
48
+ * @param {{ category?: string }} [options]
49
+ * @returns {object[]}
50
+ */
51
+ export function schemasForGenericCreate (schemas, { category } = {}) {
52
+ const list = Array.isArray(schemas) ? schemas : []
53
+ const seen = new Set()
54
+ const result = []
55
+ for (const schema of list) {
56
+ if (!isGenericCreateSchema(schema)) continue
57
+ if (category && schema.categoryName !== category) continue
58
+ const identity = schemaCreateIdentity(schema.id)
59
+ const nameKey = schemaNameKey(schema)
60
+ if (seen.has(identity) || (nameKey && seen.has(nameKey))) continue
61
+ seen.add(identity)
62
+ if (nameKey) seen.add(nameKey)
63
+ result.push(schema)
64
+ }
65
+ return result
66
+ }
67
+
68
+ /**
69
+ * Same-origin relative path only.
70
+ * @param {unknown} value
71
+ * @returns {string | null}
72
+ */
73
+ export function safeReturnTo (value) {
74
+ if (typeof value !== 'string') return null
75
+ const trimmed = value.trim()
76
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null
77
+ return trimmed
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
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { isGenericCreateSchema, pathFromHref, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
3
+
4
+ describe('document-create helpers', () => {
5
+ it('excludes platform schemas and field-less types', () => {
6
+ const schemas = [
7
+ { id: '@ossy/consultancy/schema/employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
8
+ { id: '@ossy/platform/schema/file', categoryName: 'Platform', fields: [{ name: 'name', type: 'text' }] },
9
+ { id: '@ossy/empty/schema/x', categoryName: 'Other', fields: [] },
10
+ ]
11
+ expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
12
+ '@ossy/consultancy/schema/employee',
13
+ ])
14
+ expect(isGenericCreateSchema(schemas[1])).toBe(false)
15
+ })
16
+
17
+ it('filters by category when asked', () => {
18
+ const schemas = [
19
+ { id: 'a', categoryName: 'Consultancy', fields: [{ name: 'n', type: 'text' }] },
20
+ { id: 'b', categoryName: 'Web', fields: [{ name: 'n', type: 'text' }] },
21
+ ]
22
+ expect(schemasForGenericCreate(schemas, { category: 'Consultancy' }).map((s) => s.id)).toEqual(['a'])
23
+ })
24
+
25
+ it('aliases canonical and pre-migration consultancy ids', () => {
26
+ expect(schemaCreateIdentity('@ossy/consultancy/schema/employee')).toBe('ossy/consultancy/employee')
27
+ expect(schemaCreateIdentity('@ossy/consultancy/employee')).toBe('ossy/consultancy/employee')
28
+ expect(schemaCreateIdentity('@ossy/consultancy/companyBasicInfo')).toBe('ossy/consultancy/company-basic-info')
29
+ expect(schemaCreateIdentity('@ossy/consultancy/schema/company-basic-info')).toBe('ossy/consultancy/company-basic-info')
30
+ })
31
+
32
+ it('hides workspace copies of system schemas on the create picker', () => {
33
+ const schemas = [
34
+ { id: '@ossy/consultancy/schema/company-basic-info', name: 'Company Basic Info', categoryName: 'Consultancy', icon: 'organisation', fields: [{ name: 'name', type: 'text' }] },
35
+ { id: '@ossy/consultancy/schema/contract', name: 'Contract', categoryName: 'Consultancy', icon: 'file', fields: [{ name: 'name', type: 'text' }] },
36
+ { id: '@ossy/consultancy/schema/employee', name: 'Employee', categoryName: 'Consultancy', icon: 'user-add', fields: [{ name: 'name', type: 'text' }] },
37
+ { id: '@ossy/consultancy/schema/opportunity', name: 'Opportunity', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
38
+ { id: '@ossy/consultancy/schema/proposal', name: 'Proposal', categoryName: 'Consultancy', icon: 'file', fields: [{ name: 'name', type: 'text' }] },
39
+ { id: '@ossy/consultancy/schema/vacation', name: 'Vacation', categoryName: 'Consultancy', icon: 'calendar', fields: [{ name: 'name', type: 'text' }] },
40
+ { id: '@ossy/consultancy/vacation', name: 'Vacation', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
41
+ { id: '@ossy/consultancy/employee', name: 'Employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
42
+ { id: '@ossy/consultancy/companyBasicInfo', name: 'Company Basic Info', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
43
+ { id: '@ossy/consultancy/contract', name: 'Contract', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
44
+ ]
45
+ expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
46
+ '@ossy/consultancy/schema/company-basic-info',
47
+ '@ossy/consultancy/schema/contract',
48
+ '@ossy/consultancy/schema/employee',
49
+ '@ossy/consultancy/schema/opportunity',
50
+ '@ossy/consultancy/schema/proposal',
51
+ '@ossy/consultancy/schema/vacation',
52
+ ])
53
+ })
54
+
55
+ it('keeps workspace-only custom types', () => {
56
+ const schemas = [
57
+ { id: '@ossy/consultancy/schema/employee', name: 'Employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
58
+ { id: 'workspace/custom-invoice', name: 'Invoice', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
59
+ ]
60
+ expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
61
+ '@ossy/consultancy/schema/employee',
62
+ 'workspace/custom-invoice',
63
+ ])
64
+ })
65
+
66
+ it('only allows same-origin relative returnTo paths', () => {
67
+ expect(safeReturnTo('/forecasts')).toBe('/forecasts')
68
+ expect(safeReturnTo('/en/forecasts?x=1')).toBe('/en/forecasts?x=1')
69
+ expect(safeReturnTo('//evil.example')).toBe(null)
70
+ expect(safeReturnTo('https://evil.example')).toBe(null)
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
+ })
79
+ })
@@ -8,6 +8,7 @@
8
8
  "resources.form.createFailed": "Create failed",
9
9
  "resources.form.createResource": "Create resource",
10
10
  "resources.form.typeRemoved": "This document type has been removed and can't be edited.",
11
+ "resources.form.otherCategory": "Other",
11
12
  "@ossy/resources/actions/create.label": "Create resource",
12
13
  "@ossy/resources/actions/create.description": "Create a new resource in the workspace",
13
14
  "@ossy/resources/actions/list.label": "List resources",
@@ -41,12 +42,30 @@
41
42
  "resources.upload.location": "Location: {location}",
42
43
  "resources.createDirectory.title": "Create directory",
43
44
  "resources.createDocument.title": "Create document",
45
+ "resources.createDocument.pickType": "Choose a document type to create in this location.",
46
+ "resources.createDocument.noTypes": "No document types are available to create here.",
44
47
  "resources.createDirectory.description": "Create a new directory to organize your resources. The new directory will be created in the location: {location}",
45
48
  "resources.createDirectory.namePlaceholder": "Directory name",
46
49
  "resources.createDirectory.submit": "Create directory",
47
50
  "resources.createDirectory.errorEmpty": "Directory name cannot be empty",
48
51
  "resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy",
49
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",
50
69
  "@ossy/resources/schema/markdown.body.label": "Body",
51
70
  "@ossy/resources/schema/markdown.body.description": "Markdown content (headings, lists, links, code)."
52
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'
@@ -52,3 +53,4 @@ export * from './useSchemaEngine.js'
52
53
  export * from './useDocumentValidator.js'
53
54
  export * from './useForm.js'
54
55
  export * from './resource.helpers.js'
56
+ export * from './document-create.helpers.js'
@@ -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,
@@ -8,6 +8,7 @@
8
8
  "resources.form.createFailed": "Skapandet misslyckades",
9
9
  "resources.form.createResource": "Skapa resurs",
10
10
  "resources.form.typeRemoved": "Den här dokumenttypen har tagits bort och kan inte redigeras.",
11
+ "resources.form.otherCategory": "Övrigt",
11
12
  "@ossy/resources/actions/create.label": "Skapa resurs",
12
13
  "@ossy/resources/actions/create.description": "Skapa en ny resurs i workspace",
13
14
  "@ossy/resources/actions/list.label": "Lista resurser",
@@ -41,12 +42,30 @@
41
42
  "resources.upload.location": "Plats: {location}",
42
43
  "resources.createDirectory.title": "Skapa mapp",
43
44
  "resources.createDocument.title": "Skapa dokument",
45
+ "resources.createDocument.pickType": "Välj en dokumenttyp att skapa på den här platsen.",
46
+ "resources.createDocument.noTypes": "Inga dokumenttyper kan skapas här.",
44
47
  "resources.createDirectory.description": "Skapa en ny mapp för att organisera dina resurser. Den nya mappen skapas på platsen: {location}",
45
48
  "resources.createDirectory.namePlaceholder": "Mappnamn",
46
49
  "resources.createDirectory.submit": "Skapa mapp",
47
50
  "resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
48
51
  "resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
49
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",
50
69
  "@ossy/resources/schema/markdown.body.label": "Innehåll",
51
70
  "@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
52
71
  }