This is a major breaking change that moves away from having the entire app configuration in external TOML files to settings being in the database with a UI to update them dynamically. The app loads all config into memory (app settings, SMTP conf) on boot. "Hot" replacing them is complex and it's a fair tradeoff to instead just restart the application as it is practically instant. A new `settings` table stores arbitrary string keys with a JSONB value field which happens to support arbitrary types. After every settings update, the app gracefully releases all resources (HTTP server, DB pool, SMTP pool etc.) and restarts itself, occupying the same PID. If there are any running campaigns, the auto-restart doesn't happen and the user is prompted to invoke it manually with a one-click button once all running campaigns have been paused.
182 lines
4.7 KiB
Vue
182 lines
4.7 KiB
Vue
<template>
|
|
<section class="media-files">
|
|
<h1 class="title is-4">Media
|
|
<span v-if="media.length > 0">({{ media.length }})</span>
|
|
|
|
<span class="has-text-grey-light"> / {{ serverConfig.mediaProvider }}</span>
|
|
</h1>
|
|
|
|
<b-loading :active="isProcessing || loading.media"></b-loading>
|
|
|
|
<section class="wrap-small">
|
|
<form @submit.prevent="onSubmit" class="box">
|
|
<div>
|
|
<b-field label="Upload image">
|
|
<b-upload
|
|
v-model="form.files"
|
|
drag-drop
|
|
multiple
|
|
accept=".png,.jpg,.jpeg,.gif"
|
|
expanded required>
|
|
<div class="has-text-centered section">
|
|
<p>
|
|
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
|
|
</p>
|
|
<p>Click or drag one or more images here</p>
|
|
</div>
|
|
</b-upload>
|
|
</b-field>
|
|
<div class="tags" v-if="form.files.length > 0">
|
|
<b-tag v-for="(f, i) in form.files" :key="i" size="is-medium"
|
|
closable @close="removeUploadFile(i)">
|
|
{{ f.name }}
|
|
</b-tag>
|
|
</div>
|
|
<div class="buttons">
|
|
<b-button native-type="submit" type="is-primary" icon-left="file-upload-outline"
|
|
:disabled="form.files.length === 0"
|
|
:loading="isProcessing">Upload</b-button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="section gallery">
|
|
<div v-for="group in items" :key="group.title">
|
|
<h3 class="title is-5">{{ group.title }}</h3>
|
|
|
|
<div class="thumbs">
|
|
<div v-for="m in group.items" :key="m.id" class="box thumb">
|
|
<a @click="(e) => onMediaSelect(m, e)" :href="m.url" target="_blank">
|
|
<img :src="m.thumbUrl" :title="m.filename" />
|
|
</a>
|
|
<span class="caption is-size-7" :title="m.filename">{{ m.filename }}</span>
|
|
|
|
<div class="actions has-text-right">
|
|
<a :href="m.url" target="_blank">
|
|
<b-icon icon="arrow-top-right" size="is-small" />
|
|
</a>
|
|
<a href="#" @click.prevent="$utils.confirm(null, () => deleteMedia(m.id))">
|
|
<b-icon icon="trash-can-outline" size="is-small" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<hr />
|
|
</div>
|
|
</section>
|
|
|
|
</section>
|
|
</template>
|
|
|
|
<script>
|
|
import Vue from 'vue';
|
|
import { mapState } from 'vuex';
|
|
import dayjs from 'dayjs';
|
|
|
|
export default Vue.extend({
|
|
name: 'Media',
|
|
|
|
props: {
|
|
isModal: Boolean,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
form: {
|
|
files: [],
|
|
},
|
|
toUpload: 0,
|
|
uploaded: 0,
|
|
};
|
|
},
|
|
|
|
methods: {
|
|
removeUploadFile(i) {
|
|
this.form.files.splice(i, 1);
|
|
},
|
|
|
|
onMediaSelect(m, e) {
|
|
// If the component is open in the modal mode, close the modal and
|
|
// fire the selection event.
|
|
// Otherwise, do nothing and let the image open like a normal link.
|
|
if (this.isModal) {
|
|
e.preventDefault();
|
|
this.$emit('selected', m);
|
|
this.$parent.close();
|
|
}
|
|
},
|
|
|
|
onSubmit() {
|
|
this.toUpload = this.form.files.length;
|
|
|
|
// Upload N files with N requests.
|
|
for (let i = 0; i < this.toUpload; i += 1) {
|
|
const params = new FormData();
|
|
params.set('file', this.form.files[i]);
|
|
this.$api.uploadMedia(params).then(() => {
|
|
this.onUploaded();
|
|
}, () => {
|
|
this.onUploaded();
|
|
});
|
|
}
|
|
},
|
|
|
|
deleteMedia(id) {
|
|
this.$api.deleteMedia(id).then(() => {
|
|
this.$api.getMedia();
|
|
});
|
|
},
|
|
|
|
onUploaded() {
|
|
this.uploaded += 1;
|
|
if (this.uploaded >= this.toUpload) {
|
|
this.toUpload = 0;
|
|
this.uploaded = 0;
|
|
this.form.files = [];
|
|
|
|
this.$api.getMedia();
|
|
}
|
|
},
|
|
},
|
|
|
|
computed: {
|
|
...mapState(['media', 'serverConfig', 'loading']),
|
|
|
|
isProcessing() {
|
|
if (this.toUpload > 0 && this.uploaded < this.toUpload) {
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// Filters the list of media items by months into:
|
|
// [{"title": "Jan 2020", items: [...]}, ...]
|
|
items() {
|
|
const out = [];
|
|
if (!this.media || !(this.media instanceof Array)) {
|
|
return out;
|
|
}
|
|
|
|
let lastStamp = '';
|
|
let lastIndex = 0;
|
|
this.media.forEach((m) => {
|
|
const stamp = dayjs(m.createdAt).format('MMM YYYY');
|
|
if (stamp !== lastStamp) {
|
|
out.push({ title: stamp, items: [] });
|
|
lastStamp = stamp;
|
|
lastIndex = out.length;
|
|
}
|
|
|
|
out[lastIndex - 1].items.push(m);
|
|
});
|
|
return out;
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
this.$api.getMedia();
|
|
},
|
|
});
|
|
</script>
|