@ossy/app 1.40.3 → 3.0.2
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 +6 -0
- package/cli/get-platform-files.task.js +4 -6
- package/cli/manifest-plugin.js +111 -36
- package/package.json +23 -14
- package/runtime/merge-shell-slots.js +148 -0
- package/runtime/page-runtime.js +32 -16
- package/runtime/resolve-app-slots.js +48 -11
- package/src/en.translations.json +8 -0
- package/src/manifest/build-actions-schema.js +1 -36
- package/src/manifest/build-capabilities.js +1 -190
- package/src/manifest/build-manifest-summary.js +1 -121
- package/src/manifest/discover-package-definitions.js +1 -100
- package/src/manifest/resolve-page-layout.js +51 -0
- package/src/manifest/serialize-package-definition.js +1 -30
- package/src/shell/App.jsx +13 -7
- package/src/shell/AppSettings.jsx +6 -0
- package/src/shell/DevPagesPanel.jsx +1 -1
- 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 +2 -2
- package/src/shell/ThemeSelect.jsx +101 -0
- package/src/shell/buildSidebarNav.js +86 -0
- package/src/shell/index.js +7 -0
- package/src/shell/languageCode.js +31 -0
- package/src/shell/resolvePackageHomePageId.js +14 -0
- package/src/shell/useCompactShellLayout.js +24 -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/src/manifest/action-id-to-tool-name.js +0 -29
- package/src/manifest/build-action-input-schema.js +0 -220
- package/src/manifest/template-to-json-schema.js +0 -103
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mergeShellSlots } from '../../runtime/merge-shell-slots.js'
|
|
2
|
+
|
|
3
|
+
const LAYOUT_ID_PATTERN = /^@([^/]+)\/([^/]+)\/layout\/([^/]+)$/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} layoutId
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function isCanonicalLayoutId (layoutId) {
|
|
10
|
+
return typeof layoutId === 'string' && LAYOUT_ID_PATTERN.test(layoutId.trim())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve layout id for a page at build time (ADR 0012).
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* pageMetadata?: { layout?: string },
|
|
18
|
+
* appConfig?: { layout?: string },
|
|
19
|
+
* }} options
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function resolvePageLayoutId ({
|
|
23
|
+
pageMetadata = {},
|
|
24
|
+
appConfig = {},
|
|
25
|
+
} = {}) {
|
|
26
|
+
const pageLayout = pageMetadata?.layout
|
|
27
|
+
if (typeof pageLayout === 'string' && pageLayout.trim()) return pageLayout.trim()
|
|
28
|
+
|
|
29
|
+
const appLayout = appConfig?.layout
|
|
30
|
+
if (typeof appLayout === 'string' && appLayout.trim()) return appLayout.trim()
|
|
31
|
+
|
|
32
|
+
return '@ossy/app/layout/default'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Merge layout, app, and page slot maps for a single page render.
|
|
37
|
+
*
|
|
38
|
+
* @param {{
|
|
39
|
+
* layoutSlots?: Record<string, string>,
|
|
40
|
+
* appSlots?: Record<string, string | null>,
|
|
41
|
+
* pageSlots?: Record<string, string | null>,
|
|
42
|
+
* }} options
|
|
43
|
+
* @returns {Record<string, import('../../runtime/merge-shell-slots.js').ShellSlotSpec>}
|
|
44
|
+
*/
|
|
45
|
+
export function resolvePageShellSlots ({
|
|
46
|
+
layoutSlots,
|
|
47
|
+
appSlots,
|
|
48
|
+
pageSlots,
|
|
49
|
+
} = {}) {
|
|
50
|
+
return mergeShellSlots(layoutSlots, appSlots, pageSlots)
|
|
51
|
+
}
|
|
@@ -1,30 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Pick JSON-serializable Definition fields for manifest / SSR bootstrap.
|
|
3
|
-
* Definition holds module presentation metadata only — capabilities live in manifestSummary.
|
|
4
|
-
*
|
|
5
|
-
* @param {object | null | undefined} definition
|
|
6
|
-
* @returns {object | null}
|
|
7
|
-
*/
|
|
8
|
-
export function serializePackageDefinition (definition) {
|
|
9
|
-
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) return null
|
|
10
|
-
|
|
11
|
-
/** @type {Record<string, unknown>} */
|
|
12
|
-
const out = {}
|
|
13
|
-
|
|
14
|
-
if (typeof definition.id === 'string' && definition.id.trim()) out.id = definition.id.trim()
|
|
15
|
-
if (typeof definition.title === 'string' && definition.title.trim()) out.title = definition.title.trim()
|
|
16
|
-
if (typeof definition.description === 'string' && definition.description.trim()) {
|
|
17
|
-
out.description = definition.description.trim()
|
|
18
|
-
}
|
|
19
|
-
if (typeof definition.icon === 'string' && definition.icon.trim()) out.icon = definition.icon.trim()
|
|
20
|
-
if (typeof definition.navOrder === 'number' && Number.isFinite(definition.navOrder)) {
|
|
21
|
-
out.navOrder = definition.navOrder
|
|
22
|
-
}
|
|
23
|
-
if (definition.entitlementRequired === false) out.entitlementRequired = false
|
|
24
|
-
if (typeof definition.status === 'string' && definition.status.trim()) out.status = definition.status.trim()
|
|
25
|
-
if (Array.isArray(definition.statuses) && definition.statuses.length) {
|
|
26
|
-
out.statuses = definition.statuses.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return Object.keys(out).length ? out : null
|
|
30
|
-
}
|
|
1
|
+
export * from '@ossy/manifest/serialize-package-definition'
|
package/src/shell/App.jsx
CHANGED
|
@@ -2,20 +2,22 @@ import React from 'react'
|
|
|
2
2
|
import { SDK } from '@ossy/sdk'
|
|
3
3
|
import { WorkspaceProvider } from '@ossy/sdk-react'
|
|
4
4
|
import { Theme, ComponentSlotsProvider, LocaleProvider, DEFAULT_FORM_FIELD_SLOTS } from '@ossy/design-system'
|
|
5
|
+
import { DEFAULT_SCHEMA_VIEW_SLOTS } from '@ossy/resources/schemaViewSlots.js'
|
|
6
|
+
import { DEFAULT_SCHEMA_FORM_SLOTS } from '@ossy/resources/schemaFormSlots.js'
|
|
5
7
|
import { SchemasReadBootstrap } from '@ossy/workspaces/SchemasReadBootstrap'
|
|
6
8
|
import { ThemeEditor } from './ThemeEditor.jsx'
|
|
9
|
+
import { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
|
|
7
10
|
import { defaultAppSettings } from './AppSettings.jsx'
|
|
8
11
|
import { Router } from '@ossy/router-react'
|
|
9
12
|
import { AppContext } from './AppContext.js'
|
|
10
13
|
|
|
11
|
-
export const App = ({ children, language, messages, fallbackMessages, ..._appSettings }) => {
|
|
14
|
+
export const App = ({ children, language, messages, fallbackMessages, sdk: sdkOverride, ..._appSettings }) => {
|
|
12
15
|
const appSettings = { ...defaultAppSettings(), ..._appSettings }
|
|
13
16
|
|
|
14
|
-
// `components`
|
|
15
|
-
|
|
16
|
-
const { components, ...contextSettings } = appSettings
|
|
17
|
+
// `components` and `sdk` are not JSON-serializable — keep them out of AppContext.
|
|
18
|
+
const { components, sdk: _sdkFromSettings, ...contextSettings } = appSettings
|
|
17
19
|
|
|
18
|
-
const sdk = SDK.of({
|
|
20
|
+
const sdk = sdkOverride ?? SDK.of({
|
|
19
21
|
apiUrl: appSettings.apiUrl,
|
|
20
22
|
workspaceId: appSettings.workspaceId,
|
|
21
23
|
actionRoutes: appSettings.actionRoutes,
|
|
@@ -30,10 +32,14 @@ export const App = ({ children, language, messages, fallbackMessages, ..._appSet
|
|
|
30
32
|
supportedLanguages={appSettings.supportedLanguages}
|
|
31
33
|
>
|
|
32
34
|
<AppContext.Provider value={contextSettings}>
|
|
33
|
-
<ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...(components || {}) }}>
|
|
35
|
+
<ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...DEFAULT_SCHEMA_VIEW_SLOTS, ...DEFAULT_SCHEMA_FORM_SLOTS, ...(components || {}) }}>
|
|
34
36
|
<Theme theme={appSettings.theme} themes={appSettings.themes}>
|
|
35
|
-
<WorkspaceProvider
|
|
37
|
+
<WorkspaceProvider
|
|
38
|
+
sdk={sdk}
|
|
39
|
+
enablePushInvalidation={appSettings.enablePushInvalidation === true}
|
|
40
|
+
>
|
|
36
41
|
<SchemasReadBootstrap schemas={appSettings.schemas} />
|
|
42
|
+
<WorkspaceAppSettingsSync />
|
|
37
43
|
<Router {...appSettings} pages={appSettings.pages || []}>
|
|
38
44
|
{children}
|
|
39
45
|
{appSettings.devMode && <ThemeEditor />}
|
|
@@ -38,5 +38,11 @@ export function defaultAppSettings() {
|
|
|
38
38
|
taskGraphEdges: undefined,
|
|
39
39
|
/** Action id → HTTP route map for sdk.invoke transport routing. */
|
|
40
40
|
actionRoutes: undefined,
|
|
41
|
+
/**
|
|
42
|
+
* When true, the app shell opens SSE push invalidation for every page.
|
|
43
|
+
* Default false — mount `PushInvalidationSubscriber` only on pages that
|
|
44
|
+
* need live cross-tab cache updates (see @ossy/sdk-react README).
|
|
45
|
+
*/
|
|
46
|
+
enablePushInvalidation: false,
|
|
41
47
|
}
|
|
42
48
|
}
|
|
@@ -3,7 +3,7 @@ import { Button, Text, View } from '@ossy/design-system'
|
|
|
3
3
|
import { useRouter } from '@ossy/router-react'
|
|
4
4
|
import { useApp } from './AppContext.js'
|
|
5
5
|
import { formatPagePaths, groupPagesByFeature } from './devPagesUtils.js'
|
|
6
|
-
import { packageNameToSlug } from '
|
|
6
|
+
import { packageNameToSlug } from '@ossy/manifest/build-manifest-summary'
|
|
7
7
|
|
|
8
8
|
const monoStyle = {
|
|
9
9
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Button, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
import { OpenSignIn, OpenSignUp } from '@ossy/authentication'
|
|
5
|
+
import { useApp } from './AppContext.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Profile link when authenticated; sign-in / sign-up when not.
|
|
9
|
+
*/
|
|
10
|
+
export function HeaderAuthActions ({ compact = false }) {
|
|
11
|
+
const app = useApp()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const { t } = useLocale()
|
|
14
|
+
|
|
15
|
+
if (app?.isAuthenticated) {
|
|
16
|
+
return (
|
|
17
|
+
<Button
|
|
18
|
+
variant="link"
|
|
19
|
+
suffix="profile"
|
|
20
|
+
href={router.getHref('@profile')}
|
|
21
|
+
aria-label={t('app.shell.header.profile') || 'Profile'}
|
|
22
|
+
style={{ flexShrink: 0 }}
|
|
23
|
+
>
|
|
24
|
+
{compact ? null : (t('app.shell.header.profile') || 'Profile')}
|
|
25
|
+
</Button>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<Button
|
|
32
|
+
variant="link"
|
|
33
|
+
suffix={OpenSignIn.suffix}
|
|
34
|
+
href={router.getHref('@sign-in')}
|
|
35
|
+
label={compact ? undefined : OpenSignIn.label}
|
|
36
|
+
aria-label={t(OpenSignIn.label)}
|
|
37
|
+
style={{ flexShrink: 0 }}
|
|
38
|
+
/>
|
|
39
|
+
<Button
|
|
40
|
+
variant="cta"
|
|
41
|
+
suffix={OpenSignUp.suffix}
|
|
42
|
+
href={router.getHref('@sign-up')}
|
|
43
|
+
label={compact ? undefined : OpenSignUp.label}
|
|
44
|
+
aria-label={t(OpenSignUp.label)}
|
|
45
|
+
style={{ flexShrink: 0 }}
|
|
46
|
+
/>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Text, View, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
import { languageDisplayName } from './languageCode.js'
|
|
5
|
+
|
|
6
|
+
const listItemStyle = {
|
|
7
|
+
display: 'block',
|
|
8
|
+
width: '100%',
|
|
9
|
+
textAlign: 'left',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Footer language column when multiple locales are configured.
|
|
14
|
+
*/
|
|
15
|
+
export function LanguageList () {
|
|
16
|
+
const router = useRouter()
|
|
17
|
+
const { t } = useLocale()
|
|
18
|
+
const { language, supportedLanguages, getHref } = router
|
|
19
|
+
|
|
20
|
+
const displayNames = useMemo(
|
|
21
|
+
() => new Intl.DisplayNames([language], { type: 'language' }),
|
|
22
|
+
[language],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const listLabel = t('app.shell.footer.languages') || 'Languages'
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View layout="column" gap="s" alignItems="flex-start" style={{ minWidth: 0 }}>
|
|
33
|
+
<Text variant="heading-tertiary" as="h2" style={listItemStyle}>
|
|
34
|
+
{listLabel}
|
|
35
|
+
</Text>
|
|
36
|
+
<View
|
|
37
|
+
as="nav"
|
|
38
|
+
aria-label={listLabel}
|
|
39
|
+
style={{ width: '100%' }}
|
|
40
|
+
>
|
|
41
|
+
<View
|
|
42
|
+
as="ul"
|
|
43
|
+
layout="column"
|
|
44
|
+
gap="xs"
|
|
45
|
+
alignItems="flex-start"
|
|
46
|
+
style={{ listStyle: 'none', margin: 0, padding: 0, width: '100%' }}
|
|
47
|
+
>
|
|
48
|
+
{supportedLanguages.map((lang) => {
|
|
49
|
+
const isActive = lang === language
|
|
50
|
+
const label = displayNames.of(lang) ?? languageDisplayName(lang, language)
|
|
51
|
+
|
|
52
|
+
if (isActive) {
|
|
53
|
+
return (
|
|
54
|
+
<View as="li" key={lang} style={{ width: '100%' }}>
|
|
55
|
+
<Text variant="small" as="span" aria-current="true" style={listItemStyle}>
|
|
56
|
+
{label}
|
|
57
|
+
</Text>
|
|
58
|
+
</View>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View as="li" key={lang} style={{ width: '100%' }}>
|
|
64
|
+
<Text
|
|
65
|
+
variant="small"
|
|
66
|
+
as="a"
|
|
67
|
+
href={getHref({ language: lang })}
|
|
68
|
+
style={{
|
|
69
|
+
...listItemStyle,
|
|
70
|
+
textDecoration: 'underline',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</Text>
|
|
76
|
+
</View>
|
|
77
|
+
)
|
|
78
|
+
})}
|
|
79
|
+
</View>
|
|
80
|
+
</View>
|
|
81
|
+
</View>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect } from 'react'
|
|
2
|
-
import { updateResourceContent } from '@ossy/resources'
|
|
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'
|
|
@@ -68,7 +68,7 @@ export const ThemeEditor = () => {
|
|
|
68
68
|
isEditorOpen && (
|
|
69
69
|
<Overlay isVisible={true} onClose={onToggle}>
|
|
70
70
|
<View layout="off-center" data-component="theme-editor-overlay">
|
|
71
|
-
<View
|
|
71
|
+
<View data-region="content" data-component="theme-editor-panel">
|
|
72
72
|
<View surface="primary" roundness="m" gap="m" inset="l">
|
|
73
73
|
<DevPagesPanel />
|
|
74
74
|
<View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
|
|
@@ -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,86 @@
|
|
|
1
|
+
import { slugToDisplayName } from '@ossy/package-catalog'
|
|
2
|
+
import { isServiceEntitled } from '@ossy/workspaces/entitlements'
|
|
3
|
+
import { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
|
|
4
|
+
import { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
|
|
5
|
+
|
|
6
|
+
export { resolvePackageHomePageId }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} SidebarShellItem
|
|
10
|
+
* @property {string} id
|
|
11
|
+
* @property {string} label
|
|
12
|
+
* @property {string} prefix
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} SidebarPackageItem
|
|
17
|
+
* @property {string} slug
|
|
18
|
+
* @property {string} label
|
|
19
|
+
* @property {string} icon
|
|
20
|
+
* @property {string} pageId
|
|
21
|
+
* @property {number} order
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build entitlement-filtered sidebar navigation — one link per entitled package
|
|
26
|
+
* that registers a `{slug}/home` page.
|
|
27
|
+
*
|
|
28
|
+
* @param {{
|
|
29
|
+
* manifestSummary?: { packages?: Array<{ slug?: string, package?: string, pages?: Array<{ id: string, title?: string, path?: string | Record<string, string> }> }> }
|
|
30
|
+
* workspaceServices?: Record<string, { enabled?: boolean }>
|
|
31
|
+
* definitions?: Record<string, object>
|
|
32
|
+
* isAuthenticated?: boolean
|
|
33
|
+
* workspaceId?: string
|
|
34
|
+
* devMode?: boolean
|
|
35
|
+
* devEntitlements?: Record<string, { enabled?: boolean }>
|
|
36
|
+
* shellItems?: SidebarShellItem[]
|
|
37
|
+
* excludeSlugs?: string[] | Set<string>
|
|
38
|
+
* }} options
|
|
39
|
+
* @returns {{ shellItems: SidebarShellItem[], packageItems: SidebarPackageItem[], services: Record<string, { enabled?: boolean }> }}
|
|
40
|
+
*/
|
|
41
|
+
export function buildSidebarNav ({
|
|
42
|
+
manifestSummary,
|
|
43
|
+
workspaceServices,
|
|
44
|
+
definitions = {},
|
|
45
|
+
isAuthenticated = false,
|
|
46
|
+
workspaceId,
|
|
47
|
+
devMode = false,
|
|
48
|
+
devEntitlements,
|
|
49
|
+
shellItems = [],
|
|
50
|
+
excludeSlugs = [],
|
|
51
|
+
}) {
|
|
52
|
+
const services = resolveWorkspaceServices({ devMode, devEntitlements, workspaceServices })
|
|
53
|
+
const excluded = excludeSlugs instanceof Set ? excludeSlugs : new Set(excludeSlugs)
|
|
54
|
+
|
|
55
|
+
const canShowProductGroups =
|
|
56
|
+
(isAuthenticated && workspaceId) ||
|
|
57
|
+
(devMode && devEntitlements && Object.keys(devEntitlements).length > 0)
|
|
58
|
+
|
|
59
|
+
const packageItems = !canShowProductGroups
|
|
60
|
+
? []
|
|
61
|
+
: (manifestSummary?.packages || [])
|
|
62
|
+
.filter((pkg) => pkg.slug && !excluded.has(pkg.slug))
|
|
63
|
+
.filter((pkg) => !definitions[pkg.slug]?.status?.includes('hidden'))
|
|
64
|
+
.filter((pkg) => {
|
|
65
|
+
const def = definitions[pkg.slug]
|
|
66
|
+
const entitlementRequired = def?.entitlementRequired !== false
|
|
67
|
+
return !entitlementRequired || isServiceEntitled(services, pkg.package)
|
|
68
|
+
})
|
|
69
|
+
.map((pkg) => {
|
|
70
|
+
const slug = pkg.slug
|
|
71
|
+
const def = definitions[slug]
|
|
72
|
+
const pageId = resolvePackageHomePageId(pkg.pages, slug)
|
|
73
|
+
if (!pageId) return null
|
|
74
|
+
return {
|
|
75
|
+
slug,
|
|
76
|
+
label: def?.title || slugToDisplayName(slug),
|
|
77
|
+
icon: def?.icon || 'package',
|
|
78
|
+
order: def?.navOrder ?? 100,
|
|
79
|
+
pageId,
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
|
|
84
|
+
|
|
85
|
+
return { shellItems, packageItems, services }
|
|
86
|
+
}
|
package/src/shell/index.js
CHANGED
|
@@ -7,3 +7,10 @@ export { useShellWorkspace } from './useShellWorkspace.js'
|
|
|
7
7
|
export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
|
|
8
8
|
export { resolveEndpoints } from './resolveEndpoints.js'
|
|
9
9
|
export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
|
|
10
|
+
export { useCompactShellLayout } from './useCompactShellLayout.js'
|
|
11
|
+
export { buildSidebarNav } from './buildSidebarNav.js'
|
|
12
|
+
export { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
|
|
13
|
+
export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
|
|
14
|
+
export { ThemeSelect } from './ThemeSelect.jsx'
|
|
15
|
+
export { LanguageSelect } from './LanguageSelect.jsx'
|
|
16
|
+
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,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the package home page for sidebar nav.
|
|
3
|
+
* Only `{slug}/home` qualifies — no fallback to other pages.
|
|
4
|
+
*
|
|
5
|
+
* @param {Array<{ id?: string }> | undefined} pages
|
|
6
|
+
* @param {string} slug
|
|
7
|
+
* @returns {string | null}
|
|
8
|
+
*/
|
|
9
|
+
export function resolvePackageHomePageId (pages, slug) {
|
|
10
|
+
if (!slug) return null
|
|
11
|
+
const homeId = `${slug}/home`
|
|
12
|
+
const match = (pages || []).find((page) => page?.id === homeId)
|
|
13
|
+
return match ? homeId : null
|
|
14
|
+
}
|
|
@@ -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
|
+
}
|