@ossy/app 1.40.2 → 3.0.1
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/README.md +49 -12
- package/cli/build.task.js +49 -10
- package/cli/get-platform-files.task.js +21 -15
- package/cli/manifest-plugin.js +340 -48
- package/package.json +25 -13
- package/runtime/merge-shell-slots.js +148 -0
- package/runtime/page-runtime.js +36 -18
- package/runtime/resolve-app-slots.js +126 -0
- package/src/en.translations.json +8 -0
- package/src/manifest/action-id-to-tool-name.js +29 -0
- package/src/manifest/build-action-input-schema.js +220 -0
- package/src/manifest/build-actions-schema.js +36 -0
- package/src/manifest/build-capabilities.js +190 -0
- package/src/manifest/build-manifest-summary.js +11 -5
- package/src/manifest/extract-task-catalog.js +1 -0
- package/src/manifest/resolve-page-layout.js +51 -0
- package/src/manifest/serialize-package-definition.js +2 -3
- package/src/manifest/template-to-json-schema.js +103 -0
- package/src/shell/App.jsx +18 -9
- package/src/shell/AppSettings.jsx +17 -0
- package/src/shell/DevPagesPanel.jsx +17 -13
- package/src/shell/HeaderAuthActions.jsx +49 -0
- package/src/shell/LanguageList.jsx +83 -0
- package/src/shell/LanguageSelect.jsx +73 -0
- package/src/shell/ThemeEditor.jsx +19 -34
- package/src/shell/ThemeSelect.jsx +101 -0
- package/src/shell/WorkspaceAppSettingsSync.jsx +39 -0
- package/src/shell/buildSidebarNav.js +113 -0
- package/src/shell/index.js +11 -0
- package/src/shell/languageCode.js +31 -0
- package/src/shell/patchUserAppSettings.js +10 -0
- package/src/shell/resolveEndpoints.js +43 -0
- package/src/shell/resolveWorkspaceServices.js +20 -0
- package/src/shell/themeEditorStyles.js +49 -0
- package/src/shell/useCompactShellLayout.js +24 -0
- package/src/shell/useShellWorkspace.js +40 -0
- package/src/shell-registry/blank.layout.jsx +8 -0
- package/src/shell-registry/default.layout.jsx +101 -0
- package/src/shell-registry/footer-default.component.jsx +39 -0
- package/src/shell-registry/head-default.component.jsx +17 -0
- package/src/shell-registry/header-default.component.jsx +50 -0
- package/src/shell-registry/logo-logomark.component.jsx +8 -0
- package/src/shell-registry/logo-logotype.component.jsx +15 -0
- package/src/shell-registry/minimal.layout.jsx +58 -0
- package/src/shell-registry/sidebar-default.component.jsx +281 -0
- package/src/sv.translations.json +8 -0
- package/runtime/resolve-shell-slots.js +0 -112
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
ContextMenu,
|
|
5
|
+
Dropdown,
|
|
6
|
+
View,
|
|
7
|
+
useLocale,
|
|
8
|
+
} from '@ossy/design-system'
|
|
9
|
+
import { useRouter } from '@ossy/router-react'
|
|
10
|
+
import { languageCode } from './languageCode.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Language switcher for app shell header — cycle when two locales, dropdown when more.
|
|
14
|
+
*/
|
|
15
|
+
export function LanguageSelect () {
|
|
16
|
+
const router = useRouter()
|
|
17
|
+
const { t } = useLocale()
|
|
18
|
+
const { language, supportedLanguages, getHref } = router
|
|
19
|
+
|
|
20
|
+
if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const pickerLabel = t('app.shell.header.languagePicker') || 'Language'
|
|
25
|
+
|
|
26
|
+
if (supportedLanguages.length === 2) {
|
|
27
|
+
const other = supportedLanguages.find((lang) => lang !== language) ?? supportedLanguages[0]
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Button
|
|
31
|
+
variant="link"
|
|
32
|
+
prefix="select"
|
|
33
|
+
href={getHref({ language: other })}
|
|
34
|
+
aria-label={pickerLabel}
|
|
35
|
+
style={{ flexShrink: 0 }}
|
|
36
|
+
>
|
|
37
|
+
{languageCode(language)}
|
|
38
|
+
</Button>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Dropdown
|
|
44
|
+
trigger={(
|
|
45
|
+
<Button
|
|
46
|
+
prefix="select"
|
|
47
|
+
variant="link"
|
|
48
|
+
aria-label={pickerLabel}
|
|
49
|
+
style={{ flexShrink: 0 }}
|
|
50
|
+
>
|
|
51
|
+
{languageCode(language)}
|
|
52
|
+
</Button>
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<View inset="xs" surface="primary" roundness="s">
|
|
56
|
+
<ContextMenu roundness="s" surface="primary">
|
|
57
|
+
{supportedLanguages.map((lang) => {
|
|
58
|
+
const isActive = lang === language
|
|
59
|
+
return (
|
|
60
|
+
<ContextMenu.Item
|
|
61
|
+
key={lang}
|
|
62
|
+
href={isActive ? undefined : getHref({ language: lang })}
|
|
63
|
+
aria-current={isActive ? 'true' : undefined}
|
|
64
|
+
>
|
|
65
|
+
{languageCode(lang)}
|
|
66
|
+
</ContextMenu.Item>
|
|
67
|
+
)
|
|
68
|
+
})}
|
|
69
|
+
</ContextMenu>
|
|
70
|
+
</View>
|
|
71
|
+
</Dropdown>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -1,30 +1,9 @@
|
|
|
1
|
-
import React, { useState,
|
|
2
|
-
import { updateResourceContent } from '@ossy/resources'
|
|
1
|
+
import React, { useState, useCallback, useEffect } from 'react'
|
|
2
|
+
import { updateResourceContent } from '@ossy/resources/resource.helpers.js'
|
|
3
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
4
4
|
import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
|
|
5
5
|
import { DevPagesPanel } from './DevPagesPanel.jsx'
|
|
6
|
-
|
|
7
|
-
const fabStyles = {
|
|
8
|
-
boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
|
|
9
|
-
borderRadius: '999px',
|
|
10
|
-
position: 'fixed',
|
|
11
|
-
right: 'var(--space-m)',
|
|
12
|
-
bottom: 'var(--space-m)',
|
|
13
|
-
cursor: 'pointer',
|
|
14
|
-
transition: 'transform .5s',
|
|
15
|
-
zIndex: 101,
|
|
16
|
-
padding: 'var(--space-m)',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const modalPanelStyles = {
|
|
20
|
-
width: 'min(720px, 80vw)',
|
|
21
|
-
minWidth: 'min(480px, 100%)',
|
|
22
|
-
maxHeight: '85vh',
|
|
23
|
-
overflowY: 'auto',
|
|
24
|
-
overflowX: 'hidden',
|
|
25
|
-
margin: '0 auto',
|
|
26
|
-
boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
|
|
27
|
-
}
|
|
6
|
+
import { themeEditorStyles } from './themeEditorStyles.js'
|
|
28
7
|
|
|
29
8
|
const ThemeSwitcher = () => {
|
|
30
9
|
const { activeTheme, setTheme, themes } = useTheme()
|
|
@@ -47,10 +26,6 @@ export const ThemeEditor = () => {
|
|
|
47
26
|
// const [theme, temporarilyUpdateTheme] = useTheme()
|
|
48
27
|
const theme = {}
|
|
49
28
|
const temporarilyUpdateTheme = () => {}
|
|
50
|
-
|
|
51
|
-
const toggleStyles = useMemo(() => !isEditorOpen
|
|
52
|
-
? fabStyles
|
|
53
|
-
: { ...fabStyles, transform: 'rotate(-45deg)' }, [isEditorOpen])
|
|
54
29
|
|
|
55
30
|
const onToggle = useCallback(() => {
|
|
56
31
|
setIsEditorOpen(!isEditorOpen)
|
|
@@ -85,18 +60,22 @@ export const ThemeEditor = () => {
|
|
|
85
60
|
|
|
86
61
|
return (
|
|
87
62
|
<>
|
|
63
|
+
<style href="@ossy/app/theme-editor" precedence="high">
|
|
64
|
+
{themeEditorStyles}
|
|
65
|
+
</style>
|
|
66
|
+
|
|
88
67
|
{
|
|
89
68
|
isEditorOpen && (
|
|
90
69
|
<Overlay isVisible={true} onClose={onToggle}>
|
|
91
|
-
<View layout="off-center"
|
|
92
|
-
<View
|
|
70
|
+
<View layout="off-center" data-component="theme-editor-overlay">
|
|
71
|
+
<View data-region="content" data-component="theme-editor-panel">
|
|
93
72
|
<View surface="primary" roundness="m" gap="m" inset="l">
|
|
94
73
|
<DevPagesPanel />
|
|
95
74
|
<View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
|
|
96
75
|
{Object.entries(theme).map(([name, value]) => (
|
|
97
|
-
<div
|
|
98
|
-
<label
|
|
99
|
-
<input value={value} data-name={name}
|
|
76
|
+
<div key={name} data-component="theme-editor-field">
|
|
77
|
+
<label>{name}</label>
|
|
78
|
+
<input value={value} data-name={name} />
|
|
100
79
|
</div>
|
|
101
80
|
))}
|
|
102
81
|
<Button type="submit" variant="cta">
|
|
@@ -112,7 +91,13 @@ export const ThemeEditor = () => {
|
|
|
112
91
|
)
|
|
113
92
|
}
|
|
114
93
|
|
|
115
|
-
<Button
|
|
94
|
+
<Button
|
|
95
|
+
variant="cta"
|
|
96
|
+
prefix="math-plus"
|
|
97
|
+
data-component="theme-editor-fab"
|
|
98
|
+
data-open={isEditorOpen ? 'true' : undefined}
|
|
99
|
+
onClick={onToggle}
|
|
100
|
+
/>
|
|
116
101
|
</>
|
|
117
102
|
)
|
|
118
103
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React, { useCallback } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
ContextMenu,
|
|
5
|
+
Dropdown,
|
|
6
|
+
View,
|
|
7
|
+
useLocale,
|
|
8
|
+
useTheme,
|
|
9
|
+
} from '@ossy/design-system'
|
|
10
|
+
import { patchUserAppSettings } from './patchUserAppSettings.js'
|
|
11
|
+
|
|
12
|
+
const THEME_ICON_BY_NAME = {
|
|
13
|
+
light: 'sun',
|
|
14
|
+
dark: 'moon',
|
|
15
|
+
'cloud-light': 'sun',
|
|
16
|
+
'cloud-dark': 'moon',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function themeLabel (name) {
|
|
20
|
+
if (typeof name !== 'string' || !name) {
|
|
21
|
+
return 'Theme'
|
|
22
|
+
}
|
|
23
|
+
return name
|
|
24
|
+
.split('-')
|
|
25
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
26
|
+
.join(' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function themeIcon (name) {
|
|
30
|
+
return THEME_ICON_BY_NAME[name] || 'dark-mode'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Theme switcher for app shell header — cycle when two themes, dropdown when more.
|
|
35
|
+
*/
|
|
36
|
+
export function ThemeSelect ({ compact = false }) {
|
|
37
|
+
const { themes, activeTheme, setTheme } = useTheme()
|
|
38
|
+
const { t } = useLocale()
|
|
39
|
+
|
|
40
|
+
const saveTheme = useCallback((themeName) => {
|
|
41
|
+
setTheme(themeName)
|
|
42
|
+
patchUserAppSettings({ theme: themeName }).catch(() => {})
|
|
43
|
+
}, [setTheme])
|
|
44
|
+
|
|
45
|
+
if (!themes?.length || themes.length <= 1) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const activeLabel = themeLabel(activeTheme)
|
|
50
|
+
const ariaLabel =
|
|
51
|
+
t('app.shell.header.themeSwitch', { theme: activeLabel })
|
|
52
|
+
|| `${activeLabel} theme. Switch theme.`
|
|
53
|
+
|
|
54
|
+
if (themes.length === 2) {
|
|
55
|
+
const cycleTheme = () => {
|
|
56
|
+
const index = Math.max(0, themes.indexOf(activeTheme))
|
|
57
|
+
saveTheme(themes[(index + 1) % themes.length])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Button
|
|
62
|
+
variant="link"
|
|
63
|
+
prefix={themeIcon(activeTheme)}
|
|
64
|
+
onClick={cycleTheme}
|
|
65
|
+
aria-label={ariaLabel}
|
|
66
|
+
style={{ flexShrink: 0 }}
|
|
67
|
+
>
|
|
68
|
+
{compact ? null : activeLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Dropdown
|
|
75
|
+
trigger={(
|
|
76
|
+
<Button
|
|
77
|
+
prefix={compact ? themeIcon(activeTheme) : 'select'}
|
|
78
|
+
variant="link"
|
|
79
|
+
aria-label={ariaLabel}
|
|
80
|
+
style={{ flexShrink: 0 }}
|
|
81
|
+
>
|
|
82
|
+
{compact ? null : activeLabel}
|
|
83
|
+
</Button>
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<View inset="xs" surface="primary" roundness="s">
|
|
87
|
+
<ContextMenu roundness="s" surface="primary">
|
|
88
|
+
{themes.map((name) => (
|
|
89
|
+
<ContextMenu.Item
|
|
90
|
+
key={name}
|
|
91
|
+
onClick={() => saveTheme(name)}
|
|
92
|
+
aria-current={name === activeTheme ? 'true' : undefined}
|
|
93
|
+
>
|
|
94
|
+
{themeLabel(name)}
|
|
95
|
+
</ContextMenu.Item>
|
|
96
|
+
))}
|
|
97
|
+
</ContextMenu>
|
|
98
|
+
</View>
|
|
99
|
+
</Dropdown>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { GetWorkspace, ListWorkspaces } from '@ossy/workspaces'
|
|
3
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
4
|
+
import { patchUserAppSettings } from './patchUserAppSettings.js'
|
|
5
|
+
|
|
6
|
+
function buildPatch (workspace, workspaces) {
|
|
7
|
+
const patch = {}
|
|
8
|
+
if (workspace?.name) patch.workspaceName = workspace.name
|
|
9
|
+
if (workspace?.services) patch.workspaceServices = workspace.services
|
|
10
|
+
if (workspaces?.length) {
|
|
11
|
+
patch.workspaces = workspaces.map(({ id, name }) => ({ id, name }))
|
|
12
|
+
}
|
|
13
|
+
return Object.keys(patch).length ? patch : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Persists workspace shell snapshot to the signed user-app-settings cookie
|
|
18
|
+
* (via ProxyInternal PATCH /@ossy/users/me/app-settings) so SSR bootstrap
|
|
19
|
+
* props stay stable across full-page navigations.
|
|
20
|
+
*/
|
|
21
|
+
export function WorkspaceAppSettingsSync () {
|
|
22
|
+
const sdk = useSdk()
|
|
23
|
+
const { data: workspace } = sdk.read(GetWorkspace)
|
|
24
|
+
const { data: workspaces } = sdk.read(ListWorkspaces)
|
|
25
|
+
const lastPatchRef = useRef('')
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const patch = buildPatch(workspace, workspaces)
|
|
29
|
+
if (!patch) return
|
|
30
|
+
|
|
31
|
+
const serialized = JSON.stringify(patch)
|
|
32
|
+
if (serialized === lastPatchRef.current) return
|
|
33
|
+
lastPatchRef.current = serialized
|
|
34
|
+
|
|
35
|
+
patchUserAppSettings(patch).catch(() => {})
|
|
36
|
+
}, [workspace, workspaces])
|
|
37
|
+
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { slugToDisplayName } from '@ossy/package-catalog'
|
|
2
|
+
import { isServiceEntitled } from '@ossy/workspaces/entitlements'
|
|
3
|
+
import { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} SidebarShellItem
|
|
7
|
+
* @property {string} id
|
|
8
|
+
* @property {string} label
|
|
9
|
+
* @property {string} prefix
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} SidebarPackageItem
|
|
14
|
+
* @property {string} slug
|
|
15
|
+
* @property {string} label
|
|
16
|
+
* @property {string} icon
|
|
17
|
+
* @property {string} pageId
|
|
18
|
+
* @property {number} order
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pick the package "home" page from manifest pages.
|
|
23
|
+
* Convention: slug plus "/home", then any page id ending in "/home", then shortest route path.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{ id?: string, path?: string | Record<string, string> }>} pages
|
|
26
|
+
* @param {string} slug
|
|
27
|
+
* @returns {string | null}
|
|
28
|
+
*/
|
|
29
|
+
export function resolvePackageHomePageId (pages, slug) {
|
|
30
|
+
const list = (pages || []).filter((page) => page?.id)
|
|
31
|
+
if (!list.length) return null
|
|
32
|
+
|
|
33
|
+
const conventional = list.find((page) => page.id === `${slug}/home`)
|
|
34
|
+
if (conventional) return conventional.id
|
|
35
|
+
|
|
36
|
+
const anyHome = list.find((page) => page.id === 'home' || page.id.endsWith('/home'))
|
|
37
|
+
if (anyHome) return anyHome.id
|
|
38
|
+
|
|
39
|
+
const pathLength = (page) => {
|
|
40
|
+
const path = page.path
|
|
41
|
+
if (typeof path === 'string') return path.length
|
|
42
|
+
if (path && typeof path === 'object') {
|
|
43
|
+
return Math.min(...Object.values(path).map((p) => (typeof p === 'string' ? p.length : 999)))
|
|
44
|
+
}
|
|
45
|
+
return 999
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const byPath = [...list].sort((a, b) => pathLength(a) - pathLength(b) || a.id.localeCompare(b.id))
|
|
49
|
+
return byPath[0].id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build entitlement-filtered sidebar navigation — one link per entitled package home.
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* manifestSummary?: { packages?: Array<{ slug?: string, package?: string, pages?: Array<{ id: string, title?: string, path?: string | Record<string, string> }> }> }
|
|
57
|
+
* workspaceServices?: Record<string, { enabled?: boolean }>
|
|
58
|
+
* definitions?: Record<string, object>
|
|
59
|
+
* isAuthenticated?: boolean
|
|
60
|
+
* workspaceId?: string
|
|
61
|
+
* devMode?: boolean
|
|
62
|
+
* devEntitlements?: Record<string, { enabled?: boolean }>
|
|
63
|
+
* shellItems?: SidebarShellItem[]
|
|
64
|
+
* excludeSlugs?: string[] | Set<string>
|
|
65
|
+
* }} options
|
|
66
|
+
* @returns {{ shellItems: SidebarShellItem[], packageItems: SidebarPackageItem[], services: Record<string, { enabled?: boolean }> }}
|
|
67
|
+
*/
|
|
68
|
+
export function buildSidebarNav ({
|
|
69
|
+
manifestSummary,
|
|
70
|
+
workspaceServices,
|
|
71
|
+
definitions = {},
|
|
72
|
+
isAuthenticated = false,
|
|
73
|
+
workspaceId,
|
|
74
|
+
devMode = false,
|
|
75
|
+
devEntitlements,
|
|
76
|
+
shellItems = [],
|
|
77
|
+
excludeSlugs = [],
|
|
78
|
+
}) {
|
|
79
|
+
const services = resolveWorkspaceServices({ devMode, devEntitlements, workspaceServices })
|
|
80
|
+
const excluded = excludeSlugs instanceof Set ? excludeSlugs : new Set(excludeSlugs)
|
|
81
|
+
|
|
82
|
+
const canShowProductGroups =
|
|
83
|
+
(isAuthenticated && workspaceId) ||
|
|
84
|
+
(devMode && devEntitlements && Object.keys(devEntitlements).length > 0)
|
|
85
|
+
|
|
86
|
+
const packageItems = !canShowProductGroups
|
|
87
|
+
? []
|
|
88
|
+
: (manifestSummary?.packages || [])
|
|
89
|
+
.filter((pkg) => pkg.slug && !excluded.has(pkg.slug))
|
|
90
|
+
.filter((pkg) => !definitions[pkg.slug]?.status?.includes('hidden'))
|
|
91
|
+
.filter((pkg) => {
|
|
92
|
+
const def = definitions[pkg.slug]
|
|
93
|
+
const entitlementRequired = def?.entitlementRequired !== false
|
|
94
|
+
return !entitlementRequired || isServiceEntitled(services, pkg.package)
|
|
95
|
+
})
|
|
96
|
+
.map((pkg) => {
|
|
97
|
+
const slug = pkg.slug
|
|
98
|
+
const def = definitions[slug]
|
|
99
|
+
const pageId = resolvePackageHomePageId(pkg.pages, slug)
|
|
100
|
+
if (!pageId) return null
|
|
101
|
+
return {
|
|
102
|
+
slug,
|
|
103
|
+
label: def?.title || slugToDisplayName(slug),
|
|
104
|
+
icon: def?.icon || 'package',
|
|
105
|
+
order: def?.navOrder ?? 100,
|
|
106
|
+
pageId,
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
|
|
111
|
+
|
|
112
|
+
return { shellItems, packageItems, services }
|
|
113
|
+
}
|
package/src/shell/index.js
CHANGED
|
@@ -2,3 +2,14 @@ export * from './App.jsx'
|
|
|
2
2
|
export * from './AppSettings.jsx'
|
|
3
3
|
export { useApp, useAppSettings, AppContext } from './AppContext.js'
|
|
4
4
|
export * from './ThemeEditor.jsx'
|
|
5
|
+
export { patchUserAppSettings } from './patchUserAppSettings.js'
|
|
6
|
+
export { useShellWorkspace } from './useShellWorkspace.js'
|
|
7
|
+
export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
|
|
8
|
+
export { resolveEndpoints } from './resolveEndpoints.js'
|
|
9
|
+
export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
|
|
10
|
+
export { useCompactShellLayout } from './useCompactShellLayout.js'
|
|
11
|
+
export { buildSidebarNav, resolvePackageHomePageId } from './buildSidebarNav.js'
|
|
12
|
+
export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
|
|
13
|
+
export { ThemeSelect } from './ThemeSelect.jsx'
|
|
14
|
+
export { LanguageSelect } from './LanguageSelect.jsx'
|
|
15
|
+
export { HeaderAuthActions } from './HeaderAuthActions.jsx'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO 639-1 shorthand from a BCP 47 language tag (e.g. `en` → `EN`, `sv-SE` → `SV`).
|
|
3
|
+
*
|
|
4
|
+
* @param {string} tag
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function languageCode (tag) {
|
|
8
|
+
try {
|
|
9
|
+
return new Intl.Locale(tag).language.toUpperCase()
|
|
10
|
+
} catch {
|
|
11
|
+
const [primary] = String(tag).split('-')
|
|
12
|
+
return (primary || tag).toUpperCase()
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Localized language name via `Intl.DisplayNames` (e.g. `en` in UI `sv` → `engelska`).
|
|
18
|
+
*
|
|
19
|
+
* @param {string} tag BCP 47 language tag to describe
|
|
20
|
+
* @param {string} [displayLocale] UI locale used for the label
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function languageDisplayName (tag, displayLocale) {
|
|
24
|
+
if (!tag) return ''
|
|
25
|
+
try {
|
|
26
|
+
const locale = displayLocale || tag
|
|
27
|
+
return new Intl.DisplayNames([locale], { type: 'language' }).of(tag) ?? tag
|
|
28
|
+
} catch {
|
|
29
|
+
return tag
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve integration endpoint URLs from app context and browser origin.
|
|
3
|
+
*
|
|
4
|
+
* @param {{ apiUrl?: string, workspaceId?: string } | null | undefined} app
|
|
5
|
+
* @returns {{
|
|
6
|
+
* apiUrl: string,
|
|
7
|
+
* appOrigin: string,
|
|
8
|
+
* actionsUrl: string,
|
|
9
|
+
* mcpUrl: string,
|
|
10
|
+
* capabilitiesUrl: string,
|
|
11
|
+
* workspaceId: string | undefined,
|
|
12
|
+
* }}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveEndpoints (app) {
|
|
15
|
+
const apiUrl = app?.apiUrl || ''
|
|
16
|
+
const appOrigin =
|
|
17
|
+
typeof window !== 'undefined' && window.location?.origin
|
|
18
|
+
? window.location.origin
|
|
19
|
+
: ''
|
|
20
|
+
|
|
21
|
+
const normalizedApi = apiUrl.replace(/\/$/, '')
|
|
22
|
+
let actionsUrl = '/actions'
|
|
23
|
+
|
|
24
|
+
if (normalizedApi.startsWith('http://') || normalizedApi.startsWith('https://')) {
|
|
25
|
+
actionsUrl = `${normalizedApi}/actions`
|
|
26
|
+
} else if (normalizedApi.startsWith('/')) {
|
|
27
|
+
actionsUrl = appOrigin ? `${appOrigin}${normalizedApi}/actions` : `${normalizedApi}/actions`
|
|
28
|
+
} else if (appOrigin) {
|
|
29
|
+
actionsUrl = `${appOrigin}/actions`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mcpUrl = appOrigin ? `${appOrigin}/mcp` : '/mcp'
|
|
33
|
+
const capabilitiesUrl = appOrigin ? `${appOrigin}/capabilities.json` : '/capabilities.json'
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
apiUrl: normalizedApi,
|
|
37
|
+
appOrigin,
|
|
38
|
+
actionsUrl,
|
|
39
|
+
mcpUrl,
|
|
40
|
+
capabilitiesUrl,
|
|
41
|
+
workspaceId: app?.workspaceId,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge live workspace services with dev-mode entitlement overrides.
|
|
3
|
+
*
|
|
4
|
+
* @param {{
|
|
5
|
+
* devMode?: boolean
|
|
6
|
+
* devEntitlements?: Record<string, { enabled?: boolean }>
|
|
7
|
+
* workspaceServices?: Record<string, { enabled?: boolean }>
|
|
8
|
+
* }} options
|
|
9
|
+
* @returns {Record<string, { enabled?: boolean }>}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveWorkspaceServices ({
|
|
12
|
+
devMode = false,
|
|
13
|
+
devEntitlements,
|
|
14
|
+
workspaceServices,
|
|
15
|
+
}) {
|
|
16
|
+
if (devMode && devEntitlements) {
|
|
17
|
+
return { ...(workspaceServices || {}), ...devEntitlements }
|
|
18
|
+
}
|
|
19
|
+
return workspaceServices || {}
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Dev theme editor — FAB and modal panel using theme tokens. */
|
|
2
|
+
export const themeEditorStyles = `
|
|
3
|
+
[data-component="theme-editor-fab"] {
|
|
4
|
+
box-shadow: 2px 2px 5px color-mix(in srgb, var(--foreground) 20%, transparent);
|
|
5
|
+
border-radius: var(--space-xl);
|
|
6
|
+
position: fixed;
|
|
7
|
+
right: var(--space-m);
|
|
8
|
+
bottom: var(--space-m);
|
|
9
|
+
cursor: pointer;
|
|
10
|
+
transition: transform 0.5s;
|
|
11
|
+
z-index: 101;
|
|
12
|
+
padding: var(--space-m);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
[data-component="theme-editor-fab"][data-open="true"] {
|
|
16
|
+
transform: rotate(-45deg);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
[data-component="theme-editor-panel"] {
|
|
20
|
+
width: min(720px, 80vw);
|
|
21
|
+
min-width: min(480px, 100%);
|
|
22
|
+
max-height: 85vh;
|
|
23
|
+
overflow-y: auto;
|
|
24
|
+
overflow-x: hidden;
|
|
25
|
+
margin: 0 auto;
|
|
26
|
+
box-shadow: 2px 2px 5px color-mix(in srgb, var(--foreground) 20%, transparent);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[data-component="theme-editor-overlay"] {
|
|
30
|
+
height: 100%;
|
|
31
|
+
width: 100%;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[data-component="theme-editor-field"] {
|
|
35
|
+
margin-bottom: var(--space-m);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[data-component="theme-editor-field"] label {
|
|
39
|
+
display: block;
|
|
40
|
+
font-family: var(--text-default-font-family, sans-serif);
|
|
41
|
+
margin-bottom: var(--space-xs);
|
|
42
|
+
font-weight: 700;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-component="theme-editor-field"] input {
|
|
46
|
+
width: 100%;
|
|
47
|
+
padding: var(--space-xs);
|
|
48
|
+
}
|
|
49
|
+
`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Viewports at or below this width use compact chrome (icon rail, tighter padding). */
|
|
4
|
+
const COMPACT_MEDIA_QUERY = '(max-width: 900px)'
|
|
5
|
+
|
|
6
|
+
function subscribe (onStoreChange) {
|
|
7
|
+
if (typeof window === 'undefined') return () => {}
|
|
8
|
+
const mq = window.matchMedia(COMPACT_MEDIA_QUERY)
|
|
9
|
+
mq.addEventListener('change', onStoreChange)
|
|
10
|
+
return () => mq.removeEventListener('change', onStoreChange)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSnapshot () {
|
|
14
|
+
if (typeof window === 'undefined') return false
|
|
15
|
+
return window.matchMedia(COMPACT_MEDIA_QUERY).matches
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getServerSnapshot () {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCompactShellLayout () {
|
|
23
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { GetWorkspace, ListWorkspaces } from '@ossy/workspaces'
|
|
3
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
4
|
+
import { useApp } from './AppContext.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Workspace list + active workspace for shell chrome.
|
|
8
|
+
* SSR and the first client paint use the signed user-app-settings cookie
|
|
9
|
+
* (merged into bootstrap props); live SDK reads refresh in the background.
|
|
10
|
+
*/
|
|
11
|
+
export function useShellWorkspace () {
|
|
12
|
+
const app = useApp()
|
|
13
|
+
const sdk = useSdk()
|
|
14
|
+
const { data: workspaceFromSdk } = sdk.read(GetWorkspace)
|
|
15
|
+
const { data: workspacesFromSdk } = sdk.read(ListWorkspaces)
|
|
16
|
+
|
|
17
|
+
const workspace = useMemo(() => {
|
|
18
|
+
if (workspaceFromSdk) return workspaceFromSdk
|
|
19
|
+
if (app?.workspaceName || app?.workspaceServices || app?.workspaceId) {
|
|
20
|
+
return {
|
|
21
|
+
id: app.workspaceId,
|
|
22
|
+
name: app.workspaceName,
|
|
23
|
+
services: app.workspaceServices,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}, [
|
|
28
|
+
workspaceFromSdk,
|
|
29
|
+
app?.workspaceId,
|
|
30
|
+
app?.workspaceName,
|
|
31
|
+
app?.workspaceServices,
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const workspaces = useMemo(() => {
|
|
35
|
+
if (workspacesFromSdk?.length) return workspacesFromSdk
|
|
36
|
+
return app?.workspaces ?? []
|
|
37
|
+
}, [workspacesFromSdk, app?.workspaces])
|
|
38
|
+
|
|
39
|
+
return { workspace, workspaces }
|
|
40
|
+
}
|