@ossy/app 1.40.3 → 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 +6 -0
- package/cli/get-platform-files.task.js +4 -6
- package/cli/manifest-plugin.js +100 -33
- package/package.json +22 -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/resolve-page-layout.js +51 -0
- package/src/manifest/serialize-package-definition.js +2 -3
- package/src/shell/App.jsx +13 -7
- package/src/shell/AppSettings.jsx +6 -0
- 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 +113 -0
- package/src/shell/index.js +6 -0
- package/src/shell/languageCode.js +31 -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
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { coerceShellSlotSpec, shellSlotViewId } from './merge-shell-slots.js'
|
|
3
|
+
|
|
1
4
|
/** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
|
|
2
5
|
export const APP_SLOT_NAMES = [
|
|
6
|
+
'app:head',
|
|
3
7
|
'app:header',
|
|
4
8
|
'app:sidebar',
|
|
5
9
|
'app:toolbar',
|
|
6
10
|
'app:notifications',
|
|
7
11
|
'app:system-messages',
|
|
12
|
+
'app:footer',
|
|
8
13
|
]
|
|
9
14
|
|
|
10
15
|
/** Platform-owned slot filled with the current route page component. */
|
|
@@ -14,14 +19,29 @@ const APP_SLOT_PREFIX = 'app'
|
|
|
14
19
|
|
|
15
20
|
/** Bare app region → namespaced key (legacy fallback for app components only). */
|
|
16
21
|
const BARE_APP_REGION = {
|
|
22
|
+
head: 'app:head',
|
|
17
23
|
header: 'app:header',
|
|
18
24
|
sidebar: 'app:sidebar',
|
|
19
25
|
toolbar: 'app:toolbar',
|
|
20
26
|
notifications: 'app:notifications',
|
|
21
27
|
'system-messages': 'app:system-messages',
|
|
28
|
+
footer: 'app:footer',
|
|
22
29
|
content: CONTENT_SLOT_NAME,
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* @param {import('react').ComponentType<any>} Component
|
|
34
|
+
* @param {Record<string, unknown>} defaultProps
|
|
35
|
+
* @returns {import('react').ComponentType<any>}
|
|
36
|
+
*/
|
|
37
|
+
function withShellSlotProps (Component, defaultProps) {
|
|
38
|
+
function ShellSlotTarget (props) {
|
|
39
|
+
return createElement(Component, { ...defaultProps, ...props })
|
|
40
|
+
}
|
|
41
|
+
ShellSlotTarget.displayName = `ShellSlot(${Component.displayName || Component.name || 'Component'})`
|
|
42
|
+
return ShellSlotTarget
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/**
|
|
26
46
|
* Normalize a slot map key to the canonical namespaced form when it is a bare app region.
|
|
27
47
|
*
|
|
@@ -34,10 +54,10 @@ export function normalizeAppSlotName (slotName) {
|
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
/**
|
|
37
|
-
* Build `Record<slotName, Component>` from
|
|
38
|
-
* (`slotName →
|
|
57
|
+
* Build `Record<slotName, Component>` from merged shell slot specs
|
|
58
|
+
* (`slotName → component id + default props`) and components loaded by `metadata.id`.
|
|
39
59
|
*
|
|
40
|
-
* @param {Record<string, string> | null | undefined} layoutSlotsMap
|
|
60
|
+
* @param {Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec | undefined> | null | undefined} layoutSlotsMap
|
|
41
61
|
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
42
62
|
* @returns {Record<string, import('react').ComponentType>}
|
|
43
63
|
*/
|
|
@@ -46,16 +66,33 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
|
|
|
46
66
|
const resolved = {}
|
|
47
67
|
|
|
48
68
|
const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
|
|
49
|
-
for (const [rawSlot,
|
|
69
|
+
for (const [rawSlot, rawSpec] of Object.entries(map)) {
|
|
50
70
|
const slotName = normalizeAppSlotName(rawSlot)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
71
|
+
if (!slotName || slotName === CONTENT_SLOT_NAME) continue
|
|
72
|
+
|
|
73
|
+
const spec = coerceShellSlotSpec(rawSpec) ?? (
|
|
74
|
+
rawSpec && typeof rawSpec === 'object' && !Array.isArray(rawSpec) ? rawSpec : undefined
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if (spec?.view === null) {
|
|
78
|
+
resolved[slotName] = null
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const componentId = shellSlotViewId(spec ?? rawSpec)
|
|
83
|
+
if (!componentId) continue
|
|
84
|
+
|
|
85
|
+
const Component = componentsById[componentId]
|
|
86
|
+
if (!Component) continue
|
|
87
|
+
|
|
88
|
+
const defaultProps = spec?.props ?? {}
|
|
89
|
+
resolved[slotName] = Object.keys(defaultProps).length > 0
|
|
90
|
+
? withShellSlotProps(Component, defaultProps)
|
|
91
|
+
: Component
|
|
55
92
|
}
|
|
56
93
|
|
|
57
94
|
for (const slotName of APP_SLOT_NAMES) {
|
|
58
|
-
if (resolved
|
|
95
|
+
if (slotName in resolved) continue
|
|
59
96
|
if (componentsById[slotName]) {
|
|
60
97
|
resolved[slotName] = componentsById[slotName]
|
|
61
98
|
continue
|
|
@@ -69,10 +106,10 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
|
|
|
69
106
|
|
|
70
107
|
/**
|
|
71
108
|
* Full provider slot map for a page request: app chrome and page content.
|
|
72
|
-
* Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form
|
|
109
|
+
* Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form|…`).
|
|
73
110
|
*
|
|
74
111
|
* @param {{
|
|
75
|
-
* layoutSlots?: Record<string, string> | null,
|
|
112
|
+
* layoutSlots?: Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec> | null,
|
|
76
113
|
* componentsById?: Record<string, import('react').ComponentType>,
|
|
77
114
|
* pageComponent?: import('react').ComponentType | null,
|
|
78
115
|
* }} options
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app.shell.sidebar.expand": "Expand sidebar",
|
|
3
|
+
"app.shell.sidebar.collapse": "Collapse sidebar",
|
|
4
|
+
"app.shell.header.languagePicker": "Language",
|
|
5
|
+
"app.shell.footer.languages": "Languages",
|
|
6
|
+
"app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
|
|
7
|
+
"app.shell.header.profile": "Profile"
|
|
8
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -21,9 +21,8 @@ export function serializePackageDefinition (definition) {
|
|
|
21
21
|
out.navOrder = definition.navOrder
|
|
22
22
|
}
|
|
23
23
|
if (definition.entitlementRequired === false) out.entitlementRequired = false
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
out.statuses = definition.statuses.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
24
|
+
if (Array.isArray(definition.status) && definition.status.length) {
|
|
25
|
+
out.status = definition.status.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
return Object.keys(out).length ? out : null
|
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
|
}
|
|
@@ -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,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
|
+
}
|