@ossy/app 1.39.6 → 1.39.7
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/cli/build.task.js +13 -8
- package/cli/manifest-plugin.js +60 -26
- package/package.json +12 -10
- package/runtime/page-runtime.js +18 -6
- package/runtime/resolve-shell-slots.js +112 -0
- package/src/manifest/build-manifest-summary.js +110 -0
- package/src/shell/App.jsx +1 -1
- package/src/shell/AppSettings.jsx +2 -0
- package/src/shell/DevPagesPanel.jsx +128 -0
- package/src/shell/ThemeEditor.jsx +36 -65
- package/src/shell/devPagesUtils.js +53 -0
package/cli/build.task.js
CHANGED
|
@@ -137,14 +137,8 @@ function generateResourceStub ({ stubAbs, sourceAbs }) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// Components are re-exported as-is — the manifest plugin reads `metadata.id`
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// The `slot` export (when present) identifies the named shell slot this
|
|
143
|
-
// component fills automatically — see PlatformShell for the slot registry.
|
|
144
|
-
// We use `export *` for named exports rather than an explicit list because
|
|
145
|
-
// `slot` is optional: some components don't export it, and a static named
|
|
146
|
-
// re-export of a binding that doesn't exist causes a Rollup MISSING_EXPORT
|
|
147
|
-
// error at build time.
|
|
140
|
+
// from the bundled output. Shell placement is app-controlled via `export const slots`
|
|
141
|
+
// in `*.layout.jsx`, not `export const slot` on components.
|
|
148
142
|
function generateComponentStub ({ stubAbs, sourceAbs }) {
|
|
149
143
|
const importPath = relImport(stubAbs, sourceAbs)
|
|
150
144
|
return [
|
|
@@ -261,6 +255,15 @@ async function loadConfig (configPath) {
|
|
|
261
255
|
return mod.default || {}
|
|
262
256
|
}
|
|
263
257
|
|
|
258
|
+
function readAppPackageName (cwd) {
|
|
259
|
+
try {
|
|
260
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
|
|
261
|
+
return typeof pkgJson.name === 'string' && pkgJson.name.trim() ? pkgJson.name : '@ossy/app'
|
|
262
|
+
} catch {
|
|
263
|
+
return '@ossy/app'
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
264
267
|
export async function build (cliArgs = []) {
|
|
265
268
|
arg(
|
|
266
269
|
{
|
|
@@ -317,6 +320,7 @@ export async function build (cliArgs = []) {
|
|
|
317
320
|
|
|
318
321
|
const configPath = path.resolve(srcDir, 'config.js')
|
|
319
322
|
const configValue = await loadConfig(configPath)
|
|
323
|
+
const appPackageName = readAppPackageName(cwd)
|
|
320
324
|
|
|
321
325
|
const middlewareSrc = path.resolve(srcDir, 'middleware.js')
|
|
322
326
|
if (fs.existsSync(middlewareSrc)) {
|
|
@@ -374,6 +378,7 @@ export async function build (cliArgs = []) {
|
|
|
374
378
|
srcDir,
|
|
375
379
|
staticOutDir,
|
|
376
380
|
configValue,
|
|
381
|
+
appPackageName,
|
|
377
382
|
manifestPath: path.join(buildPath, 'manifest.json'),
|
|
378
383
|
}),
|
|
379
384
|
],
|
package/cli/manifest-plugin.js
CHANGED
|
@@ -24,6 +24,9 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
24
24
|
* The serializable config object (typically `src/config.js`'s default
|
|
25
25
|
* export). Inlined under `manifest.config` so the platform server doesn't
|
|
26
26
|
* need a runtime `import('./config.js')`.
|
|
27
|
+
* @property {string} appPackageName
|
|
28
|
+
* npm package name of the app being built (e.g. `@ossy/app-test`). Used as
|
|
29
|
+
* the `package` field on manifest entries that live in the app `src/` tree.
|
|
27
30
|
* @property {string} manifestPath
|
|
28
31
|
* Absolute path to write the manifest to (typically `build/manifest.json`).
|
|
29
32
|
*
|
|
@@ -41,12 +44,6 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
41
44
|
* @typedef {object} ComponentManifestEntry
|
|
42
45
|
* @property {string} id Unique component id (from `metadata.id`).
|
|
43
46
|
* @property {string} entry URL the platform serves the component bundle from.
|
|
44
|
-
* @property {string} [slot] Named shell slot this component fills (from `export const slot`).
|
|
45
|
-
*
|
|
46
|
-
* @typedef {object} SlotManifestEntry
|
|
47
|
-
* @property {string} slot Named shell slot (e.g. `'header'`, `'sidebar'`, `'resource:booking/service/form'`).
|
|
48
|
-
* @property {string} entry URL the platform serves the component bundle from.
|
|
49
|
-
* @property {string} [package] npm package name that contributed this slot (from installed packages).
|
|
50
47
|
*
|
|
51
48
|
* @typedef {object} AggregateManifestEntry
|
|
52
49
|
* @property {string} id AggregateType string (e.g. `'User'`).
|
|
@@ -73,6 +70,8 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
73
70
|
* @typedef {object} LayoutManifestEntry
|
|
74
71
|
* @property {string} id Unique layout id (e.g. `'app-shell'`).
|
|
75
72
|
* @property {string} entry URL the platform serves the layout bundle from.
|
|
73
|
+
* @property {Record<string, string>} [slots]
|
|
74
|
+
* App-controlled slot map (`slotName` → component `metadata.id`), from `export const slots` in `*.layout.jsx`.
|
|
76
75
|
*
|
|
77
76
|
* @typedef {object} E2eManifestEntry
|
|
78
77
|
* @property {string} id Unique test id (e.g. `'authentication/sign-in'`).
|
|
@@ -83,7 +82,6 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
83
82
|
* @typedef {object} Manifest
|
|
84
83
|
* @property {ManifestEntry[]} entries Flat, ordered list of every routable thing.
|
|
85
84
|
* @property {ComponentManifestEntry[]} components Discovered `*.component.*` entries, keyed by id.
|
|
86
|
-
* @property {SlotManifestEntry[]} slots Components that declare `export const slot` — auto-injected into shell slots.
|
|
87
85
|
* @property {object[]} resourceTemplates Each `*.resource.js` file's default-exported template object.
|
|
88
86
|
* @property {AggregateManifestEntry[]} aggregates Discovered `*.aggregate.js` entries from installed packages.
|
|
89
87
|
* @property {IntegrationManifestEntry[]} integrations Discovered `*.integration.js` entries.
|
|
@@ -91,7 +89,7 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
91
89
|
* @property {EmailManifestEntry[]} emails Discovered `*.email.{jsx,tsx}` entries.
|
|
92
90
|
* @property {ActionManifestEntry[]} actions Discovered `*.action.js` entries.
|
|
93
91
|
* @property {E2eManifestEntry[]} e2es Discovered `*.e2e.js` entries from installed packages.
|
|
94
|
-
* @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries.
|
|
92
|
+
* @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries (includes app `slots` map).
|
|
95
93
|
* @property {object} config Inlined `src/config.js` default export.
|
|
96
94
|
*/
|
|
97
95
|
|
|
@@ -129,7 +127,11 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
|
|
|
129
127
|
* @param {ManifestPluginOptions} options
|
|
130
128
|
* @returns {import('rollup').Plugin}
|
|
131
129
|
*/
|
|
132
|
-
|
|
130
|
+
function entryPackage (entryInfo, appPackageName) {
|
|
131
|
+
return entryInfo.packageName || appPackageName
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, appPackageName = '@ossy/app', manifestPath }) {
|
|
133
135
|
return {
|
|
134
136
|
name: 'ossy-manifest',
|
|
135
137
|
async writeBundle (_, bundle) {
|
|
@@ -156,8 +158,6 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
156
158
|
const seenStartupIds = new Set()
|
|
157
159
|
const seenEmailIds = new Set()
|
|
158
160
|
const seenActionIds = new Set()
|
|
159
|
-
/** @type {SlotManifestEntry[]} */
|
|
160
|
-
const slots = []
|
|
161
161
|
/** @type {E2eManifestEntry[]} */
|
|
162
162
|
const e2es = []
|
|
163
163
|
const seenE2eIds = new Set()
|
|
@@ -213,7 +213,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
213
213
|
this.error(`[@ossy/app][build] Duplicate integration id "${integrationId}"`)
|
|
214
214
|
}
|
|
215
215
|
seenIntegrationIds.add(integrationId)
|
|
216
|
-
integrations.push({ id: integrationId, entry: url, credentials })
|
|
216
|
+
integrations.push({ id: integrationId, entry: url, credentials, package: entryPackage(entryInfo, appPackageName) })
|
|
217
217
|
continue
|
|
218
218
|
}
|
|
219
219
|
|
|
@@ -236,7 +236,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
236
236
|
this.error(`[@ossy/app][build] Duplicate startup id "${startupId}"`)
|
|
237
237
|
}
|
|
238
238
|
seenStartupIds.add(startupId)
|
|
239
|
-
startups.push({ id: startupId, entry: url })
|
|
239
|
+
startups.push({ id: startupId, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
240
240
|
continue
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -260,7 +260,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
260
260
|
this.error(`[@ossy/app][build] Duplicate email id "${emailId}"`)
|
|
261
261
|
}
|
|
262
262
|
seenEmailIds.add(emailId)
|
|
263
|
-
emails.push({ id: emailId, entry: url })
|
|
263
|
+
emails.push({ id: emailId, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
264
264
|
continue
|
|
265
265
|
}
|
|
266
266
|
|
|
@@ -284,7 +284,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
284
284
|
}
|
|
285
285
|
const access = mod.access ?? 'authenticated'
|
|
286
286
|
seenActionIds.add(actionId)
|
|
287
|
-
actions.push({ id: actionId, entry: url, access })
|
|
287
|
+
actions.push({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) })
|
|
288
288
|
continue
|
|
289
289
|
}
|
|
290
290
|
|
|
@@ -308,7 +308,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
308
308
|
this.error(`[@ossy/app][build] Duplicate aggregate id "${aggregateId}"`)
|
|
309
309
|
}
|
|
310
310
|
seenAggregateIds.add(aggregateId)
|
|
311
|
-
aggregates.push({ id: aggregateId, entry: url })
|
|
311
|
+
aggregates.push({ id: aggregateId, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
312
312
|
continue
|
|
313
313
|
}
|
|
314
314
|
|
|
@@ -341,6 +341,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
341
341
|
feature: typeof feature === 'string' ? feature : '',
|
|
342
342
|
requires: Array.isArray(requires) ? requires : [],
|
|
343
343
|
entry: url,
|
|
344
|
+
package: entryPackage(entryInfo, appPackageName),
|
|
344
345
|
})
|
|
345
346
|
continue
|
|
346
347
|
}
|
|
@@ -366,7 +367,38 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
366
367
|
this.error(`[@ossy/app][build] Duplicate layout id "${layoutId}"`)
|
|
367
368
|
}
|
|
368
369
|
seenLayoutIds.add(layoutId)
|
|
369
|
-
|
|
370
|
+
/** @type {Record<string, string> | undefined} */
|
|
371
|
+
let layoutSlots
|
|
372
|
+
const rawSlots = mod.slots
|
|
373
|
+
if (rawSlots != null) {
|
|
374
|
+
if (typeof rawSlots !== 'object' || Array.isArray(rawSlots)) {
|
|
375
|
+
this.error(
|
|
376
|
+
`[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export \`slots\` as a plain object (slot name → component id)`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
layoutSlots = {}
|
|
380
|
+
for (const [slotKey, componentId] of Object.entries(rawSlots)) {
|
|
381
|
+
if (typeof slotKey !== 'string' || typeof componentId !== 'string') {
|
|
382
|
+
this.error(
|
|
383
|
+
`[@ossy/app][build] layout slots in ${entryInfo.sourcePath} must map string slot names to string component ids`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
const sk = slotKey.trim()
|
|
387
|
+
const cid = componentId.trim()
|
|
388
|
+
if (!sk || !cid) {
|
|
389
|
+
this.error(
|
|
390
|
+
`[@ossy/app][build] layout slots in ${entryInfo.sourcePath} must use non-empty slot names and component ids`,
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
layoutSlots[sk] = cid
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
layouts.push({
|
|
397
|
+
id: layoutId,
|
|
398
|
+
entry: url,
|
|
399
|
+
package: entryPackage(entryInfo, appPackageName),
|
|
400
|
+
...(layoutSlots && Object.keys(layoutSlots).length > 0 ? { slots: layoutSlots } : {}),
|
|
401
|
+
})
|
|
370
402
|
continue
|
|
371
403
|
}
|
|
372
404
|
|
|
@@ -390,7 +422,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
390
422
|
this.error(`[@ossy/app][build] Duplicate resource template id "${template.id}"`)
|
|
391
423
|
}
|
|
392
424
|
seenResourceIds.add(template.id)
|
|
393
|
-
resourceTemplates.push(template)
|
|
425
|
+
resourceTemplates.push({ ...template, package: entryPackage(entryInfo, appPackageName) })
|
|
394
426
|
continue
|
|
395
427
|
}
|
|
396
428
|
|
|
@@ -405,12 +437,13 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
405
437
|
seenIds[entryInfo.kind].add(id)
|
|
406
438
|
|
|
407
439
|
if (entryInfo.kind === 'component') {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
440
|
+
if (mod.slot != null) {
|
|
441
|
+
this.error(
|
|
442
|
+
`[@ossy/app][build] component ${entryInfo.sourcePath} must not export \`slot\`. ` +
|
|
443
|
+
'Assign components to shell slots in `*.layout.jsx` via `export const slots`.',
|
|
444
|
+
)
|
|
413
445
|
}
|
|
446
|
+
components.push({ id, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
414
447
|
continue
|
|
415
448
|
}
|
|
416
449
|
|
|
@@ -422,6 +455,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
422
455
|
path: pagePath,
|
|
423
456
|
title: rawMeta.title,
|
|
424
457
|
entry: url,
|
|
458
|
+
package: entryPackage(entryInfo, appPackageName),
|
|
425
459
|
})
|
|
426
460
|
} else if (entryInfo.kind === 'api') {
|
|
427
461
|
// APIs have no sensible default route (unlike pages where we can
|
|
@@ -435,9 +469,9 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
435
469
|
`Add a \`path\` to the metadata or default-export object (e.g. \`path: '/api/${id}'\`).`,
|
|
436
470
|
)
|
|
437
471
|
}
|
|
438
|
-
entries.push({ type: 'api', id, path: rawMeta.path, entry: url })
|
|
472
|
+
entries.push({ type: 'api', id, path: rawMeta.path, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
439
473
|
} else {
|
|
440
|
-
entries.push({ type: 'task', id, entry: url })
|
|
474
|
+
entries.push({ type: 'task', id, entry: url, package: entryPackage(entryInfo, appPackageName) })
|
|
441
475
|
}
|
|
442
476
|
}
|
|
443
477
|
|
|
@@ -449,7 +483,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
|
|
|
449
483
|
}
|
|
450
484
|
|
|
451
485
|
/** @type {Manifest} */
|
|
452
|
-
const manifest = { entries, components,
|
|
486
|
+
const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
|
|
453
487
|
fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
|
|
454
488
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
|
|
455
489
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/app",
|
|
3
|
-
"version": "1.39.
|
|
3
|
+
"version": "1.39.7",
|
|
4
4
|
"description": "",
|
|
5
5
|
"source": "./src/index.js",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
".": "./src/index.js",
|
|
10
10
|
"./shell": "./src/shell/index.js",
|
|
11
11
|
"./runtime/page-runtime": "./runtime/page-runtime.js",
|
|
12
|
+
"./runtime/resolve-shell-slots": "./runtime/resolve-shell-slots.js",
|
|
12
13
|
"./runtime/api-runtime": "./runtime/api-runtime.js",
|
|
13
|
-
"./runtime/task-runtime": "./runtime/task-runtime.js"
|
|
14
|
+
"./runtime/task-runtime": "./runtime/task-runtime.js",
|
|
15
|
+
"./manifest/build-manifest-summary": "./src/manifest/build-manifest-summary.js"
|
|
14
16
|
},
|
|
15
17
|
"bin": {
|
|
16
18
|
"app": "./cli/index.js"
|
|
@@ -38,14 +40,14 @@
|
|
|
38
40
|
"@babel/eslint-parser": "^7.15.8",
|
|
39
41
|
"@babel/preset-react": "^7.26.3",
|
|
40
42
|
"@babel/register": "^7.25.9",
|
|
41
|
-
"@ossy/design-system": "^1.39.
|
|
43
|
+
"@ossy/design-system": "^1.39.7",
|
|
42
44
|
"@ossy/pages": "^1.23.0",
|
|
43
|
-
"@ossy/platform": "^1.38.
|
|
44
|
-
"@ossy/router": "^1.39.
|
|
45
|
-
"@ossy/router-react": "^1.39.
|
|
46
|
-
"@ossy/sdk": "^1.39.
|
|
47
|
-
"@ossy/sdk-react": "^1.39.
|
|
48
|
-
"@ossy/themes": "^1.39.
|
|
45
|
+
"@ossy/platform": "^1.38.7",
|
|
46
|
+
"@ossy/router": "^1.39.7",
|
|
47
|
+
"@ossy/router-react": "^1.39.7",
|
|
48
|
+
"@ossy/sdk": "^1.39.7",
|
|
49
|
+
"@ossy/sdk-react": "^1.39.7",
|
|
50
|
+
"@ossy/themes": "^1.39.7",
|
|
49
51
|
"@rollup/plugin-alias": "^6.0.0",
|
|
50
52
|
"@rollup/plugin-babel": "^7.0.0",
|
|
51
53
|
"@rollup/plugin-commonjs": "^29.0.0",
|
|
@@ -79,5 +81,5 @@
|
|
|
79
81
|
"README.md",
|
|
80
82
|
"tsconfig.json"
|
|
81
83
|
],
|
|
82
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "f65df0cd6da864c314c4f424112e05e8e2dec0ff"
|
|
83
85
|
}
|
package/runtime/page-runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createElement } from 'react'
|
|
2
2
|
import { App } from '../src/shell/App.jsx'
|
|
3
|
+
import { resolvePageSlots } from './resolve-shell-slots.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Dynamically imports each component bundle listed in `entries` and returns a
|
|
@@ -47,8 +48,9 @@ export async function loadLayout (layoutEntry) {
|
|
|
47
48
|
|
|
48
49
|
function buildTree ({ Component, Layout, metadata, props }) {
|
|
49
50
|
const lang = props.htmlLang || props.defaultLanguage || 'en'
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const contentEl = Layout
|
|
52
|
+
? createElement(Layout, props)
|
|
53
|
+
: createElement(Component, props)
|
|
52
54
|
return createElement(
|
|
53
55
|
'html',
|
|
54
56
|
{ lang },
|
|
@@ -96,8 +98,13 @@ export function createPageEntry (pageModule, options = {}) {
|
|
|
96
98
|
import('node:stream'),
|
|
97
99
|
])
|
|
98
100
|
|
|
99
|
-
const { Layout = null, componentEntries = [], ...pageProps } = props
|
|
100
|
-
const
|
|
101
|
+
const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
|
|
102
|
+
const componentsById = await loadComponents(componentEntries)
|
|
103
|
+
const components = resolvePageSlots({
|
|
104
|
+
layoutSlots,
|
|
105
|
+
componentsById,
|
|
106
|
+
pageComponent: Component,
|
|
107
|
+
})
|
|
101
108
|
const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
|
|
102
109
|
const bootstrapUrl = toBootstrapUrl(entryUrl)
|
|
103
110
|
const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
|
|
@@ -133,12 +140,17 @@ export function createPageEntry (pageModule, options = {}) {
|
|
|
133
140
|
if (typeof document === 'undefined' || typeof window === 'undefined') return
|
|
134
141
|
hydrated = true
|
|
135
142
|
const props = window.__OSSY__ || {}
|
|
136
|
-
const { layoutEntry = null, componentEntries = [], ...pageProps } = props
|
|
143
|
+
const { layoutEntry = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
|
|
137
144
|
Promise.all([
|
|
138
145
|
import('react-dom/client'),
|
|
139
146
|
loadComponents(componentEntries),
|
|
140
147
|
loadLayout(layoutEntry),
|
|
141
|
-
]).then(([{ hydrateRoot },
|
|
148
|
+
]).then(([{ hydrateRoot }, componentsById, Layout]) => {
|
|
149
|
+
const components = resolvePageSlots({
|
|
150
|
+
layoutSlots,
|
|
151
|
+
componentsById,
|
|
152
|
+
pageComponent: Component,
|
|
153
|
+
})
|
|
142
154
|
hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
|
|
143
155
|
})
|
|
144
156
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Canonical shell slot names (namespaced). App layout maps chrome only — not content. */
|
|
2
|
+
export const SHELL_SLOT_NAMES = [
|
|
3
|
+
'shell:header',
|
|
4
|
+
'shell:sidebar',
|
|
5
|
+
'shell:toolbar',
|
|
6
|
+
'shell:notifications',
|
|
7
|
+
'shell:system-messages',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** Platform-owned slot filled with the current route page component. */
|
|
11
|
+
export const CONTENT_SLOT_NAME = 'shell:content'
|
|
12
|
+
|
|
13
|
+
/** Bare shell region → namespaced key (legacy fallback for app components only). */
|
|
14
|
+
const BARE_SHELL_REGION = {
|
|
15
|
+
header: 'shell:header',
|
|
16
|
+
sidebar: 'shell:sidebar',
|
|
17
|
+
toolbar: 'shell:toolbar',
|
|
18
|
+
notifications: 'shell:notifications',
|
|
19
|
+
'system-messages': 'shell:system-messages',
|
|
20
|
+
content: CONTENT_SLOT_NAME,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a slot map key to the canonical namespaced form when it is a bare shell region.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} slotName
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeShellSlotName (slotName) {
|
|
30
|
+
const key = typeof slotName === 'string' ? slotName.trim() : ''
|
|
31
|
+
return BARE_SHELL_REGION[key] ?? key
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build `Record<slotName, Component>` from the app layout's static `slots` map
|
|
36
|
+
* (`slotName → componentId`) and components loaded by `metadata.id`.
|
|
37
|
+
*
|
|
38
|
+
* One component per slot. Optional fallback (app components only): if the layout
|
|
39
|
+
* map omits a slot, use a component whose id equals the slot name (e.g. `shell:header`)
|
|
40
|
+
* or the bare region name (e.g. `header`).
|
|
41
|
+
*
|
|
42
|
+
* Does not resolve `shell:content` — use {@link resolvePageSlots}.
|
|
43
|
+
*
|
|
44
|
+
* @param {Record<string, string> | null | undefined} layoutSlotsMap
|
|
45
|
+
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
46
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveShellSlots (layoutSlotsMap, componentsById) {
|
|
49
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
50
|
+
const resolved = {}
|
|
51
|
+
|
|
52
|
+
const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
|
|
53
|
+
for (const [rawSlot, componentId] of Object.entries(map)) {
|
|
54
|
+
const slotName = normalizeShellSlotName(rawSlot)
|
|
55
|
+
const id = typeof componentId === 'string' ? componentId.trim() : ''
|
|
56
|
+
if (!slotName || !id || slotName === CONTENT_SLOT_NAME) continue
|
|
57
|
+
const Component = componentsById[id]
|
|
58
|
+
if (Component) resolved[slotName] = Component
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const slotName of SHELL_SLOT_NAMES) {
|
|
62
|
+
if (resolved[slotName]) continue
|
|
63
|
+
if (componentsById[slotName]) {
|
|
64
|
+
resolved[slotName] = componentsById[slotName]
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const bare = slotName.slice('shell:'.length)
|
|
68
|
+
if (componentsById[bare]) resolved[slotName] = componentsById[bare]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return resolved
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const RESOURCE_SLOT_PREFIX = 'resource:'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Map manifest components whose `metadata.id` is a `resource:{type}/{view}` key.
|
|
78
|
+
*
|
|
79
|
+
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
80
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveResourceSlots (componentsById) {
|
|
83
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
84
|
+
const resolved = {}
|
|
85
|
+
for (const [id, Component] of Object.entries(componentsById || {})) {
|
|
86
|
+
if (typeof id === 'string' && id.startsWith(RESOURCE_SLOT_PREFIX) && Component) {
|
|
87
|
+
resolved[id] = Component
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return resolved
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Full provider slot map for a page request: shell chrome, resource views, and page content.
|
|
95
|
+
*
|
|
96
|
+
* @param {{
|
|
97
|
+
* layoutSlots?: Record<string, string> | null,
|
|
98
|
+
* componentsById?: Record<string, import('react').ComponentType>,
|
|
99
|
+
* pageComponent?: import('react').ComponentType | null,
|
|
100
|
+
* }} options
|
|
101
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
102
|
+
*/
|
|
103
|
+
export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
|
|
104
|
+
const shellSlots = resolveShellSlots(layoutSlots, componentsById)
|
|
105
|
+
const resourceSlots = resolveResourceSlots(componentsById)
|
|
106
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
107
|
+
const resolved = { ...componentsById, ...shellSlots, ...resourceSlots }
|
|
108
|
+
if (pageComponent) {
|
|
109
|
+
resolved[CONTENT_SLOT_NAME] = pageComponent
|
|
110
|
+
}
|
|
111
|
+
return resolved
|
|
112
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} packageName npm package name (e.g. `@ossy/booking`).
|
|
3
|
+
* @returns {string} URL slug (e.g. `booking`).
|
|
4
|
+
*/
|
|
5
|
+
export function packageNameToSlug (packageName) {
|
|
6
|
+
const slash = packageName.indexOf('/')
|
|
7
|
+
return slash === -1 ? packageName : packageName.slice(slash + 1)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} slug URL slug (e.g. `booking`).
|
|
12
|
+
* @returns {string} Scoped package name (e.g. `@ossy/booking`).
|
|
13
|
+
*/
|
|
14
|
+
export function slugToPackageName (slug) {
|
|
15
|
+
return `@ossy/${slug}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} manifest Loaded manifest from `@ossy/platform` `loadManifest`.
|
|
20
|
+
* @returns {{ packages: Array<object> }} Grouped, JSON-serializable package summary.
|
|
21
|
+
*/
|
|
22
|
+
export function buildManifestSummary (manifest) {
|
|
23
|
+
/** @type {Map<string, object>} */
|
|
24
|
+
const groups = new Map()
|
|
25
|
+
|
|
26
|
+
const ensure = (pkg) => {
|
|
27
|
+
if (!groups.has(pkg)) {
|
|
28
|
+
groups.set(pkg, {
|
|
29
|
+
package: pkg,
|
|
30
|
+
slug: packageNameToSlug(pkg),
|
|
31
|
+
pages: [],
|
|
32
|
+
apis: [],
|
|
33
|
+
actions: [],
|
|
34
|
+
components: [],
|
|
35
|
+
resourceTemplates: [],
|
|
36
|
+
tasks: [],
|
|
37
|
+
integrations: [],
|
|
38
|
+
emails: [],
|
|
39
|
+
aggregates: [],
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
return groups.get(pkg)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const add = (pkg, key, item) => {
|
|
46
|
+
ensure(pkg || '@ossy/app')[key].push(item)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const page of manifest.pages || []) {
|
|
50
|
+
add(page.package, 'pages', {
|
|
51
|
+
id: page.id,
|
|
52
|
+
path: page.path,
|
|
53
|
+
...(page.title ? { title: page.title } : {}),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const api of manifest.apis || []) {
|
|
58
|
+
add(api.package, 'apis', { id: api.id, path: api.path })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const action of manifest.actions || []) {
|
|
62
|
+
add(action.package, 'actions', {
|
|
63
|
+
id: action.id,
|
|
64
|
+
...(action.access ? { access: action.access } : {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const component of manifest.components || []) {
|
|
69
|
+
add(component.package, 'components', { id: component.id })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const template of manifest.resourceTemplates || []) {
|
|
73
|
+
add(template.package, 'resourceTemplates', {
|
|
74
|
+
id: template.id,
|
|
75
|
+
...(template.title ? { title: template.title } : {}),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const task of manifest.tasks || []) {
|
|
80
|
+
add(task.package, 'tasks', { id: task.id })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const integration of manifest.integrations || []) {
|
|
84
|
+
add(integration.package, 'integrations', {
|
|
85
|
+
id: integration.id,
|
|
86
|
+
...(integration.credentials?.length ? { credentials: integration.credentials } : {}),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const email of manifest.emails || []) {
|
|
91
|
+
add(email.package, 'emails', { id: email.id })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const aggregate of manifest.aggregates || []) {
|
|
95
|
+
add(aggregate.package, 'aggregates', { id: aggregate.id })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const packages = Array.from(groups.values())
|
|
99
|
+
.sort((a, b) => a.package.localeCompare(b.package))
|
|
100
|
+
|
|
101
|
+
for (const pkg of packages) {
|
|
102
|
+
for (const key of Object.keys(pkg)) {
|
|
103
|
+
if (Array.isArray(pkg[key])) {
|
|
104
|
+
pkg[key].sort((a, b) => a.id.localeCompare(b.id))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { packages }
|
|
110
|
+
}
|
package/src/shell/App.jsx
CHANGED
|
@@ -26,8 +26,8 @@ export const App = ({ children, ..._appSettings }) => {
|
|
|
26
26
|
<WorkspaceProvider sdk={sdk}>
|
|
27
27
|
<Router {...appSettings} pages={appSettings.pages || []}>
|
|
28
28
|
{children}
|
|
29
|
+
{appSettings.devMode && <ThemeEditor />}
|
|
29
30
|
</Router>
|
|
30
|
-
{appSettings.devMode && <ThemeEditor />}
|
|
31
31
|
</WorkspaceProvider>
|
|
32
32
|
</Theme>
|
|
33
33
|
</ComponentSlotsProvider>
|
|
@@ -25,5 +25,7 @@ export function defaultAppSettings() {
|
|
|
25
25
|
faviconHref: undefined,
|
|
26
26
|
/** When true, main app sidebar is collapsed to icons (from server cookie / app-settings). */
|
|
27
27
|
sidebarPrimaryCollapsed: false,
|
|
28
|
+
/** Grouped manifest entries by npm package — for dev tooling. */
|
|
29
|
+
manifestSummary: undefined,
|
|
28
30
|
}
|
|
29
31
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Button, Text, View } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
import { useApp } from './AppContext.js'
|
|
5
|
+
import { formatPagePaths, groupPagesByFeature } from './devPagesUtils.js'
|
|
6
|
+
import { packageNameToSlug } from '../manifest/build-manifest-summary.js'
|
|
7
|
+
|
|
8
|
+
const monoStyle = {
|
|
9
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
10
|
+
fontSize: '0.8125rem',
|
|
11
|
+
lineHeight: 1.4,
|
|
12
|
+
wordBreak: 'break-all',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const currentPageStyles = {
|
|
16
|
+
padding: 'var(--space-s)',
|
|
17
|
+
borderRadius: 'var(--space-xs)',
|
|
18
|
+
background: 'var(--surface-secondary, hsla(0, 0%, 0%, 0.04))',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DevPagesPanel = () => {
|
|
22
|
+
const app = useApp()
|
|
23
|
+
const router = useRouter()
|
|
24
|
+
const pages = app?.pages || []
|
|
25
|
+
|
|
26
|
+
const groups = useMemo(() => groupPagesByFeature(pages), [pages])
|
|
27
|
+
|
|
28
|
+
const currentPage = useMemo(
|
|
29
|
+
() => pages.find((p) => p.id === app?.pageId),
|
|
30
|
+
[pages, app?.pageId],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const hasPackageViewer = useMemo(
|
|
34
|
+
() => pages.some((p) => p.id === 'packages/detail'),
|
|
35
|
+
[pages],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const packageDetailHref = useMemo(() => {
|
|
39
|
+
if (!hasPackageViewer) return null
|
|
40
|
+
const pkg = currentPage?.package
|
|
41
|
+
if (!pkg) return null
|
|
42
|
+
return router.getHref({
|
|
43
|
+
id: 'packages/detail',
|
|
44
|
+
params: { packageSlug: packageNameToSlug(pkg) },
|
|
45
|
+
})
|
|
46
|
+
}, [currentPage?.package, hasPackageViewer, router])
|
|
47
|
+
|
|
48
|
+
const currentPathLabel = useMemo(() => {
|
|
49
|
+
if (currentPage?.path) return formatPagePaths(currentPage.path)
|
|
50
|
+
const url = app?.url || router.href
|
|
51
|
+
if (!url) return '—'
|
|
52
|
+
const pathname = url.split('?')[0].split('#')[0]
|
|
53
|
+
return pathname || '—'
|
|
54
|
+
}, [currentPage, router.href, app?.url])
|
|
55
|
+
|
|
56
|
+
const currentUrl = app?.url || router.href || '—'
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View gap="m">
|
|
60
|
+
<Text variant="title-tertiary">Dev</Text>
|
|
61
|
+
|
|
62
|
+
<View gap="xs" style={currentPageStyles}>
|
|
63
|
+
<Text variant="title-tertiary" style={{ marginBottom: 'var(--space-xs)' }}>
|
|
64
|
+
Current page
|
|
65
|
+
</Text>
|
|
66
|
+
<Text style={monoStyle}>
|
|
67
|
+
<strong>id:</strong> {app?.pageId || '—'}
|
|
68
|
+
</Text>
|
|
69
|
+
<Text style={monoStyle}>
|
|
70
|
+
<strong>path:</strong> {currentPathLabel}
|
|
71
|
+
</Text>
|
|
72
|
+
<Text style={monoStyle}>
|
|
73
|
+
<strong>url:</strong> {currentUrl}
|
|
74
|
+
</Text>
|
|
75
|
+
{packageDetailHref && currentPage?.package && (
|
|
76
|
+
<Button
|
|
77
|
+
variant="link"
|
|
78
|
+
href={packageDetailHref}
|
|
79
|
+
style={{ alignSelf: 'flex-start', padding: 0, height: 'auto' }}
|
|
80
|
+
>
|
|
81
|
+
View package ({currentPage.package})
|
|
82
|
+
</Button>
|
|
83
|
+
)}
|
|
84
|
+
</View>
|
|
85
|
+
|
|
86
|
+
<View gap="s">
|
|
87
|
+
<Text variant="title-tertiary">
|
|
88
|
+
Pages ({pages.length})
|
|
89
|
+
</Text>
|
|
90
|
+
|
|
91
|
+
{groups.map(({ key, label, pages: groupPages }) => (
|
|
92
|
+
<View key={key} gap="xs">
|
|
93
|
+
<Text style={{ fontWeight: 700, fontSize: '0.875rem' }}>{label}</Text>
|
|
94
|
+
<View gap="xs">
|
|
95
|
+
{groupPages.map((page) => {
|
|
96
|
+
const href = router.getHref(page.id)
|
|
97
|
+
const selected = page.id === app?.pageId
|
|
98
|
+
return (
|
|
99
|
+
<Button
|
|
100
|
+
key={page.id}
|
|
101
|
+
href={href}
|
|
102
|
+
variant={selected ? 'tag-active' : 'link'}
|
|
103
|
+
aria-current={selected ? 'page' : undefined}
|
|
104
|
+
style={{
|
|
105
|
+
display: 'block',
|
|
106
|
+
textAlign: 'left',
|
|
107
|
+
width: '100%',
|
|
108
|
+
padding: 'var(--space-xs) var(--space-s)',
|
|
109
|
+
height: 'auto',
|
|
110
|
+
whiteSpace: 'normal',
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<View gap="2px" style={{ alignItems: 'flex-start' }}>
|
|
114
|
+
<Text style={{ ...monoStyle, fontWeight: 600 }}>{page.id}</Text>
|
|
115
|
+
<Text style={{ ...monoStyle, opacity: 0.85 }}>
|
|
116
|
+
{formatPagePaths(page.path)}
|
|
117
|
+
</Text>
|
|
118
|
+
</View>
|
|
119
|
+
</Button>
|
|
120
|
+
)
|
|
121
|
+
})}
|
|
122
|
+
</View>
|
|
123
|
+
</View>
|
|
124
|
+
))}
|
|
125
|
+
</View>
|
|
126
|
+
</View>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -1,47 +1,28 @@
|
|
|
1
1
|
import React, { useState, useMemo, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useResource } from '@ossy/sdk-react'
|
|
3
3
|
import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
|
|
4
|
-
import {
|
|
4
|
+
import { DevPagesPanel } from './DevPagesPanel.jsx'
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
background: 'transparent',
|
|
8
|
-
display: 'content',
|
|
9
|
-
pointerEvents: 'none'
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const blobStyles = {
|
|
6
|
+
const fabStyles = {
|
|
13
7
|
boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
|
|
14
8
|
borderRadius: '999px',
|
|
15
|
-
position: '
|
|
9
|
+
position: 'fixed',
|
|
16
10
|
right: 'var(--space-m)',
|
|
17
11
|
bottom: 'var(--space-m)',
|
|
18
12
|
cursor: 'pointer',
|
|
19
13
|
transition: 'transform .5s',
|
|
20
|
-
|
|
21
|
-
padding: 'var(--space-m)'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const editorContainerStyles = {
|
|
25
|
-
position: 'absolute',
|
|
26
|
-
right: '0',
|
|
27
|
-
top: '0',
|
|
28
|
-
height: '100%',
|
|
29
|
-
width: '100%',
|
|
30
|
-
display: 'flex',
|
|
31
|
-
justifyContent: 'flex-end',
|
|
32
|
-
alignItems: 'stretch',
|
|
33
|
-
padding: '8px 32px'
|
|
14
|
+
zIndex: 101,
|
|
15
|
+
padding: 'var(--space-m)',
|
|
34
16
|
}
|
|
35
17
|
|
|
36
|
-
const
|
|
37
|
-
width: '
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
|
|
41
|
-
padding: '16px 16px 76px 16px',
|
|
42
|
-
overflowX: 'none',
|
|
18
|
+
const modalPanelStyles = {
|
|
19
|
+
width: 'min(720px, 80vw)',
|
|
20
|
+
minWidth: 'min(480px, 100%)',
|
|
21
|
+
maxHeight: '85vh',
|
|
43
22
|
overflowY: 'auto',
|
|
44
|
-
|
|
23
|
+
overflowX: 'hidden',
|
|
24
|
+
margin: '0 auto',
|
|
25
|
+
boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
|
|
45
26
|
}
|
|
46
27
|
|
|
47
28
|
const ThemeSwitcher = () => {
|
|
@@ -59,7 +40,6 @@ const ThemeSwitcher = () => {
|
|
|
59
40
|
}
|
|
60
41
|
|
|
61
42
|
export const ThemeEditor = () => {
|
|
62
|
-
const app = useApp()
|
|
63
43
|
const { updateResourceContent } = useResource('PCX53TaGviq4_8KvK-VOp')
|
|
64
44
|
const [isEditorOpen, setIsEditorOpen] = useState(false)
|
|
65
45
|
const [viewCount, setViewCount] = useState(0)
|
|
@@ -68,8 +48,8 @@ export const ThemeEditor = () => {
|
|
|
68
48
|
const temporarilyUpdateTheme = () => {}
|
|
69
49
|
|
|
70
50
|
const toggleStyles = useMemo(() => !isEditorOpen
|
|
71
|
-
?
|
|
72
|
-
: { ...
|
|
51
|
+
? fabStyles
|
|
52
|
+
: { ...fabStyles, transform: 'rotate(-45deg)' }, [isEditorOpen])
|
|
73
53
|
|
|
74
54
|
const onToggle = useCallback(() => {
|
|
75
55
|
setIsEditorOpen(!isEditorOpen)
|
|
@@ -103,44 +83,35 @@ export const ThemeEditor = () => {
|
|
|
103
83
|
}, [isEditorOpen])
|
|
104
84
|
|
|
105
85
|
return (
|
|
106
|
-
|
|
107
|
-
|
|
86
|
+
<>
|
|
108
87
|
{
|
|
109
88
|
isEditorOpen && (
|
|
110
|
-
<
|
|
111
|
-
<View
|
|
112
|
-
<View
|
|
113
|
-
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
89
|
+
<Overlay isVisible={true} onClose={onToggle}>
|
|
90
|
+
<View layout="off-center" style={{ height: '100%', width: '100%' }}>
|
|
91
|
+
<View slot="content" style={modalPanelStyles}>
|
|
92
|
+
<View surface="primary" roundness="m" gap="m" inset="l">
|
|
93
|
+
<DevPagesPanel />
|
|
94
|
+
<View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
|
|
95
|
+
{Object.entries(theme).map(([name, value]) => (
|
|
96
|
+
<div style={{ marginBottom: '16px' }}>
|
|
97
|
+
<label style={{ display: 'block', fontFamily: 'sans-serif', marginBottom: '4px', fontWeight: 'bold' }}>{name}</label>
|
|
98
|
+
<input value={value} data-name={name} style={{ width: '100%', padding: '4px' }}/>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
<Button type="submit" variant="cta">
|
|
102
|
+
Save
|
|
103
|
+
</Button>
|
|
104
|
+
</View>
|
|
105
|
+
<ThemeSwitcher/>
|
|
106
|
+
<Text>Views: {viewCount}</Text>
|
|
107
|
+
</View>
|
|
122
108
|
</View>
|
|
123
|
-
<ThemeSwitcher/>
|
|
124
|
-
<Text>Views: {viewCount}</Text>
|
|
125
109
|
</View>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<View>
|
|
129
|
-
Pages: {app?.pages?.length || 0}
|
|
130
|
-
</View>
|
|
131
|
-
|
|
132
|
-
<View>
|
|
133
|
-
{(app?.pages || []).map(page => (
|
|
134
|
-
<Button>{page.id}</Button>
|
|
135
|
-
))}
|
|
136
|
-
</View>
|
|
137
|
-
</View>
|
|
138
|
-
</div>
|
|
110
|
+
</Overlay>
|
|
139
111
|
)
|
|
140
112
|
}
|
|
141
113
|
|
|
142
114
|
<Button variant="cta" prefix="math-plus" style={toggleStyles} onClick={onToggle} />
|
|
143
|
-
|
|
144
|
-
</Overlay>
|
|
115
|
+
</>
|
|
145
116
|
)
|
|
146
117
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} pageId
|
|
3
|
+
* @returns {string} Group key for sorting and labeling (e.g. `booking`, `app`).
|
|
4
|
+
*/
|
|
5
|
+
export function getPageGroupKey (pageId) {
|
|
6
|
+
const slash = pageId.indexOf('/')
|
|
7
|
+
if (slash === -1) return 'app'
|
|
8
|
+
return pageId.slice(0, slash)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} groupKey
|
|
13
|
+
* @returns {string} Display label (e.g. `@ossy/booking`, `@ossy/app`).
|
|
14
|
+
*/
|
|
15
|
+
export function getPageGroupLabel (groupKey) {
|
|
16
|
+
if (groupKey === 'app') return '@ossy/app'
|
|
17
|
+
return `@ossy/${groupKey}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string | Record<string, string> | undefined} path
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function formatPagePaths (path) {
|
|
25
|
+
if (!path) return '—'
|
|
26
|
+
if (typeof path === 'string') return path
|
|
27
|
+
return Object.entries(path)
|
|
28
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
29
|
+
.map(([lang, p]) => `${lang}: ${p}`)
|
|
30
|
+
.join(' · ')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Array<{ id: string, path?: string | Record<string, string> }>} pages
|
|
35
|
+
* @returns {Array<{ key: string, label: string, pages: typeof pages }>}
|
|
36
|
+
*/
|
|
37
|
+
export function groupPagesByFeature (pages) {
|
|
38
|
+
const byKey = new Map()
|
|
39
|
+
|
|
40
|
+
for (const page of pages) {
|
|
41
|
+
const key = getPageGroupKey(page.id)
|
|
42
|
+
if (!byKey.has(key)) byKey.set(key, [])
|
|
43
|
+
byKey.get(key).push(page)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Array.from(byKey.entries())
|
|
47
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
48
|
+
.map(([key, groupPages]) => ({
|
|
49
|
+
key,
|
|
50
|
+
label: getPageGroupLabel(key),
|
|
51
|
+
pages: [...groupPages].sort((a, b) => a.id.localeCompare(b.id)),
|
|
52
|
+
}))
|
|
53
|
+
}
|