mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-22 14:22:55 +01:00
Initial commit 🎵
This commit is contained in:
parent
bcecf6d431
commit
c0b18d78cc
12 changed files with 770 additions and 0 deletions
5
lnbits/extensions/jukebox/README.md
Normal file
5
lnbits/extensions/jukebox/README.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# Jukebox
|
||||||
|
|
||||||
|
To use this extension you need a Spotify client ID and client secret. You get these by creating an app in the Spotify developers dashboard here https://developer.spotify.com/dashboard/applications
|
||||||
|
|
||||||
|
Select the playlists you want people to be able to pay for, share the frontend page, profit :)
|
14
lnbits/extensions/jukebox/__init__.py
Normal file
14
lnbits/extensions/jukebox/__init__.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
from quart import Blueprint
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("ext_jukebox")
|
||||||
|
|
||||||
|
jukebox_ext: Blueprint = Blueprint(
|
||||||
|
"jukebox", __name__, static_folder="static", template_folder="templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .lnurl import * # noqa
|
6
lnbits/extensions/jukebox/config.json
Normal file
6
lnbits/extensions/jukebox/config.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Jukebox",
|
||||||
|
"short_description": "Spotify jukebox middleware",
|
||||||
|
"icon": "audiotrack",
|
||||||
|
"contributors": ["benarc"]
|
||||||
|
}
|
34
lnbits/extensions/jukebox/crud.py
Normal file
34
lnbits/extensions/jukebox/crud.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .wordlists import animals
|
||||||
|
from .models import Shop, Item
|
||||||
|
|
||||||
|
|
||||||
|
async def create_update_jukebox(wallet_id: str) -> int:
|
||||||
|
juke_id = urlsafe_short_hash()
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jukebox (id, wallet, user, secret, token, playlists)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(juke_id, wallet_id, "", "", "", ""),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jukebox(id: str) -> Optional[Jukebox]:
|
||||||
|
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,))
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
async def get_jukeboxs(id: str) -> Optional[Jukebox]:
|
||||||
|
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,))
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
async def delete_jukebox(shop: int, item_id: int):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM jukebox WHERE id = ?
|
||||||
|
""",
|
||||||
|
(shop, item_id),
|
||||||
|
)
|
16
lnbits/extensions/jukebox/migrations.py
Normal file
16
lnbits/extensions/jukebox/migrations.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial jukebox table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE jukebox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
playlists TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
18
lnbits/extensions/jukebox/models.py
Normal file
18
lnbits/extensions/jukebox/models.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from collections import OrderedDict
|
||||||
|
from quart import url_for
|
||||||
|
from typing import NamedTuple, Optional, List, Dict
|
||||||
|
|
||||||
|
class Jukebox(NamedTuple):
|
||||||
|
id: int
|
||||||
|
wallet: str
|
||||||
|
user: str
|
||||||
|
secret: str
|
||||||
|
token: str
|
||||||
|
playlists: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Charges":
|
||||||
|
return cls(**dict(row))
|
216
lnbits/extensions/jukebox/static/js/index.js
Normal file
216
lnbits/extensions/jukebox/static/js/index.js
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
const pica = window.pica()
|
||||||
|
|
||||||
|
const defaultItemData = {
|
||||||
|
unit: 'sat'
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedWallet: null,
|
||||||
|
confirmationMethod: 'wordlist',
|
||||||
|
wordlistTainted: false,
|
||||||
|
jukebox: {
|
||||||
|
method: null,
|
||||||
|
wordlist: [],
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
itemDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {...defaultItemData},
|
||||||
|
units: ['sat']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
printItems() {
|
||||||
|
return this.jukebox.items.filter(({enabled}) => enabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openNewDialog() {
|
||||||
|
this.itemDialog.show = true
|
||||||
|
this.itemDialog.data = {...defaultItemData}
|
||||||
|
},
|
||||||
|
openUpdateDialog(itemId) {
|
||||||
|
this.itemDialog.show = true
|
||||||
|
let item = this.jukebox.items.find(item => item.id === itemId)
|
||||||
|
this.itemDialog.data = item
|
||||||
|
},
|
||||||
|
imageAdded(file) {
|
||||||
|
let blobURL = URL.createObjectURL(file)
|
||||||
|
let image = new Image()
|
||||||
|
image.src = blobURL
|
||||||
|
image.onload = async () => {
|
||||||
|
let canvas = document.createElement('canvas')
|
||||||
|
canvas.setAttribute('width', 100)
|
||||||
|
canvas.setAttribute('height', 100)
|
||||||
|
await pica.resize(image, canvas, {
|
||||||
|
quality: 0,
|
||||||
|
alpha: true,
|
||||||
|
unsharpAmount: 95,
|
||||||
|
unsharpRadius: 0.9,
|
||||||
|
unsharpThreshold: 70
|
||||||
|
})
|
||||||
|
this.itemDialog.data.image = canvas.toDataURL()
|
||||||
|
this.itemDialog = {...this.itemDialog}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imageCleared() {
|
||||||
|
this.itemDialog.data.image = null
|
||||||
|
this.itemDialog = {...this.itemDialog}
|
||||||
|
},
|
||||||
|
disabledAddItemButton() {
|
||||||
|
return (
|
||||||
|
!this.itemDialog.data.name ||
|
||||||
|
this.itemDialog.data.name.length === 0 ||
|
||||||
|
!this.itemDialog.data.price ||
|
||||||
|
!this.itemDialog.data.description ||
|
||||||
|
!this.itemDialog.data.unit ||
|
||||||
|
this.itemDialog.data.unit.length === 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
changedWallet(wallet) {
|
||||||
|
this.selectedWallet = wallet
|
||||||
|
this.loadShop()
|
||||||
|
},
|
||||||
|
loadShop() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/jukebox/api/v1/jukebox', this.selectedWallet.inkey)
|
||||||
|
.then(response => {
|
||||||
|
this.jukebox = response.data
|
||||||
|
this.confirmationMethod = response.data.method
|
||||||
|
this.wordlistTainted = false
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async setMethod() {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/jukebox/api/v1/jukebox/method',
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
{method: this.confirmationMethod, wordlist: this.jukebox.wordlist}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
message:
|
||||||
|
`Method set to ${this.confirmationMethod}.` +
|
||||||
|
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.loadShop()
|
||||||
|
},
|
||||||
|
async sendItem() {
|
||||||
|
let {id, name, image, description, price, unit} = this.itemDialog.data
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
price,
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/jukebox/api/v1/jukebox/items/' + id,
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/jukebox/api/v1/jukebox/items',
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item '${this.itemDialog.data.name}' added.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadShop()
|
||||||
|
this.itemDialog.show = false
|
||||||
|
this.itemDialog.data = {...defaultItemData}
|
||||||
|
},
|
||||||
|
toggleItem(itemId) {
|
||||||
|
let item = this.jukebox.items.find(item => item.id === itemId)
|
||||||
|
item.enabled = !item.enabled
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/jukebox/api/v1/jukebox/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
item
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.jukebox.items = this.jukebox.items
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteItem(itemId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this item?')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/jukebox/api/v1/jukebox/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item deleted.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.jukebox.items.splice(
|
||||||
|
this.jukebox.items.findIndex(item => item.id === itemId),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.selectedWallet = this.g.user.wallets[0]
|
||||||
|
this.loadShop()
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/jukebox/api/v1/currencies')
|
||||||
|
.then(response => {
|
||||||
|
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
146
lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
Normal file
146
lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="How to use"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<ol>
|
||||||
|
<li>Register items.</li>
|
||||||
|
<li>
|
||||||
|
Print QR codes and paste them on your store, your menu, somewhere,
|
||||||
|
somehow.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Clients scan the QR codes and get information about the items plus the
|
||||||
|
price on their phones directly (they must have internet)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Once they decide to pay, they'll get an invoice on their phones
|
||||||
|
automatically
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
When the payment is confirmed, a confirmation code will be issued for
|
||||||
|
them.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
The confirmation codes are words from a predefined sequential word list.
|
||||||
|
Each new payment bumps the words sequence by 1. So you can check the
|
||||||
|
confirmation codes manually by just looking at them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For example, if your wordlist is
|
||||||
|
<code>[apple, banana, coconut]</code> the first purchase will be
|
||||||
|
<code>apple</code>, the second <code>banana</code> and so on. When it
|
||||||
|
gets to the end it starts from the beginning again.
|
||||||
|
</p>
|
||||||
|
<p>Powered by LNURL-pay.</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Create item (a shop will be created automatically based on the wallet you use)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">POST</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}/jukebox/api/v1/jukebox/items -H
|
||||||
|
"Content-Type: application/json" -H "X-Api-Key: {{
|
||||||
|
g.user.wallets[0].inkey }}" -d '{"name": <string>,
|
||||||
|
"description": <string>, "image": <data-uri string>,
|
||||||
|
"price": <integer>, "unit": <"sat" or "USD">}'
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get the shop data along with items"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"id": <integer>, "wallet": <string>, "wordlist":
|
||||||
|
<string>, "items": [{"id": <integer>, "name":
|
||||||
|
<string>, "description": <string>, "image":
|
||||||
|
<string>, "enabled": <boolean>, "price": <integer>,
|
||||||
|
"unit": <string>, "lnurl": <string>}, ...]}<</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}/jukebox/api/v1/jukebox -H
|
||||||
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Update item (all fields must be sent again)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">PUT</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}/jukebox/api/v1/jukebox/items/<item_id> -H "Content-Type:
|
||||||
|
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
|
||||||
|
'{"name": <string>, "description": <string>, "image":
|
||||||
|
<data-uri string>, "price": <integer>, "unit": <"sat"
|
||||||
|
or "USD">}'
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Delete item">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">DELETE</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}/jukebox/api/v1/jukebox/items/<item_id> -H "X-Api-Key: {{
|
||||||
|
g.user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
211
lnbits/extensions/jukebox/templates/jukebox/index.html
Normal file
211
lnbits/extensions/jukebox/templates/jukebox/index.html
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Items</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
|
||||||
|
>Add jukebox</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% raw %}
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
selection="multiple"
|
||||||
|
:data="jukebox.items"
|
||||||
|
row-key="id"
|
||||||
|
no-data-label="No items for sale yet"
|
||||||
|
:pagination="{rowsPerPage: 0}"
|
||||||
|
:binary-state-sort="true"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width>Name</q-th>
|
||||||
|
<q-th auto-width>Description</q-th>
|
||||||
|
<q-th auto-width>Image</q-th>
|
||||||
|
<q-th auto-width>Price</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
:icon="props.row.enabled ? 'done' : 'block'"
|
||||||
|
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
|
||||||
|
type="a"
|
||||||
|
@click="toggleItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
|
||||||
|
<q-td auto-width> {{ props.row.description }} </q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
<img
|
||||||
|
v-if="props.row.image"
|
||||||
|
:src="props.row.image"
|
||||||
|
style="height: 1.5em"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
{{ props.row.price }} {{ props.row.unit }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="openUpdateDialog(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
type="a"
|
||||||
|
@click="deleteItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits jukebox extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "jukebox/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="itemDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-card-section>
|
||||||
|
<h5
|
||||||
|
class="q-ma-none"
|
||||||
|
v-if="itemDialog.data.id"
|
||||||
|
v-text="itemDialog.data.name"
|
||||||
|
></h5>
|
||||||
|
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
|
||||||
|
|
||||||
|
<q-responsive v-if="itemDialog.data.id" :ratio="1">
|
||||||
|
<qrcode
|
||||||
|
:value="itemDialog.data.lnurl"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
|
||||||
|
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
||||||
|
class="q-mb-lg"
|
||||||
|
>Copy LNURL</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<q-form @submit="sendItem" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.name"
|
||||||
|
type="text"
|
||||||
|
label="Item name"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.description"
|
||||||
|
type="text"
|
||||||
|
label="Brief description"
|
||||||
|
></q-input>
|
||||||
|
<q-file
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
capture="environment"
|
||||||
|
accept="image/jpeg, image/png"
|
||||||
|
:max-file-size="3*1024**2"
|
||||||
|
label="Small image (optional)"
|
||||||
|
clearable
|
||||||
|
@input="imageAdded"
|
||||||
|
@clear="imageCleared"
|
||||||
|
>
|
||||||
|
<template v-if="itemDialog.data.image" v-slot:before>
|
||||||
|
<img style="height: 1em" :src="itemDialog.data.image" />
|
||||||
|
</template>
|
||||||
|
<template v-if="itemDialog.data.image" v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
name="cancel"
|
||||||
|
@click.stop.prevent="imageCleared"
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-file>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="itemDialog.data.price"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:label="`Item price (${itemDialog.data.unit})`"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="itemDialog.data.unit"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="itemDialog.units"
|
||||||
|
></q-select>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="disabledAddItemButton()"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
|
||||||
|
Item
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||||
|
<script src="/jukebox/static/js/index.js"></script>
|
||||||
|
{% endblock %}
|
25
lnbits/extensions/jukebox/templates/jukebox/jukebox.html
Normal file
25
lnbits/extensions/jukebox/templates/jukebox/jukebox.html
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends "print.html" %} {% block page %} {% raw %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div v-for="item in items" class="q-my-sm q-mx-lg">
|
||||||
|
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
|
||||||
|
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
|
||||||
|
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %} {% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
created: function () {
|
||||||
|
window.print()
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
items: JSON.parse('{{items | tojson}}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
25
lnbits/extensions/jukebox/views.py
Normal file
25
lnbits/extensions/jukebox/views.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from quart import g, render_template, request
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.crud import get_standalone_payment
|
||||||
|
|
||||||
|
from . import jukebox_ext
|
||||||
|
from .crud import get_jukebox
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
async def index():
|
||||||
|
return await render_template("jukebox/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.route("/<juke_id>")
|
||||||
|
async def print_qr_codes(juke_id):
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
|
||||||
|
return await render_template("jukebox/jukebox.html", jukebox=jukebox)
|
54
lnbits/extensions/jukebox/views_api.py
Normal file
54
lnbits/extensions/jukebox/views_api.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
from quart import g, jsonify
|
||||||
|
from http import HTTPStatus
|
||||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||||
|
|
||||||
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
|
from . import jukebox_ext
|
||||||
|
from .crud import (
|
||||||
|
create_update_jukebox,
|
||||||
|
get_jukebox,
|
||||||
|
get_jukeboxs,
|
||||||
|
delete_jukebox,
|
||||||
|
)
|
||||||
|
from .models import Jukebox
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.route("/api/v1/jukebox", methods=["GET"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_get_jukeboxs():
|
||||||
|
jukebox = await get_jukeboxs(g.wallet.id)
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
jukebox._asdict()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
#websocket get spotify crap
|
||||||
|
|
||||||
|
@jukebox_ext.route("/api/v1/jukebox/items", methods=["POST"])
|
||||||
|
@jukebox_ext.route("/api/v1/jukebox/items/<item_id>", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("admin")
|
||||||
|
@api_validate_post_request(
|
||||||
|
|
||||||
|
schema={
|
||||||
|
"wallet": {"type": "string", "empty": False},
|
||||||
|
"user": {"type": "string", "empty": False},
|
||||||
|
"secret": {"type": "string", "required": False},
|
||||||
|
"token": {"type": "string", "required": True},
|
||||||
|
"playlists": {"type": "string", "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_create_update_jukebox(item_id=None):
|
||||||
|
jukebox = await create_update_jukebox(g.wallet.id, **g.data)
|
||||||
|
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("admin")
|
||||||
|
async def api_delete_item(juke_id):
|
||||||
|
shop = await delete_jukebox(juke_id)
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
Loading…
Add table
Reference in a new issue