[feat] Extension Builder (#3339)

This commit is contained in:
Vlad Stan
2025-09-25 18:33:33 +03:00
committed by GitHub
parent c05122e5fb
commit 4f9a5090c2
27 changed files with 2871 additions and 16 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -243,6 +243,13 @@ body.body--dark .q-field--error .q-field__messages {
width: 500px;
}
.blur-and-disable {
filter: blur(4px); /* blur entire element & children */
pointer-events: none; /* block mouse interaction */
user-select: none; /* prevent text selection */
opacity: 0.6; /* optional: faded look */
}
.lnbits__table-bordered td,
.lnbits__table-bordered th {
border: 1px solid black;
+10
View File
@@ -164,6 +164,8 @@ window.localisation.en = {
featured: 'Featured',
all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
new_version: 'New Version',
extension_depends_on: 'Depends on:',
@@ -420,6 +422,7 @@ window.localisation.en = {
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}',
@@ -489,9 +492,16 @@ window.localisation.en = {
user_default_extensions_label: 'User extensions',
user_default_extensions_hint:
'Extensions that will be enabled by default for the users.',
extension_builder: 'Extension Builder',
extension_builder_manifest_url: 'Extension Builder Manifest URL',
extension_builder_manifest_url_hint:
'URL to a JSON manifest file with extension builder details',
miscellanous: 'Miscellanous',
misc_disable_extensions: 'Disable Extensions',
misc_disable_extensions_label: 'Disable all extensions',
misc_disable_extensions_builder: 'Enable Extensions Builder',
misc_disable_extensions_builder_label:
'Enable Extensions Builder for non admin users.',
misc_hide_api: 'Hide API',
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
wallets_management: 'Wallets Management',
+3 -2
View File
@@ -1,14 +1,15 @@
window.LNbits = {
g: window.g,
api: {
request(method, url, apiKey, data) {
request(method, url, apiKey, data, options = {}) {
return axios({
method: method,
url: url,
headers: {
'X-Api-Key': apiKey
},
data: data
data: data,
...options
})
},
getServerHealth() {
+115
View File
@@ -0,0 +1,115 @@
window.app.component('lnbits-data-fields', {
name: 'lnbits-data-fields',
template: '#lnbits-data-fields',
props: ['fields', 'hide-advanced'],
data() {
return {
fieldTypes: [
{label: 'Text', value: 'str'},
{label: 'Integer', value: 'int'},
{label: 'Float', value: 'float'},
{label: 'Boolean', value: 'bool'},
{label: 'Date Time', value: 'datetime'},
{label: 'JSON', value: 'json'},
{label: 'Wallet Select', value: 'wallet'},
{label: 'Currency Select', value: 'currency'}
],
fieldsTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Field Name',
field: 'name',
sortable: true
},
{
name: 'type',
align: 'left',
label: 'Type',
field: 'type',
sortable: false
},
{
name: 'label',
align: 'left',
label: 'UI Label',
field: 'label',
sortable: true
},
{
name: 'hint',
align: 'left',
label: 'UI Hint',
field: 'hint',
sortable: false
},
{
name: 'optional',
align: 'left',
label: 'Optional',
field: 'optional',
sortable: false
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 100,
page: 1,
rowsNumber: 100
},
search: null,
hideEmpty: true
}
}
},
methods: {
addField: function () {
this.fields.push({
name: 'field_name_' + (this.fields.length + 1),
type: 'text',
label: '',
hint: '',
optional: true,
sortable: true,
searchable: true,
editable: true,
fields: [] // For nested fields in JSON type
})
},
removeField: function (field) {
const index = this.fields.indexOf(field)
if (index > -1) {
this.fields.splice(index, 1)
}
}
},
async created() {
if (!this.hideAdvanced) {
this.fieldsTable.columns.push(
{
name: 'editable',
align: 'left',
label: 'UI Editable',
field: 'editable',
sortable: false
},
{
name: 'sortable',
align: 'left',
label: 'Sortable',
field: 'sortable',
sortable: false
},
{
name: 'searchable',
align: 'left',
label: 'Searchable',
field: 'searchable',
sortable: false
}
)
}
}
})
+350
View File
@@ -0,0 +1,350 @@
window.ExtensionsBuilderPageLogic = {
data: function () {
return {
step: 1,
previewStepNames: {
2: 'settings',
3: 'owner_data',
4: 'client_data',
5: 'public_page'
},
extensionDataCleanString: '',
extensionData: {
id: '',
name: '',
stub_version: '',
short_description: '',
description: '',
public_page: {
has_public_page: true,
owner_data_fields: {
name: '',
description: ''
},
client_data_fields: {
public_inputs: []
},
action_fields: {
generate_action: true,
generate_payment_logic: false,
wallet_id: '',
currency: '',
amount: '',
paid_flag: ''
}
},
preview_action: {
is_preview_mode: false,
is_settings_preview: false,
is_owner_data_preview: false,
is_client_data_preview: false,
is_public_page_preview: false
},
settings_data: {
name: 'Settings',
enabled: true,
type: 'user',
fields: []
},
owner_data: {
name: 'OwnerData',
fields: []
},
client_data: {
enabled: true,
name: 'ClientData',
fields: []
}
},
sampleField: {
name: 'name',
type: 'str',
label: 'Name',
hint: '',
optional: true,
editable: true,
searchable: true,
sortable: true
},
settingsTypes: [
{label: 'User Settings', value: 'user'},
{label: 'Admin Settings', value: 'admin'}
],
amountSource: [
{label: 'Client Data', value: 'client_data'},
{label: 'Owner Data', value: 'owner_data'}
],
extensionStubVersions: []
}
},
watch: {
'extensionData.public_page.action_fields.amount_source': function (
newVal,
oldVal
) {
if (oldVal && newVal !== oldVal) {
this.extensionData.public_page.action_fields.amount = ''
}
}
},
computed: {
paymentActionAmountFields() {
const amount_source =
this.extensionData.public_page.action_fields.amount_source
console.log('### amount_source:', amount_source)
if (!amount_source) return ['']
if (amount_source === 'owner_data') {
return [''].concat(
this.extensionData.owner_data.fields
.filter(f => f.type === 'int' || f.type === 'float')
.map(f => f.name)
)
}
if (amount_source === 'client_data') {
return [''].concat(
this.extensionData.client_data.fields
.filter(f => f.type === 'int' || f.type === 'float')
.map(f => f.name)
)
}
}
},
methods: {
saveState() {
this.$q.localStorage.set(
'lnbits.extension.builder.data',
JSON.stringify(this.extensionData)
)
this.$q.localStorage.set('lnbits.extension.builder.step', this.step)
},
nextStep() {
this.saveState()
this.$refs.stepper.next()
this.refreshPreview()
},
previousStep() {
this.saveState()
this.$refs.stepper.previous()
this.refreshPreview()
},
onStepChange() {
this.saveState()
this.refreshPreview()
},
clearAllData() {
LNbits.utils
.confirmDialog(
'Are you sure you want to clear all data? This action cannot be undone.'
)
.onOk(() => {
this.extensionData = JSON.parse(this.extensionDataCleanString)
this.$q.localStorage.remove('lnbits.extension.builder.data')
this.$refs.stepper.set(1)
})
},
exportJsonData() {
const status = Quasar.exportFile(
`${this.extensionData.id || 'data-export'}.json`,
JSON.stringify(this.extensionData, null, 2),
'text/json'
)
if (status !== true) {
Quasar.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
} else {
Quasar.Notify.create({
message: 'File downloaded!',
color: 'positive',
icon: 'file_download'
})
}
},
onJsonDataInput(event) {
const file = event.target.files[0]
const reader = new FileReader()
reader.onload = e => {
this.extensionData = {
...this.extensionData,
...JSON.parse(e.target.result)
}
this.$refs.extensionDataInput.value = null
Quasar.Notify.create({
message: 'File loaded!',
color: 'positive',
icon: 'file_upload'
})
}
reader.readAsText(file)
},
async buildExtension() {
try {
const options = {responseType: 'blob'}
const response = await LNbits.api.request(
'POST',
'/api/v1/extension/builder/zip',
null,
this.extensionData,
options
)
// download the zip file
const url = window.URL.createObjectURL(new Blob([response.data]))
const a = document.createElement('a')
a.href = url
a.download = `${this.extensionData.id || 'lnbits-extension'}.zip`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async buildExtensionAndDeploy() {
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/extension/builder/deploy',
null,
this.extensionData
)
Quasar.Notify.create({
message: data.message || 'Extension deployed!',
color: 'positive'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async cleanCacheData() {
LNbits.utils
.confirmDialog(
'Are you sure you want to clean the cache data? This action cannot be undone.',
'Clean Cache Data'
)
.onOk(async () => {
try {
const {data} = await LNbits.api.request(
'DELETE',
'/api/v1/extension/builder',
null,
{}
)
Quasar.Notify.create({
message: data.message || 'Cache data cleaned!',
color: 'positive'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
async previewExtension(previewPageName) {
this.saveState()
try {
await LNbits.api.request(
'POST',
'/api/v1/extension/builder/preview',
null,
{
...this.extensionData,
...{
preview_action: {
is_preview_mode: !!previewPageName,
is_settings_preview: previewPageName === 'settings',
is_owner_data_preview: previewPageName === 'owner_data',
is_client_data_preview: previewPageName === 'client_data',
is_public_page_preview: previewPageName === 'public_page'
}
}
}
)
this.refreshIframe(previewPageName)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async refreshPreview() {
setTimeout(() => {
const stepName = this.previewStepNames[`${this.step}`] || ''
if (!stepName) return
this.previewExtension(stepName)
}, 100)
},
async getStubExtensionReleases() {
try {
const stub_ext_id = 'extension_builder_stub'
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${stub_ext_id}/releases`
)
this.extensionStubVersions = data.sort((a, b) =>
a.version < b.version ? 1 : -1
)
this.extensionData.stub_version = this.extensionStubVersions[0]
? this.extensionStubVersions[0].version
: ''
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
refreshIframe(previewPageName = '') {
const iframe = this.$refs[`iframeStep${this.step}`]
if (!iframe) {
console.warn('Extension Builder Preview iframe not loaded yet.')
return
}
iframe.onload = () => {
const iframeDoc =
iframe.contentDocument || iframe.contentWindow.document
iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top'
}
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
},
initBasicData() {
this.extensionData.owner_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionData.client_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionData.settings_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionDataCleanString = JSON.stringify(this.extensionData)
}
},
created: function () {
this.initBasicData()
const extensionData = this.$q.localStorage.getItem(
'lnbits.extension.builder.data'
)
if (extensionData) {
this.extensionData = {...this.extensionData, ...JSON.parse(extensionData)}
}
const step = +this.$q.localStorage.getItem('lnbits.extension.builder.step')
if (step) {
this.step = step
}
if (this.g.user.admin) {
this.getStubExtensionReleases()
}
setTimeout(() => {
this.refreshIframe()
}, 1000)
},
mixins: [windowMixin]
}
+9
View File
@@ -184,6 +184,15 @@ const routes = [
scripts: ['/static/js/extensions.js']
}
},
{
path: '/extensions/builder',
name: 'ExtensionsBuilder',
component: DynamicComponent,
props: {
fetchUrl: '/extensions/builder',
scripts: ['/static/js/extensions_builder.js']
}
},
{
path: '/account',
name: 'Account',
+6
View File
@@ -31,6 +31,12 @@ body.body--dark .q-field--error {
.lnbits__dialog-card {
width: 500px;
}
.blur-and-disable {
filter: blur(4px); /* blur entire element & children */
pointer-events: none; /* block mouse interaction */
user-select: none; /* prevent text selection */
opacity: 0.6; /* optional: faded look */
}
.lnbits__table-bordered td,
.lnbits__table-bordered th {
+1
View File
@@ -42,6 +42,7 @@
"js/components/lnbits-qrcode-lnurl.js",
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components/payment-list.js",
"js/components.js",
"js/init-app.js"