feat: dynamic extension loading via routes.json (#3605)

This commit is contained in:
dni ⚡ 2025-12-08 11:28:09 +01:00 committed by GitHub
parent 5f86627eae
commit fd765e2060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 91 additions and 31 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,27 +9,18 @@ window.app.component('lnbits-manage-extension-list', {
} }
}, },
watch: { watch: {
'g.user.extensions': { 'g.user.extensions'() {
async handler() { this.loadExtensions()
await this.loadExtensions()
}
}, },
searchTerm() { searchTerm() {
this.filterUserExtensionsByTerm() this.filterUserExtensionsByTerm()
} }
}, },
methods: { methods: {
map(data) {
const obj = {...data}
obj.url = ['/', obj.code, '/'].join('')
return obj
},
async loadExtensions() { async loadExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', '/api/v1/extension') res = await LNbits.api.request('GET', '/api/v1/extension')
this.extensions = data this.extensions = res.data.sort((a, b) => a.name.localeCompare(b.name))
.map(extension => this.map(extension))
.sort((a, b) => a.name.localeCompare(b.name))
this.filterUserExtensionsByTerm() this.filterUserExtensionsByTerm()
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)

View file

@ -9,6 +9,46 @@ const quasarConfig = {
} }
} }
const DynamicComponent = {
async created() {
const name = this.$route.path.split('/')[1]
const path = `/${name}/`
const routesPath = `/${name}/static/routes.json`
const hasPath = this.$router.getRoutes().some(r => r.path === path)
if (hasPath) {
console.log('Dynamic route already exists for extension:', name)
return
}
fetch(routesPath)
.then(async res => {
if (!res.ok) {
throw new Error('No dynamic routes found')
}
const routes = await res.json()
routes.forEach(r => {
console.log('Adding dynamic route:', r.path)
window.router.addRoute({
path: r.path,
name: r.name,
component: async () => {
await LNbits.utils.loadTemplate(r.template)
await LNbits.utils.loadScript(r.component)
return window[r.name]
}
})
window.router.push(this.$route.fullPath)
})
})
.catch(() => {
if (RENDERED_ROUTE !== path) {
console.log('Redirecting to non-vue route:', path)
window.location = path
return
}
})
}
}
const routes = [ const routes = [
{ {
path: '/node', path: '/node',
@ -87,6 +127,11 @@ const routes = [
path: '/error', path: '/error',
name: 'PageError', name: 'PageError',
component: PageError component: PageError
},
{
path: '/:pathMatch(.*)*',
name: 'DynamicComponent',
component: DynamicComponent
} }
] ]
@ -107,18 +152,9 @@ window.app.mixin({
api: window._lnbitsApi, api: window._lnbitsApi,
utils: window._lnbitsUtils, utils: window._lnbitsUtils,
g: window.g, g: window.g,
utils: window._lnbitsUtils,
...WINDOW_SETTINGS ...WINDOW_SETTINGS
} }
}, },
computed: {
isVueRoute() {
const currentPath = window.location.pathname
const matchedRoute = window.router.resolve(currentPath)
const isVueRoute = matchedRoute?.matched?.length > 0
return isVueRoute
}
},
// backwards compatibility for extensions, should not be used in the future // backwards compatibility for extensions, should not be used in the future
methods: { methods: {
copyText: window._lnbitsUtils.copyText, copyText: window._lnbitsUtils.copyText,

View file

@ -1,4 +1,31 @@
window._lnbitsUtils = { window._lnbitsUtils = {
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = src
script.onload = () => {
resolve()
}
script.onerror = () => {
reject(new Error(`Failed to load script ${src}`))
}
document.body.appendChild(script)
})
},
async loadTemplate(url) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load template from ${url}`)
}
return response.text()
})
.then(html => {
const template = document.createElement('div')
template.innerHTML = html.trim()
document.body.appendChild(template)
})
},
copyText(text, message, position) { copyText(text, message, position) {
Quasar.copyToClipboard(text).then(() => { Quasar.copyToClipboard(text).then(() => {
Quasar.Notify.create({ Quasar.Notify.create({

View file

@ -51,9 +51,12 @@
<lnbits-header-wallets <lnbits-header-wallets
v-if="g.user && !g.isPublicPage" v-if="g.user && !g.isPublicPage"
></lnbits-header-wallets> ></lnbits-header-wallets>
<router-view v-if="isVueRoute" :key="$route.path"></router-view> <!-- block page content from static extensions -->
<!-- FastAPI Content from extensions --> <div v-if="$route.path === '{{ request.path }}'">
<div v-else>{% block page %}{% endblock %}</div> {% block page %}{% endblock %}
</div>
<!-- vue router-view -->
<router-view :key="$route.path"></router-view>
</q-page> </q-page>
</q-page-container> </q-page-container>
{% endblock %} {% endblock %}
@ -64,6 +67,7 @@
</q-layout> </q-layout>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
const RENDERED_ROUTE = '{{ request.path }}'
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }} const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
Object.keys(WINDOW_SETTINGS).forEach(key => { Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key] window[key] = WINDOW_SETTINGS[key]

View file

@ -17,11 +17,10 @@
</q-item> </q-item>
<q-item <q-item
v-for="extension in userExtensions" v-for="extension in userExtensions"
:key="extension.code"
clickable clickable
:active="$route.path.startsWith(extension.url)" :active="$route.path.startsWith('/' + extension.code)"
tag="a" tag="a"
:href="extension.url" :to="'/' + extension.code + '/'"
> >
<q-item-section side> <q-item-section side>
<q-avatar size="md"> <q-avatar size="md">
@ -33,7 +32,10 @@
><span v-text="extension.name"></span> ><span v-text="extension.name"></span>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side v-show="$route.path.startsWith(extension.url)"> <q-item-section
side
v-show="$route.path.startsWith('/' + extension.code)"
>
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon> <q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section> </q-item-section>
</q-item> </q-item>