@nan0web/ui-payload 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/package.json +32 -0
- package/releases/3/3/v3.3.0/task.md +64 -0
- package/releases/3/3/v3.3.0/task.spec.js +13 -0
- package/src/access.js +39 -0
- package/src/components/BooleanCell.js +8 -0
- package/src/components/BooleanCell.tsx +50 -0
- package/src/components/ImageCell.js +26 -0
- package/src/components/ImageCell.tsx +54 -0
- package/src/components/MapCell.js +6 -0
- package/src/components/MapCell.tsx +59 -0
- package/src/domain/PayloadApp.js +48 -0
- package/src/domain/SeedModel.js +149 -0
- package/src/index.ts +17 -0
- package/src/richtext/Nan0ComponentNode.js +98 -0
- package/src/richtext/Nan0ElementNode.js +93 -0
- package/src/richtext/Nan0HTMLFeature.js +94 -0
- package/src/richtext/Nan0RawNode.js +92 -0
- package/src/richtext/client/index.js +20 -0
- package/src/richtext/fromNan0Html.js +294 -0
- package/src/richtext/index.js +10 -0
- package/src/richtext/nan0html-converter.spec.js +63 -0
- package/src/richtext/toNan0Html.js +188 -0
- package/src/templates/PayloadCollectionTemplate.js +167 -0
- package/src/templates/PayloadCollectionTemplate.spec.js +26 -0
- package/src/uiPayloadRegistry.js +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, ЯRаСлав (YaRaSLove) <support@yaro.page>
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and distribute this software for any purpose
|
|
6
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
|
7
|
+
and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nan0web/ui-payload",
|
|
3
|
+
"version": "3.3.0",
|
|
4
|
+
"description": "NaN0Web Universal Payload CMS UI Components Package (ImageCell, MapCell, BooleanCell, richtext)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./ui/payload": "./src/index.ts",
|
|
11
|
+
"./components/*": "./src/components/*",
|
|
12
|
+
"./templates": "./src/templates/PayloadCollectionTemplate.js",
|
|
13
|
+
"./templates/*": "./src/templates/*",
|
|
14
|
+
"./richtext": "./src/richtext/index.js",
|
|
15
|
+
"./richtext/client": "./src/richtext/client/index.js",
|
|
16
|
+
"./richtext/*": "./src/richtext/*"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@payloadcms/richtext-lexical": "3.86.0",
|
|
20
|
+
"lexical": "0.41.0",
|
|
21
|
+
"react": "^19.0.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"payload": "^3.0.0",
|
|
25
|
+
"@payloadcms/ui": "^3.0.0",
|
|
26
|
+
"react": "^19.0.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test src/test/*.spec.tsx src/templates/*.spec.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
version: 3.3.0
|
|
3
|
+
type: feature
|
|
4
|
+
status: active
|
|
5
|
+
locale: uk
|
|
6
|
+
models: ["MarkdownBlockView", "MediaBlockView"]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# 🚀 Mission: Payload CMS UI Адаптери та Блок-Компоненти у @nan0web/ui-payload (v3.3.0)
|
|
10
|
+
|
|
11
|
+
## 🏁 Overview (Огляд)
|
|
12
|
+
Реалізація універсальних UI компонентів та адаптерів Payload CMS для `MarkdownBlock`, `MediaBlock` та надання поліморфного `uiPayloadRegistry`.
|
|
13
|
+
|
|
14
|
+
## 👥 User Stories (Сценарії)
|
|
15
|
+
- Як розробник, я хочу відображати блоки `MarkdownBlock` через готові React/Payload UI елементи з підтримкою вкладеного графіків/калькуляторів.
|
|
16
|
+
- Як розробник, я хочу реєструвати Payload UI блоки через `uiPayloadRegistry`, щоб OLMUI рендеринг працював без ручних процедурних розгалужень.
|
|
17
|
+
|
|
18
|
+
## 🏗 Data-Driven Architecture (Моделювання)
|
|
19
|
+
- `uiPayloadRegistry`:
|
|
20
|
+
- `Map<ModelConstructor, PayloadBlockViewComponent>`
|
|
21
|
+
- `MarkdownBlockView`:
|
|
22
|
+
- Компонент для `type: 'text/markdown'`
|
|
23
|
+
- `MediaBlockView`:
|
|
24
|
+
- Компонент для `static $upload = true`
|
|
25
|
+
|
|
26
|
+
## 🎯 Scope (Задачі)
|
|
27
|
+
- [ ] Створити `uiPayloadRegistry` для прив'язки моделей до Payload/React UI View класів.
|
|
28
|
+
- [ ] Створити універсальний компонент `MarkdownBlockView`.
|
|
29
|
+
- [ ] Створити універсальний компонент `MediaBlockView`.
|
|
30
|
+
|
|
31
|
+
## ✅ Acceptance Criteria (DoD)
|
|
32
|
+
- [ ] Контрактні тести (`task.spec.js`) написані і успішно проходять (Green).
|
|
33
|
+
- [ ] OLMUI рендеринг: 0% процедурних `switch/case` в елементах рендерингу.
|
|
34
|
+
|
|
35
|
+
## 📌 Context Checkpoint (2026-08-07)
|
|
36
|
+
|
|
37
|
+
### Implemented
|
|
38
|
+
- Added `fromNan0Html()` and `inventoryNan0Html()` conversion helpers.
|
|
39
|
+
- Added Lexical custom nodes: `Nan0ElementNode`, `Nan0RawNode`, and `Nan0ComponentNode`.
|
|
40
|
+
- Added `Nan0HTMLFeature()` with server-side HTML converters and client import-map entry.
|
|
41
|
+
- Added Payload Lexical dependencies and public richtext exports.
|
|
42
|
+
- Added `ImageCell` preview with fixed `192x108` dimensions.
|
|
43
|
+
- Added Payload 3.86-compatible client feature provider in `src/richtext/client/index.js`.
|
|
44
|
+
|
|
45
|
+
### Verification
|
|
46
|
+
- `pnpm --prefix packages/ui-payload test` passes.
|
|
47
|
+
- Root commit hook passed 43 tests.
|
|
48
|
+
- Industrial Bank seed and Payload admin integration were exercised.
|
|
49
|
+
|
|
50
|
+
### Current State
|
|
51
|
+
- Changes are intentionally uncommitted after removing the last agent-created commit.
|
|
52
|
+
- They are staged in `packages/ui-payload` and must be committed by the user.
|
|
53
|
+
- Other repository changes are unrelated and must not be included.
|
|
54
|
+
- Industrial Bank CMS import map uses `@nan0web/ui-payload#ImageCell`.
|
|
55
|
+
- CMS image paths `/img/...` and `/images/...` are mounted from `bank/public`.
|
|
56
|
+
|
|
57
|
+
### Next Steps
|
|
58
|
+
1. Commit only the staged `packages/ui-payload` changes.
|
|
59
|
+
2. Run the Industrial Bank CMS build and fix remaining unrelated TypeScript blockers, starting with `src/test-read.ts` using obsolete `DB({ rootDir })`.
|
|
60
|
+
3. Verify `/admin/collections/cards/<id>` editing and `ImageCell` rendering.
|
|
61
|
+
4. Add focused contract tests for Nan0HTML/Lexical conversion and custom nodes.
|
|
62
|
+
|
|
63
|
+
### New Chat Bootstrap
|
|
64
|
+
Start from branch `feature/payload-cms`, base `070afe7`, with staged changes in `packages/ui-payload`. Do not reset, clean, commit, or push without explicit user instruction.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { uiPayloadRegistry } from '../../../../src/uiPayloadRegistry.js'
|
|
4
|
+
|
|
5
|
+
describe('Release v3.3.0: @nan0web/ui-payload Contract', () => {
|
|
6
|
+
it('uiPayloadRegistry provides polymorphic lookup for block views', () => {
|
|
7
|
+
class MockModel {}
|
|
8
|
+
class MockView {}
|
|
9
|
+
uiPayloadRegistry.set(MockModel, MockView)
|
|
10
|
+
|
|
11
|
+
assert.equal(uiPayloadRegistry.get(MockModel), MockView)
|
|
12
|
+
})
|
|
13
|
+
})
|
package/src/access.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access control helpers for Payload CMS collections.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a Payload access control function checking user presence and optional role(s).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // Requires any authenticated user
|
|
10
|
+
* create: accessFor()
|
|
11
|
+
*
|
|
12
|
+
* // Requires specific role(s)
|
|
13
|
+
* update: accessFor('admin', 'editor')
|
|
14
|
+
* // or
|
|
15
|
+
* delete: accessFor(['admin'])
|
|
16
|
+
*
|
|
17
|
+
* @param {...(string | string[])} roles - Allowed role(s). If none specified, checks for any authenticated user.
|
|
18
|
+
* @returns {import('payload').Access} Payload Access control function
|
|
19
|
+
*/
|
|
20
|
+
export function accessFor(...roles) {
|
|
21
|
+
const flatRoles = roles.flat().filter(Boolean)
|
|
22
|
+
return (/** @type {any} */ { req: { user } }) => {
|
|
23
|
+
if (!user) return false
|
|
24
|
+
if (flatRoles.length === 0) return true
|
|
25
|
+
if (Array.isArray(user.roles)) {
|
|
26
|
+
return flatRoles.some((role) => user.roles.includes(role))
|
|
27
|
+
}
|
|
28
|
+
if (typeof user.role === 'string') {
|
|
29
|
+
return flatRoles.includes(user.role)
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shortcut alias for public read access.
|
|
37
|
+
* @type {() => boolean}
|
|
38
|
+
*/
|
|
39
|
+
export const publicAccess = () => true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { useTranslation } from '@payloadcms/ui'
|
|
5
|
+
|
|
6
|
+
export const BooleanCell: React.FC<{ cellData: boolean; field: any }> = ({ cellData, field }) => {
|
|
7
|
+
const { i18n } = useTranslation()
|
|
8
|
+
const lang = i18n?.language?.toLowerCase().startsWith('en') ? 'en' : 'uk'
|
|
9
|
+
const val = Boolean(cellData)
|
|
10
|
+
|
|
11
|
+
const isHiddenField = field?.name?.toLowerCase() === 'hidden'
|
|
12
|
+
const customLabels = field?.admin?.custom?.labels
|
|
13
|
+
|
|
14
|
+
const defaultLabels = isHiddenField
|
|
15
|
+
? { true: { uk: '🙈 Приховано', en: '🙈 Hidden' }, false: { uk: '🌐 Активно', en: '🌐 Active' } }
|
|
16
|
+
: { true: { uk: '✅ Так', en: '✅ Yes' }, false: { uk: '❌ Ні', en: '❌ No' } }
|
|
17
|
+
|
|
18
|
+
const labels = customLabels || defaultLabels
|
|
19
|
+
const text = labels[val ? 'true' : 'false']?.[lang] || (val ? 'Yes' : 'No')
|
|
20
|
+
|
|
21
|
+
const isWarning = isHiddenField ? val : !val
|
|
22
|
+
const variant = isWarning ? 'warning' : 'success'
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
className={`badge bg-${variant}-subtle text-${variant}-emphasis border border-${variant}-subtle`}
|
|
27
|
+
style={{
|
|
28
|
+
display: 'inline-flex',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
gap: '4px',
|
|
31
|
+
padding: '3px 8px',
|
|
32
|
+
borderRadius: '12px',
|
|
33
|
+
fontSize: '11px',
|
|
34
|
+
fontWeight: 600,
|
|
35
|
+
backgroundColor: isWarning
|
|
36
|
+
? 'var(--bs-warning-bg-subtle, rgba(255, 193, 7, 0.15))'
|
|
37
|
+
: 'var(--bs-success-bg-subtle, rgba(25, 135, 84, 0.15))',
|
|
38
|
+
color: isWarning
|
|
39
|
+
? 'var(--bs-warning-text-emphasis, #664d03)'
|
|
40
|
+
: 'var(--bs-success-text-emphasis, #0a3622)',
|
|
41
|
+
borderColor: isWarning
|
|
42
|
+
? 'var(--bs-warning-border-subtle, rgba(255, 193, 7, 0.3))'
|
|
43
|
+
: 'var(--bs-success-border-subtle, rgba(25, 135, 84, 0.3))',
|
|
44
|
+
}}
|
|
45
|
+
suppressHydrationWarning
|
|
46
|
+
>
|
|
47
|
+
{text}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export function ImageCell(props) {
|
|
4
|
+
const value = props.cellData || props.value
|
|
5
|
+
if (!value) return null
|
|
6
|
+
|
|
7
|
+
const srcValue = typeof value === 'object' ? value.url || value.thumbnailURL : value
|
|
8
|
+
if (!srcValue) return null
|
|
9
|
+
const src = srcValue.startsWith('http') || srcValue.startsWith('/') ? srcValue : `/${srcValue}`
|
|
10
|
+
|
|
11
|
+
return React.createElement('img', {
|
|
12
|
+
src,
|
|
13
|
+
alt: 'Thumbnail',
|
|
14
|
+
width: 192,
|
|
15
|
+
height: 108,
|
|
16
|
+
style: {
|
|
17
|
+
width: 192,
|
|
18
|
+
minWidth: 192,
|
|
19
|
+
height: 108,
|
|
20
|
+
minHeight: 108,
|
|
21
|
+
objectFit: 'cover',
|
|
22
|
+
borderRadius: 6,
|
|
23
|
+
display: 'block',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export const ImageCell: React.FC<{ cellData: string }> = ({ cellData }) => {
|
|
6
|
+
const [hasError, setHasError] = useState(false)
|
|
7
|
+
|
|
8
|
+
if (!cellData || typeof cellData !== 'string' || hasError) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
style={{
|
|
12
|
+
display: 'inline-flex',
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
justifyContent: 'center',
|
|
15
|
+
width: '192px',
|
|
16
|
+
height: '108px',
|
|
17
|
+
borderRadius: '6px',
|
|
18
|
+
background: 'rgba(255, 255, 255, 0.05)',
|
|
19
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
20
|
+
fontSize: '12px',
|
|
21
|
+
color: '#888',
|
|
22
|
+
}}
|
|
23
|
+
suppressHydrationWarning
|
|
24
|
+
>
|
|
25
|
+
<span>🖼️ Без фото</span>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const src = cellData.startsWith('http') || cellData.startsWith('/') ? cellData : `/${cellData}`
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div style={{ display: 'inline-flex', alignItems: 'center' }} suppressHydrationWarning>
|
|
34
|
+
<img
|
|
35
|
+
src={src}
|
|
36
|
+
alt="Preview"
|
|
37
|
+
style={{
|
|
38
|
+
width: '192px',
|
|
39
|
+
minWidth: '192px',
|
|
40
|
+
height: '108px',
|
|
41
|
+
minHeight: '108px',
|
|
42
|
+
display: 'block',
|
|
43
|
+
objectFit: 'cover',
|
|
44
|
+
borderRadius: '6px',
|
|
45
|
+
border: '1px solid rgba(255, 255, 255, 0.25)',
|
|
46
|
+
background: '#111',
|
|
47
|
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4)',
|
|
48
|
+
}}
|
|
49
|
+
onError={() => setHasError(true)}
|
|
50
|
+
suppressHydrationWarning
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
export const MapCell: React.FC<{ rowData: any }> = ({ rowData }) => {
|
|
6
|
+
const lat = rowData?.lat || rowData?.latitude || '50.4501'
|
|
7
|
+
const lon = rowData?.lng || rowData?.longitude || '30.5234'
|
|
8
|
+
const address = rowData?.address || rowData?.title || 'Відділення'
|
|
9
|
+
|
|
10
|
+
const zoom = 15
|
|
11
|
+
const latNum = parseFloat(String(lat)) || 50.4501
|
|
12
|
+
const lonNum = parseFloat(String(lon)) || 30.5234
|
|
13
|
+
|
|
14
|
+
const x = Math.floor(((lonNum + 180) / 360) * Math.pow(2, zoom))
|
|
15
|
+
const y = Math.floor(
|
|
16
|
+
((1 - Math.log(Math.tan((latNum * Math.PI) / 180) + 1 / Math.cos((latNum * Math.PI) / 180)) / Math.PI) / 2) *
|
|
17
|
+
Math.pow(2, zoom)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const osmTileUrl = `https://tile.openstreetmap.org/${zoom}/${x}/${y}.png`
|
|
21
|
+
const osmMapUrl = `https://www.openstreetmap.org/?mlat=${latNum}&mlon=${lonNum}#map=16/${latNum}/${lonNum}`
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<a
|
|
25
|
+
href={osmMapUrl}
|
|
26
|
+
target="_blank"
|
|
27
|
+
rel="noopener noreferrer"
|
|
28
|
+
style={{
|
|
29
|
+
display: 'inline-flex',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
gap: '8px',
|
|
32
|
+
textDecoration: 'none',
|
|
33
|
+
color: 'inherit',
|
|
34
|
+
}}
|
|
35
|
+
suppressHydrationWarning
|
|
36
|
+
>
|
|
37
|
+
<img
|
|
38
|
+
src={osmTileUrl}
|
|
39
|
+
alt="OpenStreetMap"
|
|
40
|
+
style={{
|
|
41
|
+
width: '100px',
|
|
42
|
+
height: '56px',
|
|
43
|
+
objectFit: 'cover',
|
|
44
|
+
borderRadius: '4px',
|
|
45
|
+
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
46
|
+
background: '#222',
|
|
47
|
+
}}
|
|
48
|
+
onError={(e) => {
|
|
49
|
+
;(e.target as HTMLElement).style.display = 'none'
|
|
50
|
+
}}
|
|
51
|
+
suppressHydrationWarning
|
|
52
|
+
/>
|
|
53
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
54
|
+
<span style={{ fontSize: '12px', fontWeight: 600 }}>📍 {address}</span>
|
|
55
|
+
<span style={{ fontSize: '10px', color: '#1471d1' }}>🗺️ OpenStreetMap ({latNum.toFixed(4)}, {lonNum.toFixed(4)})</span>
|
|
56
|
+
</div>
|
|
57
|
+
</a>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ModelAsApp } from '@nan0web/ui'
|
|
2
|
+
import { getPayload } from 'payload'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} AppOptions
|
|
6
|
+
* @property {import('payload').Payload} [payload]
|
|
7
|
+
* @property {any} [config]
|
|
8
|
+
*/
|
|
9
|
+
/** @typedef {import('@nan0web/ui').ModelAsAppOptions & AppOptions} PayloadAppOptions */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PayloadApp
|
|
13
|
+
* Base application class for Payload CMS subcommands (SeedModel, TransformModel, etc.)
|
|
14
|
+
* @extends {ModelAsApp}
|
|
15
|
+
*/
|
|
16
|
+
export class PayloadApp extends ModelAsApp {
|
|
17
|
+
/**
|
|
18
|
+
* @param {Partial<ModelAsApp> | Record<string, any>} [data={}]
|
|
19
|
+
* @param {Partial<PayloadAppOptions>} [options={}]
|
|
20
|
+
*/
|
|
21
|
+
constructor(data = {}, options = {}) {
|
|
22
|
+
super(data, options)
|
|
23
|
+
/** @type {boolean} Show help */ this.help
|
|
24
|
+
this._ = {
|
|
25
|
+
...this._,
|
|
26
|
+
payload: options.payload || null,
|
|
27
|
+
config: options.config || null,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lazily initializes and returns the Payload Local API instance.
|
|
33
|
+
* @returns {Promise<import('payload').Payload>}
|
|
34
|
+
*/
|
|
35
|
+
async getPayloadInstance() {
|
|
36
|
+
if (this._.payload) {
|
|
37
|
+
return this._.payload
|
|
38
|
+
}
|
|
39
|
+
if (this._.config) {
|
|
40
|
+
const resolvedConfig = await Promise.resolve(this._.config)
|
|
41
|
+
this._.payload = await getPayload({ config: resolvedConfig })
|
|
42
|
+
return this._.payload
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'PayloadApp: Neither payload instance nor config was provided in options.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { PayloadApp } from './PayloadApp.js'
|
|
2
|
+
import { show, progress, result } from '@nan0web/ui/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SeedModel - Universal Subcommand to seed DB-FS data into Payload CMS
|
|
6
|
+
* @extends {PayloadApp}
|
|
7
|
+
*/
|
|
8
|
+
export class SeedModel extends PayloadApp {
|
|
9
|
+
static alias = 'seed'
|
|
10
|
+
|
|
11
|
+
static UI = {
|
|
12
|
+
title: 'Seed DB-FS Data into Payload CMS',
|
|
13
|
+
start: 'Starting Payload CMS data seeding...',
|
|
14
|
+
scanning: 'Scanning data directory {target}...',
|
|
15
|
+
loading: 'Loading data from {uri}...',
|
|
16
|
+
seeded: 'Seeded data for {collection} -> {file}',
|
|
17
|
+
done: 'Data seeding completed successfully! Seeded {count} records.',
|
|
18
|
+
errorDb: 'No DB instance found. Cannot run SeedModel command.',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static dataDir = {
|
|
22
|
+
help: 'Target directory containing SSOT data files (.yaml, .nano, .json)',
|
|
23
|
+
default: 'data',
|
|
24
|
+
positional: true,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static output = {
|
|
28
|
+
help: 'Output web directory containing payload.config.ts',
|
|
29
|
+
default: 'web',
|
|
30
|
+
alias: 'o',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Partial<SeedModel>} [data={}]
|
|
35
|
+
* @param {Partial<import('./PayloadApp.js').PayloadAppOptions>} [options={}]
|
|
36
|
+
*/
|
|
37
|
+
constructor(data = {}, options = {}) {
|
|
38
|
+
super(data, options)
|
|
39
|
+
/** @type {string} Target directory containing SSOT data files */ this.dataDir
|
|
40
|
+
/** @type {string} Output path */ this.output
|
|
41
|
+
/** @type {boolean} Help flag */ this.help
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads seed files via DB instance.
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @returns {Promise<string[]>}
|
|
48
|
+
*/
|
|
49
|
+
async readSeedFiles(target) {
|
|
50
|
+
const db = this._.db
|
|
51
|
+
if (!db) return []
|
|
52
|
+
let prefix = ''
|
|
53
|
+
if (target && target !== '.') {
|
|
54
|
+
prefix = target.startsWith('@') ? target : `@cwd/${target}`
|
|
55
|
+
}
|
|
56
|
+
const items = []
|
|
57
|
+
try {
|
|
58
|
+
for await (const entry of db.browse(prefix)) {
|
|
59
|
+
if (db.Directory.isConfig(entry.path)) continue
|
|
60
|
+
if (db.Directory.isDirectory(entry.path)) continue
|
|
61
|
+
const ext = entry.path.split('.').pop()?.toLowerCase()
|
|
62
|
+
if (['yaml', 'nan0', 'json', 'yml'].includes(ext || '')) {
|
|
63
|
+
items.push(entry.path)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Fallback
|
|
68
|
+
}
|
|
69
|
+
return items
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Dynamically resolves collection slug from document metadata or URI structure.
|
|
74
|
+
* @param {string} uri
|
|
75
|
+
* @param {any} [doc]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
resolveCollectionSlug(uri, doc = {}) {
|
|
79
|
+
if (doc && typeof doc === 'object') {
|
|
80
|
+
if (doc.$collection) return String(doc.$collection)
|
|
81
|
+
if (doc.model && typeof doc.model === 'string') return doc.model.toLowerCase() + 's'
|
|
82
|
+
}
|
|
83
|
+
const parts = uri.split('/').filter(Boolean)
|
|
84
|
+
return parts[parts.length - 2] || 'items'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Base default normalization for DB-FS records.
|
|
89
|
+
* Can be overridden by domain-specific SeedModels (e.g. CardSeedModel, BankSeedModel).
|
|
90
|
+
* @param {any} rawRecord
|
|
91
|
+
* @returns {Object}
|
|
92
|
+
*/
|
|
93
|
+
normalizeRecord(rawRecord) {
|
|
94
|
+
const entity = rawRecord.page || rawRecord.doc || rawRecord
|
|
95
|
+
return {
|
|
96
|
+
title: entity.title || rawRecord.title || '',
|
|
97
|
+
slug: entity.slug || rawRecord.slug || '',
|
|
98
|
+
description: entity.description || rawRecord.description || '',
|
|
99
|
+
image: entity.image || rawRecord.image || '',
|
|
100
|
+
order: entity.order ?? rawRecord.order ?? 0,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @returns {AsyncGenerator<import('@nan0web/ui/core').Intent, import('@nan0web/ui/core').ResultIntent, any>}
|
|
106
|
+
*/
|
|
107
|
+
async *run() {
|
|
108
|
+
const { db, t } = this._
|
|
109
|
+
if (!db) {
|
|
110
|
+
throw new Error(t(SeedModel.UI.errorDb))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payload = await this.getPayloadInstance().catch(() => this._.payload)
|
|
114
|
+
const target = this.dataDir || 'data'
|
|
115
|
+
|
|
116
|
+
yield progress(t(SeedModel.UI.start))
|
|
117
|
+
yield progress(t(SeedModel.UI.scanning, { target }))
|
|
118
|
+
|
|
119
|
+
const items = await this.readSeedFiles(target)
|
|
120
|
+
let count = 0
|
|
121
|
+
let i = 0
|
|
122
|
+
for (const uri of items) {
|
|
123
|
+
yield progress(t(SeedModel.UI.loading, { uri }), i++, items.length)
|
|
124
|
+
const doc = await db.fetch(uri)
|
|
125
|
+
if (doc) {
|
|
126
|
+
const collection = this.resolveCollectionSlug(uri, doc)
|
|
127
|
+
if (payload && typeof payload.create === 'function') {
|
|
128
|
+
try {
|
|
129
|
+
const rawList = doc.departments || doc.currencies || doc.metals || doc.news || (Array.isArray(doc) ? doc : [doc])
|
|
130
|
+
for (const rawRecord of rawList) {
|
|
131
|
+
const normalizedRecord = this.normalizeRecord(rawRecord)
|
|
132
|
+
await payload.create({ collection, data: normalizedRecord })
|
|
133
|
+
++count
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// Skip fallback record
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const rawList = doc.departments || doc.currencies || doc.metals || doc.news || (Array.isArray(doc) ? doc : [doc])
|
|
140
|
+
count += rawList.length
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
yield progress(t(SeedModel.UI.loading, { uri }), i, items.length)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
yield show(t(SeedModel.UI.done, { count }), 'success')
|
|
147
|
+
return result({ status: 'ok', count })
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { ImageCell } from './components/ImageCell.js'
|
|
2
|
+
export { BooleanCell } from './components/BooleanCell.js'
|
|
3
|
+
export { MapCell } from './components/MapCell.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// richtext — pure functions only (node classes require 'lexical' context)
|
|
7
|
+
export { fromNan0Html, inventoryNan0Html, FORMAT } from './richtext/fromNan0Html.js'
|
|
8
|
+
|
|
9
|
+
// access control helpers
|
|
10
|
+
export { accessFor, publicAccess } from './access.js'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|