manage program editors

This commit is contained in:
bkfox
2024-04-22 23:54:44 +02:00
parent b28105c659
commit a2a399e531
31 changed files with 448 additions and 153 deletions

View File

@ -2,7 +2,7 @@ import './styles/admin.scss'
import './index.js'
import App from './app';
import {admin as components} from './components'
import components from './components/admin.js'
const AdminApp = {
...App,

View File

@ -17,10 +17,22 @@ const App = {
},
methods: {
//! Delete elements from DOM using provided selector.
deleteElements(sel) {
for(var el of document.querySelectorAll(sel))
el.parentNode.removeChild(el)
}
},
//! File has been selected
//! TODO: replace using regular ref and bindings.
fileSelected(select, input, preview) {
const item = this.$refs[select].item
if(item) {
this.$refs[input].value = item.id
if(preview)
preview.src = item.file
}
},
}
}

View File

@ -20,24 +20,23 @@
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ labelField && selected.data[labelField] || selected }}
{{ selectedLabel }}
</slot>
</span>
</a>
<div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<a v-for="(item, index) in items" :key="item.id"
href="#" :data-autocomplete-index="index"
<span v-for="(item, index) in items" :key="item.id"
:data-autocomplete-index="index"
@click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
:title="labelField && item.data[labelField] || item"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ labelField && item.data[labelField] || item }}
{{ getValue(item, labelField) || item }}
</slot>
</a>
</span>
</div>
</div>
</div>
@ -56,12 +55,14 @@ export default {
props: {
//! Search URL (where `${query}` is replaced by search term)
url: String,
//! Extra GET url parameters
urlParams: Object,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: String,
placeholder: Object,
//! input form field name
name: String,
//! Field on items to use as label
@ -105,6 +106,20 @@ export default {
},
computed: {
fullUrl() {
if(!this.urlParams)
return this.url
const url = new URL(this.url, window.location.origin)
const params = new URLSearchParams(url.searchParams)
for(var key in this.urlParams)
params.set(key, this.urlParams[key])
const join = this.url.indexOf("?") >= 0 ? "&" : "?"
url.search = params.toString()
return url.href
},
isFetching() { return !!this.promise },
selected() {
@ -136,12 +151,34 @@ export default {
},
methods: {
reset() {
this.inputValue = ""
this.selectedIndex = -1
this.items = []
},
// TODO: move to utils/data
getValue(data, path=null) {
if(!data)
return null
if(!path)
return data
const paths = path.split('.')
for(const key of paths) {
if(key in data)
data = data[key]
else return null;
}
return data
},
itemValue(item) {
return this.valueField ? item && item[this.valueField] : item;
return this.valueField ? this.getValue(item, this.valueField) : item;
},
itemLabel(item) {
return this.labelField ? item && item[this.labelField] : item;
return this.labelField ? this.getValue(item, this.labelField) : item;
},
hide() {
@ -183,7 +220,7 @@ export default {
if(!this.items.length)
return
var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex);
var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null)
this.select(index, false, false)
this.cursor = -1;
@ -227,7 +264,7 @@ export default {
return
this.query = query
var url = this.url.replace('${query}', query)
var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query)
var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json())

View File

@ -6,7 +6,7 @@
:value="value"/>
</template>
<a-rows ref="rows" :set="set"
<a-rows ref="rows" :set="set" :context="this"
:columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onColumnMove"
@cell="e => $emit('cell', e)">
@ -45,7 +45,7 @@
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
v-slot:[slot]="data">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<div class="field">
<div class="control">
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
@ -118,8 +118,6 @@
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
//! initial data set load at mount
initials: Array,
},
data() {
@ -184,7 +182,7 @@
//! Reset forms to initials
reset() {
this.load(this.initials || [], true)
this.load(this.formData?.initials || [], true)
},
},

View File

@ -0,0 +1,102 @@
<template>
<div class="a-group-users">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
Members
</th>
<th style="width: 1rem">
<span class="icon">
<i class="fa fa-trash"/>
</span>
</th>
</tr>
</thead>
<tbody>
<template v-for="item of items" :key="item.id">
<tr>
<td>
<b class="mr-3">{{ item.data.user.username }}</b>
<span class="text-light">{{ item.data.user.first_name }} {{ item.data.user.last_name }}</span>
</td>
<td class="align-center">
<input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked">
</td>
</tr>
</template>
</tbody>
</table>
<div>
<label>
<span class="icon">
<i class="fa fa-user"/>
</span>
Add user
</label>
<a-autocomplete ref="autocomplete" :url="searchUrl"
label-field="username" value-field="id"
@select="onUserSelect">
<template #item="{item}">
<b class="mr-3">{{ item.username }}</b>
<span class="text-light">{{ item.first_name }} {{ item.last_name }}</span>
&mdash;
<i>{{ item.email }}</i>
</template>
</a-autocomplete>
</div>
</div>
</template>
<script>
import Model, { Set } from "../model.js"
import AAutocomplete from "./AAutocomplete.vue"
export default {
components: {AAutocomplete},
props: {
model: {type: Function, default: Model },
// List url
url: String,
// User autocomplete url
searchUrl: String,
// POST url
commitUrl: String,
// default values
initials: {type: Object, default: () => ({})},
},
data() {
return {
set: new Set(this.model, {url: this.url, unique: true}),
}
},
computed: {
items() { return this.set?.items || [] },
user_ids() { return this.set?.items.map(i => i.data.user.id) },
},
methods: {
onUserSelect(index, item, value) {
if(this.user_ids.indexOf(item.id) != -1)
return
this.set.push({
...this.initials,
user: {...item},
})
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
getData: i => ({...this.initials, user_id: i.data.user.id})
})
},
},
mounted() {
this.set.fetch()
},
}
</script>

View File

@ -4,9 +4,9 @@
<div class="modal-card">
<header class="modal-card-head">
<div class="modal-card-title">
<slot name="title">{{ title }}</slot>
<slot name="title" :item="item">{{ title }}</slot>
</div>
<slot name="bar"></slot>
<slot name="bar" :item="item"></slot>
<button type="button" class="delete square" aria-label="close" @click="close">
<span class="icon">
<i class="fa fa-close"></i>

View File

@ -1,25 +1,25 @@
<template>
<tr>
<slot name="head" :item="item" :row="row"/>
<slot name="head" :context="context" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col">
<slot name="cell-before" :item="item" :cell="cells[col]"
<slot name="cell-before" :context="context" :item="item" :cell="cells[col]"
:attr="attr"/>
<component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
<slot :name="attr" :item="item" :cell="cells[col]"
<slot :name="attr" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]">
{{ itemData && itemData[attr] }}
</slot>
<slot name="cell" :item="item" :cell="cells[col]"
<slot name="cell" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]"/>
</component>
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
<slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template>
<slot name="tail" :item="item" :row="row"/>
<slot name="tail" :context="context" :item="item" :row="row"/>
</tr>
</template>
<script>
@ -30,6 +30,8 @@ export default {
emits: ['move', 'cell'],
props: {
//! Context object
context: {type: Object, default: () => ({})},
//! Item to display in row
item: {type: Object, default: () => ({})},
//! Columns to display, as items' attributes

View File

@ -1,7 +1,7 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<a-row :columns="columnNames"
<a-row :context="context" :columns="columnNames"
:orderable="columnsOrderable" cellTag="th"
@move="moveColumn">
<template v-if="$slots['header-head']" v-slot:head="data">
@ -26,7 +26,7 @@
<slot name="head"/>
<template v-for="(item,row) in items" :key="row">
<!-- data-index comes from AList component drag & drop -->
<a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
<a-row :context="context" :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@ -54,6 +54,9 @@ const Component = {
props: {
...AList.props,
//! Context object
context: {type: Object, default: () => ({})},
//! Ordered list of columns, as objects with:
//! - name: item attribute value
//! - label: display label

View File

@ -0,0 +1,23 @@
import AFileUpload from "./AFileUpload.vue"
import ASelectFile from "./ASelectFile.vue"
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
import AGroupUsers from "./AGroupUsers.vue"
import base from "./index.js"
export const admin = {
...base,
AGroupUsers,
AFileUpload, ASelectFile,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}
export default admin

View File

@ -1,7 +1,8 @@
import AActionButton from './AActionButton.vue'
import AAutocomplete from './AAutocomplete.vue'
import ACarousel from './ACarousel.vue'
import AModal from "./AModal.vue"
import AActionButton from './AActionButton.vue'
import ADropdown from "./ADropdown.vue"
import ACarousel from './ACarousel.vue'
import AEpisode from './AEpisode.vue'
import AList from './AList.vue'
import APage from './APage.vue'
@ -11,31 +12,15 @@ import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue'
import ASwitch from './ASwitch.vue'
import AModal from "./AModal.vue"
import AFileUpload from "./AFileUpload.vue"
import ASelectFile from "./ASelectFile.vue"
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
/**
* Core components
*/
export const base = {
AAutocomplete, ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist,
AActionButton, AAutocomplete, AModal,
ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem, ASwitch,
}
export default base
export const admin = {
...base,
AActionButton, AFileUpload, ASelectFile, AModal,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}

View File

@ -113,7 +113,7 @@ export default class Model {
}
/**
* Update instance's data with provided data. Return None
* Set instance's data with provided data. Return None
*/
commit(data) {
this.data = data;
@ -121,11 +121,17 @@ export default class Model {
}
/**
* Update model data, without reset previous value
* Update model data, without reset previous value.
* Item is marked as updated.
*/
update(data) {
this.data = {...this.data, ...data}
this.id = this.constructor.getId(this.data)
this.updated = true
}
delete() {
this.deleted = true
}
/**
@ -177,8 +183,24 @@ export class Set {
this.push(item, {args: args, save: false});
}
//! Return total items count
get length() { return this.items.length }
//! Return a list of items marked as deleted
get deletedItems() {
return this.items.filter(i => i.deleted)
}
//! Return a list of created items
get createdItems() {
return this.items.filter(i => !i.deleted && !i.id)
}
//! Return a list of updated items
get updatedItems() {
return this.items.filter(i => i.updated)
}
/**
* Fetch multiple items from server
*/
@ -190,6 +212,58 @@ export class Set {
.map(d => new model(d, {url: url, ...args})))
}
fetch({url=null, reset=false, ...options}={}, args=null) {
url = url || this.url
options = this.model.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data =>
(data instanceof Array ? data : data.results)
.map(d => new this.model(d, {url: url, ...args}))
)
.then(data => {
if(reset)
this.items = data
else
// TODO: remove duplicate
this.items = [...this.items, ...data]
return data
})
}
/**
* Commit changes to server.
* ref: `views.mixin.ListCommitMixin`
*/
commit(url, {getData=null, ...options}={}) {
const createdItems = this.createdItems
const body = {
delete: this.deletedItems.map(i => i.id),
update: this.updatedItems.map(getData),
create: createdItems.map(getData),
}
if(!body.delete && !body.update && !body.create)
return
getData = getData || ((i) => i.data);
options = this.model.getOptions(options)
options.method = "POST"
options.body = JSON.stringify(body)
return fetch(url, options)
.then(response => response.json())
.then(data => {
const {created, updated, deleted} = data
if(createdItems)
this.items = this.items.filter(i => createdItems.indexOf(i) == -1)
if(deleted)
this.items = this.items.filter(i => deleted.indexOf(i.id) == -1)
this.extend(created)
this.extend(updated)
return data
})
}
/**
* Load list from localStorage
*/
@ -234,22 +308,30 @@ export class Set {
: this.items.findIndex(x => x.id == pred.id);
}
extend(items, options) {
items.forEach(i => this.push(i, options))
}
/**
* Add item to set, return index.
* If item already exists, replace it.
*/
push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args);
if(this.unique) {
let index = this.findIndex(item);
let index = -1
if(this.unique && item.id) {
index = this.findIndex(item);
if(index > -1)
return index;
this.items[index] = item
}
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item);
save && this.save();
return this.items.length-1;
if(index == -1) {
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item)
index = this.items.length-1
}
save && this.save()
return index;
}
/**

View File

@ -309,9 +309,9 @@
.preview-header {
width: 100%;
&:not(.no-cover) {
/*&:not(.no-cover) {
min-height: var(--header-height);
}
}*/
&.no-cover {
height: unset;

View File

@ -21,6 +21,10 @@
&.x { padding-right: 0px !important; }
}
.align-center {
text-align: center !important;
justify-content: center;
}
.clear-left { clear: left !important }
.clear-right { clear: right !important }

View File

@ -25,6 +25,8 @@ export default defineConfig({
globals: {
vue: 'Vue',
},
assetFileNames: "[name].[ext]",
chunkFileNames: "[name].js",
entryFileNames: "[name].js",
},
plugins: [commonjs()],