[WIP] add user notifications and text templates
All checks were successful
/ test (push) Successful in 54s
All checks were successful
/ test (push) Successful in 54s
This commit is contained in:
parent
f7fda52fd0
commit
ba031a3204
22 changed files with 750 additions and 28 deletions
112
web/src/components/inputs/FormatedText.vue
Normal file
112
web/src/components/inputs/FormatedText.vue
Normal file
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div contenteditable @input="onchange" ref="text">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormatedText',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
format: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selection: {start: 0, end: 0, direction: 'forward', type: 'Caret'}
|
||||
};
|
||||
},
|
||||
emits: ['input'],
|
||||
methods: {
|
||||
rawhtml(value) {
|
||||
if (typeof this.format === 'function') {
|
||||
return this.format(value.replace(/ /g, ' '));
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
onchange(event) {
|
||||
const div = this.$refs.text;
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) {
|
||||
this.selection.start = this.calculateOffset(div, sel.anchorNode, sel.anchorOffset);
|
||||
this.selection.end = this.calculateOffset(div, sel.focusNode, sel.focusOffset);
|
||||
this.selection.direction = sel.direction;
|
||||
this.selection.type = sel.type;
|
||||
}
|
||||
this.$emit('input', event.target.innerText.replace(/ /g, ' ').replace(/\xA0/g, ' '));
|
||||
},
|
||||
calculateOffset(container, node, offset) {
|
||||
let position = 0;
|
||||
let found = false;
|
||||
const walk = (elem) => {
|
||||
if (elem === node) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (elem.nodeType === 3) {
|
||||
position += elem.length;
|
||||
} else {
|
||||
for (let i = 0; i < elem.childNodes.length; i++) {
|
||||
walk(elem.childNodes[i]);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(container);
|
||||
return position + offset;
|
||||
},
|
||||
findNode(container, offset) {
|
||||
let position = 0;
|
||||
let found = false;
|
||||
let node = null;
|
||||
const walk = (elem) => {
|
||||
if (position + elem.length >= offset) {
|
||||
found = true;
|
||||
node = elem;
|
||||
return;
|
||||
}
|
||||
if (elem.nodeType === 3) {
|
||||
position += elem.length;
|
||||
} else {
|
||||
for (let i = 0; i < elem.childNodes.length; i++) {
|
||||
walk(elem.childNodes[i]);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(container);
|
||||
return [node, offset - position]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
if (this.selection) {
|
||||
const div = this.$refs.text;
|
||||
div.innerHTML = this.rawhtml(this.value);
|
||||
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.setStart(...this.findNode(div, this.selection.start));
|
||||
range.setEnd(...this.findNode(div, this.selection.end));
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const div = this.$refs.text;
|
||||
div.innerHTML = this.rawhtml(this.value);
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -14,9 +14,11 @@ import Ticket from "@/views/Ticket.vue";
|
|||
import Admin from "@/views/admin/Admin.vue";
|
||||
import Empty from "@/views/Empty.vue";
|
||||
import Events from "@/views/admin/Events.vue";
|
||||
import Settings from "@/views/admin/Settings.vue";
|
||||
import AccessControl from "@/views/admin/AccessControl.vue";
|
||||
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
|
||||
import Shipping from "@/views/admin/Shipping.vue";
|
||||
import Notifications from "@/views/admin/Notifications.vue";
|
||||
|
||||
const routes = [
|
||||
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
|
||||
|
@ -59,6 +61,10 @@ const routes = [
|
|||
path: 'events/', name: 'events', component: Events, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
},
|
||||
{
|
||||
path: 'settings/', name: 'settings', component: Settings, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
},
|
||||
{
|
||||
path: '', name: 'admin', component: Dashboard, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
|
@ -75,6 +81,10 @@ const routes = [
|
|||
path: 'shipping/', name: 'shipping', component: Shipping, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
},
|
||||
{
|
||||
path: 'notifications/', name: 'notifications', component: Notifications, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
}
|
||||
]
|
||||
},
|
||||
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
|
||||
|
|
101
web/src/store.js
101
web/src/store.js
|
@ -17,7 +17,10 @@ const store = createStore({
|
|||
users: [],
|
||||
groups: [],
|
||||
state_options: [],
|
||||
messageTemplates: [],
|
||||
messageTemplateVariables: [],
|
||||
shippingVouchers: [],
|
||||
userNotificationChannels: [],
|
||||
|
||||
loadedItems: {},
|
||||
loadedTickets: {},
|
||||
|
@ -44,7 +47,9 @@ const store = createStore({
|
|||
users: 0,
|
||||
groups: 0,
|
||||
states: 0,
|
||||
messageTemplates: 0,
|
||||
shippingVouchers: 0,
|
||||
userNotificationChannels: 0,
|
||||
},
|
||||
persistent_loaded: false,
|
||||
shared_loaded: false,
|
||||
|
@ -243,10 +248,21 @@ const store = createStore({
|
|||
setThumbnail(state, {url, data}) {
|
||||
state.thumbnailCache[url] = data;
|
||||
},
|
||||
setMessageTemplates(state, templates) {
|
||||
state.messageTemplates = templates;
|
||||
state.fetchedData = {...state.fetchedData, messageTemplates: Date.now()};
|
||||
},
|
||||
setMessageTemplateVariables(state, variables) {
|
||||
state.messageTemplateVariables = variables;
|
||||
},
|
||||
setShippingVouchers(state, codes) {
|
||||
state.shippingVouchers = codes;
|
||||
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
|
||||
},
|
||||
setUserNotificationChannels(state, channels) {
|
||||
state.userNotificationChannels = channels;
|
||||
state.fetchedData = {...state.fetchedData, userNotificationChannels: Date.now()};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login({commit}, {username, password, remember}) {
|
||||
|
@ -339,7 +355,7 @@ const store = createStore({
|
|||
commit('replaceEvents', [...state.events.filter(e => e.id !== event_id)])
|
||||
}
|
||||
},
|
||||
async updateEvent({commit, dispatch, state}, {id, partial_event}){
|
||||
async updateEvent({commit, dispatch, state}, {id, partial_event}) {
|
||||
const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token);
|
||||
if (success) {
|
||||
commit('replaceEvents', [...state.events.filter(e => e.id !== id), data])
|
||||
|
@ -456,7 +472,10 @@ const store = createStore({
|
|||
}
|
||||
},
|
||||
async postItemComment({commit, dispatch, state, getters}, {id, message}) {
|
||||
const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
|
||||
const {
|
||||
data,
|
||||
success
|
||||
} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
|
||||
if (data && success) {
|
||||
state.fetchedData.items = 0;
|
||||
await dispatch('loadEventItems');
|
||||
|
@ -482,6 +501,39 @@ const store = createStore({
|
|||
const {data, success} = await getters.session.patch(`/2/tickets/${id}/`, ticket);
|
||||
commit('updateTicket', data);
|
||||
},
|
||||
async fetchMessageTemplates({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.messageTemplates.length > 0) return;
|
||||
const {data, success} = await http.get('/2/message_templates/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplates', data);
|
||||
}
|
||||
},
|
||||
async updateMessageTemplate({dispatch, state}, template) {
|
||||
const {data, success} = await http.patch(`/2/message_templates/${template.id}/`,
|
||||
{'message': template.message}, state.user.token);
|
||||
if (data && success) {
|
||||
state.fetchedData.messageTemplates = 0;
|
||||
dispatch('fetchMessageTemplates');
|
||||
}
|
||||
},
|
||||
async fetchMessageTemplateVariables({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.messageTemplateVariables.length > 0) return;
|
||||
const {data, success} = await http.get('/2/message_template_variables/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplateVariables', data);
|
||||
}
|
||||
},
|
||||
async createMessageTemplate({dispatch, state}, template_name) {
|
||||
const {data, success} = await http.post('/2/message_templates/', {
|
||||
name: template_name,
|
||||
message: '-'
|
||||
}, state.user.token);
|
||||
if (data && success) {
|
||||
dispatch('fetchMessageTemplates');
|
||||
}
|
||||
},
|
||||
async fetchShippingVouchers({commit, state, getters}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
|
@ -505,23 +557,36 @@ const store = createStore({
|
|||
state.fetchedData.tickets = 0;
|
||||
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
|
||||
}
|
||||
}
|
||||
},
|
||||
async fetchUserNotificationChannels({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.userNotificationChannels > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/user_notification_channels/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setUserNotificationChannels', data);
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
|
||||
prefix: "lf_",
|
||||
debug: false,
|
||||
isLoadedKey: "persistent_loaded",
|
||||
state: ["remember", "user", "events", "lastUsed",]
|
||||
}), sharedStatePlugin({
|
||||
debug: false,
|
||||
isLoadedKey: "shared_loaded",
|
||||
clearingMutation: "logout",
|
||||
afterInit: "afterSharedInit",
|
||||
state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
|
||||
watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
|
||||
mutations: [//"replaceTickets",
|
||||
],
|
||||
}),],
|
||||
plugins: [
|
||||
persistentStatePlugin({ // TODO change remember to some kind of enable field
|
||||
prefix: "lf_",
|
||||
debug: false,
|
||||
isLoadedKey: "persistent_loaded",
|
||||
state: ["remember", "user", "events", "lastUsed",]
|
||||
}),
|
||||
sharedStatePlugin({
|
||||
debug: false,
|
||||
isLoadedKey: "shared_loaded",
|
||||
clearingMutation: "logout",
|
||||
afterInit: "afterSharedInit",
|
||||
state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes",
|
||||
"loadedTickets", "shippingVouchers", "messageTemplates", "messageTemplatesVariables",],
|
||||
watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes",
|
||||
"loadedTickets", "shippingVouchers", "messageTemplates", "messageTemplatesVariables",],
|
||||
mutations: [//"replaceTickets",
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
store.watch((state) => state.user, (user) => {
|
||||
|
|
|
@ -8,9 +8,15 @@
|
|||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'admin'}" active-class="dummy" exact-active-class="active">Dashboard</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'settings'}" active-class="active">Settings</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'notifications'}" active-class="active">Notifications</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'shipping'}" active-class="active">Shipping</router-link>
|
||||
</li>
|
||||
|
|
53
web/src/views/admin/Notifications.vue
Normal file
53
web/src/views/admin/Notifications.vue
Normal file
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div>
|
||||
<Table :items="userNotificationChannels.map(channel => ({...channel, username: channel.user.username || {}}))"
|
||||
:columns="['id', 'username', 'channel_type', 'channel_target', 'event_filter', /*'active', 'created'*/]">
|
||||
<template #actions="{ item }">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger" @click.stop="">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
delete
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<div class="input-group">
|
||||
<select class="form-control">
|
||||
<option value="1">user</option>
|
||||
<option value="2">admin</option>
|
||||
</select>
|
||||
<select class="form-control">
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<input type="text" class="form-control" placeholder="channel_target">
|
||||
<input type="text" class="form-control" value="*">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Notifications',
|
||||
components: {Table},
|
||||
computed: mapState(['userNotificationChannels']),
|
||||
methods: mapActions(['fetchUserNotificationChannels']),
|
||||
mounted() {
|
||||
this.fetchUserNotificationChannels();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
109
web/src/views/admin/Settings.vue
Normal file
109
web/src/views/admin/Settings.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<h3 class="text-center">Available Message Template Variables</h3>
|
||||
<p>
|
||||
<span v-for="(variable, key) in messageTemplateVariables" :key="key" class="badge badge-primary"
|
||||
style="margin: 5px;">
|
||||
{{ variable }}
|
||||
</span>
|
||||
</p>
|
||||
<h3 class="text-center">Message Templates</h3>
|
||||
<div v-for="template in messageTemplatesIntermediate" :key="template.id" class="card bg-dark"
|
||||
style="margin-bottom: 10px;">
|
||||
<div class="card-header">{{ template.name }}</div>
|
||||
<FormatedText :value="template.message" :format="formatText" class="card-body"
|
||||
@input="changeMessageTemplate(template.id, $event)"/>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary" @click="resetMessageTemplate(template.id)"
|
||||
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Reset
|
||||
</button>
|
||||
<button class="btn btn-success" @click="saveMessageTemplate(template.id)"
|
||||
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="newTemplateName" placeholder="New Template Name">
|
||||
<button class="btn btn-success input-group-btn" @click="createMessageTemplateAndReset()"
|
||||
ref="createButton">Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
import FormatedText from "@/components/inputs/FormatedText.vue";
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {FormatedText, Table},
|
||||
data() {
|
||||
return {
|
||||
messageTemplatesIntermediate: [],
|
||||
newTemplateName: '',
|
||||
};
|
||||
},
|
||||
computed: mapState(['messageTemplates', 'messageTemplateVariables']),
|
||||
methods: {
|
||||
...mapActions(['fetchMessageTemplates', 'fetchMessageTemplateVariables', 'updateMessageTemplate', 'createMessageTemplate']),
|
||||
formatText(value) {
|
||||
return value.replace(/{{(.+?)}}/g, (match, key) => {
|
||||
return `<span class="text-primary">{{${key}}}</span>`;
|
||||
}).replace(/\n/g, '<br>').replace(/\t/g, ' ');
|
||||
},
|
||||
changeMessageTemplate(id, message) {
|
||||
console.log(id, message);
|
||||
this.messageTemplatesIntermediate.forEach(template => {
|
||||
if (template.id === id) {
|
||||
template.message = message;
|
||||
}
|
||||
});
|
||||
},
|
||||
saveMessageTemplate(id) {
|
||||
this.updateMessageTemplate(this.messageTemplatesIntermediate.find(template => template.id === id));
|
||||
},
|
||||
resetMessageTemplate(id) {
|
||||
this.messageTemplatesIntermediate.find(template => template.id === id).message =
|
||||
this.messageTemplates.find(template => template.id === id).message;
|
||||
},
|
||||
async createMessageTemplateAndReset() {
|
||||
this.$refs.createButton.disabled = true;
|
||||
await this.createMessageTemplate(this.newTemplateName);
|
||||
this.newTemplateName = '';
|
||||
this.$refs.createButton.disabled = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchMessageTemplates().then(() => {
|
||||
this.messageTemplatesIntermediate = JSON.parse(JSON.stringify(this.messageTemplates));
|
||||
});
|
||||
this.fetchMessageTemplateVariables();
|
||||
},
|
||||
watch: {
|
||||
messageTemplates() {
|
||||
for (const template of this.messageTemplates) {
|
||||
if (!this.messageTemplatesIntermediate.find(t => t.id === template.id)) {
|
||||
this.messageTemplatesIntermediate.push(JSON.parse(JSON.stringify(template)));
|
||||
}
|
||||
}
|
||||
for (const template of this.messageTemplatesIntermediate) {
|
||||
if (!this.messageTemplates.find(t => t.id === template.id)) {
|
||||
this.messageTemplatesIntermediate = this.messageTemplatesIntermediate.filter(t => t.id !== template.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue