btcpayserver/BTCPayServer/wwwroot/js/template-editor.js
2024-03-27 19:20:49 +09:00

313 lines
12 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const parseConfig = str => {
try {
return JSON.parse(str)
} catch (err) {
console.error('Error deserializing form config:', err)
}
}
const $config = document.getElementById('TemplateConfig')
let items = parseConfig($config.value) || []
const itemProps = {
id: String,
title: String,
image: String,
description: String,
priceType: String,
price: Number,
inventory: Number,
disabled: Boolean,
categories: Array
}
const Item = Vue.extend({
name: 'item',
template: '#item',
props: {
...itemProps
}
})
const ItemEditorUpload = Vue.component('item-editor-upload', {
template: '#item-editor-upload',
props: {
uploadUrl: {
type: String,
required: true
}
},
data () {
return {
error: null,
disabled: true
}
},
methods: {
fileChanged () {
this.disabled = !this.$refs.input || this.$refs.input.files.length === 0;
},
reportError(error) {
this.error = error;
this.$refs.input.classList.add('is-invalid');
this.$emit('error', error);
},
async upload() {
const file = this.$refs.input.files[0];
if (!file) return this.reportError('No file selected');
this.error = null;
this.$refs.input.classList.remove('is-invalid');
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(this.uploadUrl, { method: 'POST', body: formData });
if (response.ok) {
const { error, fileUrl } = await response.json();
if (error) {
this.reportError(error)
} else {
this.$refs.input.value = null;
this.disabled = true;
this.$emit('uploaded', fileUrl);
}
}
} catch (e) {
console.error(e);
this.reportError('Upload failed');
}
}
}
})
const ItemEditor = Vue.component('item-editor', {
template: '#item-editor',
components: {
Item,
ItemEditorUpload
},
props: {
item: itemProps
},
data () {
return {
errors: {},
editingItem: null,
categoriesSelect: null,
customPriceOptions: [
{ text: 'Fixed', value: 'Fixed' },
{ text: 'Minimum', value: 'Minimum' },
{ text: 'Custom', value: 'Topup' },
]
}
},
computed: {
allCategories() {
return this.$parent.allCategories;
}
},
methods: {
toId(value) {
return value.toLowerCase().trim().replace(/\W+/gi, '-')
},
onTitleChange(e) {
const $input = e.target;
$input.classList.toggle('is-invalid', !$input.checkValidity())
if (!$input.checkValidity()) {
Vue.set(this.errors, 'title', 'Title is required')
} else if (this.editingItem.title.startsWith('-')){
Vue.set(this.errors, 'title', 'Title cannot start with "-"')
} else if (!this.editingItem.title.trim()){
Vue.set(this.errors, 'title', 'Title is required')
} else {
Vue.delete(this.errors, 'title')
}
// set id from title if not set
if (!this.editingItem.id) {
this.editingItem.id = this.toId(this.editingItem.title)
Vue.delete(this.errors, 'id')
}
},
onIdChange(e) {
// set id from title if not set
if (!this.editingItem.id) this.editingItem.id = this.toId(this.editingItem.title)
// validate
const $input = e.target;
$input.classList.toggle('is-invalid', !$input.checkValidity())
if (this.editingItem.id) {
const existingItem = this.$parent.items.find(i => i.id === this.editingItem.id);
if (existingItem && existingItem.id !== this.item.id)
Vue.set(this.errors, 'id', `An item with the ID "${this.editingItem.id}" already exists`)
if (this.editingItem.id.startsWith('-'))
Vue.set(this.errors, 'id', 'ID cannot start with "-"')
else if (this.editingItem.id.trim() === '')
Vue.set(this.errors, 'id', 'ID is required')
else
Vue.delete(this.errors, 'id')
} else {
Vue.set(this.errors, 'id', 'ID is required')
}
},
onInventoryChange(e) {
const $input = e.target;
$input.classList.toggle('is-invalid', !$input.checkValidity())
if (!$input.checkValidity()) {
Vue.set(this.errors, 'inventory', 'Inventory must not be set or be a valid number (>=0)')
}
},
onPriceChange(e) {
const $input = e.target;
$input.classList.toggle('is-invalid', !$input.checkValidity())
if (this.editingItem.priceType !== 'Topup' && !$input.checkValidity()) {
Vue.set(this.errors, 'price', 'Price must be a valid number')
} else {
Vue.delete(this.errors, 'price')
}
},
onPriceTypeChange(e) {
const $input = e.target;
$input.classList.toggle('is-invalid', !$input.checkValidity())
if ($input.value === 'Topup') {
Vue.set(this.editingItem, 'price', null)
}
}
},
watch: {
item(newItem) {
this.errors = {};
this.editingItem = newItem;
if (this.editingItem != null) {
// update categories
this.categoriesSelect.clearOptions();
this.categoriesSelect.addOptions(this.allCategories.map(value => ({ value, text: value })));
this.categoriesSelect.setValue(this.editingItem.categories);
}
}
},
mounted() {
this.categoriesSelect = new TomSelect(this.$refs.editorCategories, {
persist: false,
createOnBlur: true,
create: true,
options: this.allCategories.map(value => ({ value, text: value })),
});
this.categoriesSelect.on('change', () => {
const value = this.categoriesSelect.getValue();
Vue.set(this.editingItem, 'categories', Array.from(value.split(',').reduce((res, item) => {
const category = item.trim();
if (category) res.add(category);
return res;
}, new Set())))
});
},
beforeDestroy() {
this.categoriesSelect.destroy();
}
})
const ItemsEditor = Vue.component('items-editor', {
template: '#items-editor',
components: {
Item
},
props: {
items: Array,
selectedItem: itemProps
},
methods: {
getImage(item) {
const image = item.image || '~/img/img-placeholder.svg';
return image.startsWith('~') ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image
}
}
})
Vue.use(vSortable)
Vue.use(VueSanitizeDirective.default)
new Vue({
el: '#TemplateEditor',
name: 'template-editor',
components: {
ItemsEditor,
ItemEditor
},
data () {
return {
items,
selectedItem: null,
selectedItemInitial: null,
editorOffcanvas: null,
}
},
computed: {
itemsJSON() {
return JSON.stringify(this.items, null, 2)
},
allCategories() {
return Array.from(this.items.reduce((res, item) => {
(item.categories || []).forEach(category => { res.add(category); });
return res;
}, new Set()));
},
itemChanged() {
return this.selectedItem && this.selectedItemInitial && (
this.selectedItem.id !== this.selectedItemInitial.id ||
this.selectedItem.title !== this.selectedItemInitial.title ||
this.selectedItem.price !== this.selectedItemInitial.price ||
this.selectedItem.image !== this.selectedItemInitial.image ||
this.selectedItem.disabled !== this.selectedItemInitial.disabled ||
this.selectedItem.inventory !== this.selectedItemInitial.inventory ||
this.selectedItem.priceType !== this.selectedItemInitial.priceType ||
this.selectedItem.categories !== this.selectedItemInitial.categories ||
this.selectedItem.description !== this.selectedItemInitial.description
)
}
},
methods: {
updateFromJSON(event) {
const items = parseConfig(event.target.value)
if (!items) return
this.items = items
this.selectedItem = this.selectedItemInitial = null
},
addItem(event) {
const length = this.items.push({
id: '',
title: '',
priceType: 'Fixed',
price: 0,
image: '',
description: '',
categories: [],
inventory: null,
disabled: false
})
this.selectItem(null, length - 1)
},
selectItem(event, index) {
this.selectedItem = this.items[index]
this.selectedItemInitial = { ...this.selectedItem } // pristine copy
this.showOffcanvas()
},
removeItem(event, index) {
this.items.splice(index, 1)
this.selectedItem = this.selectedItemInitial = null
},
sortItems(event) {
const { newIndex, oldIndex } = event
this.items.splice(newIndex, 0, this.items.splice(oldIndex, 1)[0])
},
showOffcanvas() {
if (window.getComputedStyle(this.$refs.editorOffcanvas).visibility === 'hidden')
this.editorOffcanvas.show();
},
hideOffcanvas() {
this.editorOffcanvas.hide();
}
},
mounted() {
if (!this.items) this.items = []
this.editorOffcanvas = bootstrap.Offcanvas.getOrCreateInstance(this.$refs.editorOffcanvas);
}
})
})