Polish UI elements and fix styling issues.

- Change global font to Inter.
- Introduce global top nav bar.
- Restyle form inputs to have inline labels.
- Restyle form inputs to have inline lengt counters.
- Override glitchy Buefy animations (sidebar, toast etc.)
- Fix tag alignment inside tables in responsive view.
- Refactor import page UI.
- Miscellaneous styling fixes.
- Add missing Fontello icons.
This commit is contained in:
Kailash Nadh 2020-07-26 19:43:43 +05:30
parent 942eb7c3d8
commit e2e65b1bc0
21 changed files with 580 additions and 413 deletions

View file

@ -9,9 +9,10 @@ listmonk is a standalone, self-hosted, newsletter and mailing list manager. It i
### Installation and use
- Download the [latest release](https://github.com/knadh/listmonk/releases) for your platform and extract the listmonk binary. For example: `tar -C $HOME/listmonk -xzf listmonk_$VERSION_$OS_$ARCH.tar.gz`
- Navigate to the directory containing the binary (`cd $HOME/listmonk`) and run `./listmonk --new-config` to generate a sample `config.toml` and add your configuration (SMTP and Postgres DB credentials primarily).
- Navigate to the directory containing the binary (`cd $HOME/listmonk`) and run `./listmonk --new-config` to generate a sample `config.toml` and add the DB configuration.
- `./listmonk --install` to setup the DB.
- Run `./listmonk` and visit `http://localhost:9000`.
- Visit the `Settings` page to configure your instance.
- Since there is no user auth yet, it's best to put listmonk behind a proxy like Nginx and setup basicauth on all endpoints except for the few endpoints that need to be public. Here is a [sample nginx config](https://github.com/knadh/listmonk/wiki/Production-Nginx-config) for production use.
### Configuration and customization

View file

@ -440,6 +440,48 @@
"content-save-outline"
]
},
{
"uid": "80491c76df0c066833e0f8211903d37c",
"css": "minus",
"code": 59423,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M791 541H209V459H791V541Z",
"width": 1000
},
"search": [
"minus"
]
},
{
"uid": "a7a02467d65aabd7cd61903ea3e855b6",
"css": "arrow-up",
"code": 59424,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M541 834H459V334L228.5 562.5 169.9 503.9 500 173.8 830.1 503.9 771.5 562.5 541 334V834Z",
"width": 1000
},
"search": [
"arrow-up"
]
},
{
"uid": "a9b97a98d1427ca1c4f90b2f8f4f03c1",
"css": "arrow-down",
"code": 59425,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M459 166H541V666L771.5 437.5 830.1 496.1 500 826.2 169.9 496.1 228.5 437.5 459 666V166Z",
"width": 1000
},
"search": [
"arrow-down"
]
},
{
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
"css": "vector-square",
@ -1364,20 +1406,6 @@
"arrow-collapse-all"
]
},
{
"uid": "a9b97a98d1427ca1c4f90b2f8f4f03c1",
"css": "arrow-down",
"code": 983109,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M459 166H541V666L771.5 437.5 830.1 496.1 500 826.2 169.9 496.1 228.5 437.5 459 666V166Z",
"width": 1000
},
"search": [
"arrow-down"
]
},
{
"uid": "578692c5a0b505985bf797ee8ebce545",
"css": "arrow-down-thick",
@ -1686,20 +1714,6 @@
"arrow-top-left"
]
},
{
"uid": "a7a02467d65aabd7cd61903ea3e855b6",
"css": "arrow-up",
"code": 983133,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M541 834H459V334L228.5 562.5 169.9 503.9 500 173.8 830.1 503.9 771.5 562.5 541 334V834Z",
"width": 1000
},
"search": [
"arrow-up"
]
},
{
"uid": "00e74cb9bfa86a1b90b39d2d8132c3b1",
"css": "arrow-up-thick",
@ -12774,20 +12788,6 @@
"minecraft"
]
},
{
"uid": "80491c76df0c066833e0f8211903d37c",
"css": "minus",
"code": 983924,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M791 541H209V459H791V541Z",
"width": 1000
},
"search": [
"minus"
]
},
{
"uid": "4dae8d34e12ee29474c244f25a6cbc1c",
"css": "minus-box",

View file

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>frontend/favicon.png" />
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= BASE_URL %>api/config.js"></script>
</head>

View file

@ -1,93 +1,103 @@
<template>
<div id="app">
<section class="sidebar">
<b-sidebar
type="is-white"
position="static"
mobile="reduce"
:fullheight="true"
:open="true"
:can-cancel="false"
>
<div>
<b-navbar :fixed-top="true">
<template slot="brand">
<div class="logo">
<a href="/"><img class="full" src="@/assets/logo.svg"/></a>
<img class="favicon" src="@/assets/favicon.png"/>
<p class="is-size-7 has-text-grey version">{{ version }}</p>
<router-link :to="{name: 'dashboard'}">
<img class="full" src="@/assets/logo.svg"/>
<img class="favicon" src="@/assets/favicon.png"/>
</router-link>
</div>
<b-menu :accordion="false">
<b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link"
:active="activeItem.dashboard"
icon="view-dashboard-variant-outline" label="Dashboard">
</b-menu-item><!-- dashboard -->
</template>
<template slot="end">
<b-navbar-item tag="div"></b-navbar-item>
</template>
</b-navbar>
<b-menu-item :expanded="activeGroup.lists"
icon="format-list-bulleted-square" label="Lists">
<b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists"
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
<div class="wrapper">
<section class="sidebar">
<b-sidebar
position="static"
mobile="reduce"
:fullheight="true"
:open="true"
:can-cancel="false"
>
<div>
<b-menu :accordion="false">
<b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link"
:active="activeItem.dashboard"
icon="view-dashboard-variant-outline" label="Dashboard">
</b-menu-item><!-- dashboard -->
<b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms"
icon="newspaper-variant-outline" label="Forms"></b-menu-item>
</b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.lists"
icon="format-list-bulleted-square" label="Lists">
<b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists"
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
<b-menu-item :expanded="activeGroup.subscribers"
icon="account-multiple" label="Subscribers">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers"
icon="account-multiple" label="All subscribers"></b-menu-item>
<b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms"
icon="newspaper-variant-outline" label="Forms"></b-menu-item>
</b-menu-item><!-- lists -->
<b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import"
icon="file-upload-outline" label="Import"></b-menu-item>
</b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.subscribers"
icon="account-multiple" label="Subscribers">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers"
icon="account-multiple" label="All subscribers"></b-menu-item>
<b-menu-item :expanded="activeGroup.campaigns"
icon="rocket-launch-outline" label="Campaigns">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns"
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import"
icon="file-upload-outline" label="Import"></b-menu-item>
</b-menu-item><!-- subscribers -->
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign"
icon="plus" label="Create new"></b-menu-item>
<b-menu-item :expanded="activeGroup.campaigns"
icon="rocket-launch-outline" label="Campaigns">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns"
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media"
icon="image-outline" label="Media"></b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign"
icon="plus" label="Create new"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates"
icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-item><!-- campaigns -->
<b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media"
icon="image-outline" label="Media"></b-menu-item>
<b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item>
</b-menu-list>
</b-menu>
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates"
icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-item><!-- campaigns -->
<b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item>
</b-menu-list>
</b-menu>
</div>
</b-sidebar>
</section>
<!-- sidebar-->
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart">
<div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm(
'Ensure running campaigns are paused. Restart?', reloadApp)">
Restart
</b-button>
</div>
</div>
</b-sidebar>
</section>
<!-- sidebar-->
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart">
<div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm(
'Ensure running campaigns are paused. Restart?', reloadApp)">
Restart
</b-button>
</div>
<router-view :key="$route.fullPath" />
</div>
<router-view :key="$route.fullPath" />
</div>
<b-loading v-if="!isLoaded" active>

View file

@ -6,6 +6,7 @@
@import "~bulma/sass/components/menu";
@import "~bulma/sass/components/message";
@import "~bulma/sass/components/modal";
@import "~bulma/sass/components/navbar";
@import "~bulma/sass/components/pagination";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/form/_all";

View file

@ -71,3 +71,6 @@
.mdi-chevron-right:before { content: '\e81c'; } /* '' */
.mdi-chevron-left:before { content: '\e81d'; } /* '' */
.mdi-content-save-outline:before { content: '\e81e'; } /* '' */
.mdi-minus:before { content: '\e81f'; } /* '' */
.mdi-arrow-up:before { content: '\e820'; } /* '' */
.mdi-arrow-down:before { content: '\e821'; } /* '' */

View file

@ -1,14 +1,16 @@
/* Import Bulma to set variables */
@import "~bulma/sass/utilities/_all";
$body-family: "IBM Plex Sans", "Helvetica Neue", sans-serif;
$body-family: "Inter", "Helvetica Neue", sans-serif;
$body-size: 15px;
$background: $white-bis;
$body-background-color: $white-bis;
$primary: #7f2aff;
$green: #4caf50;
$turquoise: $green;
$red: #ff5722;
$link: $primary;
$input-placeholder-color: $black-ter;
$input-placeholder-color: $grey-light;
$colors: map-merge($colors, (
"turquoise": ($green, $green-invert),
@ -77,35 +79,58 @@ section {
}
}
.box {
box-shadow: 0 0 2px $grey-lighter;
}
/* Two column sidebar+body layout */
#app {
display: flex;
flex-direction: row;
min-height: 100%;
> .sidebar {
flex-shrink: 1;
box-shadow: 0 0 5px #eee;
border-right: 1px solid #eee;
.b-sidebar {
position: sticky;
top: 0px;
}
.wrapper {
display: flex;
flex-direction: row;
min-height: 100vh;
margin-top: 0px;
}
> .main {
margin: 30px 30px 30px 45px;
.sidebar {
flex-shrink: 1;
box-shadow: 0 0 3px $grey-lighter;
background: $white;
}
.main {
background: $white;
margin-left: 15px;
padding: 30px;
flex-grow: 1;
position: relative;
}
}
.navbar {
box-shadow: 0 0 3px $grey-lighter;
}
.navbar-brand {
padding: 0 0 0 25px;
.favicon {
display: none;
}
.full {
max-height: 20px;
margin-top: 12px;
}
.favicon {
margin-top: 8px;
}
}
.b-sidebar {
.logo {
padding: 15px;
}
position: sticky;
top: 75px;
.sidebar-content {
border-right: 1px solid #eee;
background: transparent;
}
.menu-list {
.router-link-exact-active {
@ -116,14 +141,14 @@ section {
margin-right: 0;
}
> li {
margin-bottom: 15px;
margin-bottom: 10px;
a {
padding-left: 25px;
}
}
a {
border-radius: 0;
}
}
.logo {
margin-bottom: 30px;
}
.favicon {
display: none;
}
}
@ -181,10 +206,6 @@ section {
display: none;
}
/* Toasts */
.notices .toast {
animation: none;
}
/* Fix for button primary colour. */
.button.is-primary {
@ -198,11 +219,39 @@ section {
}
.autocomplete .dropdown-content {
background-color: $white-bis;
background-color: $white-ter;
}
.help {
color: $grey;
.input, .taginput .taginput-container.is-focusable, .textarea {
box-shadow: inset 2px 2px 0px $white-ter;
}
/* Form fields */
.field {
&:not(:last-child) {
margin-bottom: 2rem;
}
.control {
position: relative;
.help.counter {
position: absolute;
top: -20px;
right: 0;
}
}
label {
color: $grey;
}
.help {
color: $grey-light;
}
}
.has-numberinput .field, .field.is-grouped {
margin-bottom: 0;
}
/* Tags */
@ -267,6 +316,10 @@ section.dashboard {
margin-bottom: 0.5rem;
}
.counts .column {
padding: 30px;
}
.level-item {
background-color: $white-bis;
padding: 30px;
@ -296,6 +349,11 @@ section.lists {
}
}
/* List selector */
.list-tags {
margin-bottom: 1rem;
}
/* Subscribers page */
.subscribers-controls {
padding-bottom: 15px;
@ -520,6 +578,40 @@ section.campaign {
}
}
/* Vue animations */
.slide-enter-active, .slide-leave-active {
transition: opacity 50ms;
max-height: none;
}
.slide-enter, .slide-leave-to {
transition: opacity 50ms;
opacity: 0;
max-height: none;
}
.slide-leave-active, .slide-leave-to {
transition: none;
display: none;
}
/* Toasts */
.notices {
@keyframes scale {
0% {
scale: 1;
}
50% {
scale: 1.3;
}
100% {
scale: 1;
}
}
.toast {
animation: scale 300ms ease-in-out;
}
}
@media screen and (max-width: 1450px) and (min-width: 769px) {
section.campaigns {
/* Fold the stats labels until the card view */
@ -539,34 +631,57 @@ section.campaign {
}
@media screen and (max-width: 1023px) {
html, body {
overflow-x: auto;
}
#app .main {
margin-left: 5px;
padding: 30px 20px 30px 20px;
}
.navbar-brand {
.full {
display: none;
}
.favicon {
display: block;
}
padding-left: 10px;
}
.b-sidebar {
top: 30px;
}
/* Hide sidebar menu captions on mobile */
.b-sidebar .sidebar-content.is-mini-mobile {
.menu-list li {
margin-bottom: 30px;
.menu-list {
li {
margin-bottom: 30px;
span:nth-child(2) {
display: none;
span:nth-child(2) {
display: none;
}
.icon.is-small {
scale: 1.4;
}
}
.icon.is-small {
scale: 1.4;
}
}
.logo {
text-align: center;
.full {
display: none;
}
.favicon {
display: block;
}
.version {
display: none;
> li {
a {
padding-left: 15px;
}
}
}
}
#app > .content {
margin: 15px;
td .tags {
display: block;
text-align: right;
.tag:not(:last-child) {
margin-right: 0;
}
}
}
@ -574,4 +689,4 @@ section.campaign {
section.dashboard label {
min-width: auto;
}
}
}

View file

@ -1,7 +1,6 @@
<template>
<div class="field">
<b-field :label="label + (selectedItems ? ` (${selectedItems.length})` : '')">
<div :class="classes">
<div :class="['list-tags', ...classes]">
<b-taglist>
<b-tag v-for="l in selectedItems"
:key="l.id"
@ -13,9 +12,10 @@
</b-tag>
</b-taglist>
</div>
</b-field>
<b-field :message="message">
<b-field :message="message"
:label="label + (selectedItems ? ` (${selectedItems.length})` : '')"
label-position="on-border">
<b-autocomplete
:placeholder="placeholder"
clearable

View file

@ -63,7 +63,7 @@ export default class utils {
// UI shortcuts.
static confirm = (msg, onConfirm, onCancel) => {
Dialog.confirm({
scroll: 'keep',
scroll: 'clip',
message: !msg ? 'Are you sure?' : msg,
onConfirm,
onCancel,
@ -72,7 +72,7 @@ export default class utils {
static prompt = (msg, inputAttrs, onConfirm, onCancel) => {
Dialog.prompt({
scroll: 'keep',
scroll: 'clip',
message: msg,
confirmText: 'OK',
inputAttrs: {
@ -91,7 +91,7 @@ export default class utils {
message: msg,
type: !typ ? 'is-success' : typ,
queue: false,
duration: duration || 3000,
duration: duration || 2000,
});
};
}

View file

@ -33,22 +33,22 @@
<b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab">
<b-tab-item label="Campaign" icon="rocket-launch-outline">
<b-tab-item label="Campaign" label-position="on-border" icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="onSubmit">
<b-field label="Name">
<b-field label="Name" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Subject">
<b-field label="Subject" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
placeholder="Subject" required></b-input>
</b-field>
<b-field label="From address">
<b-field label="From address" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
placeholder="Your Name <noreply@yoursite.com>" required></b-input>
</b-field>
@ -62,34 +62,40 @@
placeholder="Lists to send to"
></list-selector>
<b-field label="Template">
<b-field label="Template" label-position="on-border">
<b-select placeholder="Template" v-model="form.templateId"
:disabled="!canEdit" required>
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
</b-select>
</b-field>
<b-field label="Tags">
<b-field label="Tags" label-position="on-border">
<b-taginput v-model="form.tags" :disabled="!canEdit"
ellipsis icon="tag-outline" placeholder="Tags"></b-taginput>
</b-field>
<hr />
<b-field label="Send later?">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
</b-field>
<b-field v-if="form.sendLater" label="Send at">
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
placeholder="Date and time"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
horizontal-time-picker>
</b-datetimepicker>
</b-field>
<div class="columns">
<div class="column is-2">
<b-field label="Send later?">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
</b-field>
</div>
<div class="column">
<br />
<b-field v-if="form.sendLater">
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
placeholder="Date and time"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
horizontal-time-picker>
</b-datetimepicker>
</b-field>
</div>
</div>
<hr />
<b-field v-if="isNew">
@ -267,11 +273,7 @@ export default Vue.extend({
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d;
this.$buefy.toast.open({
message: `'${d.name}' ${typMsg}`,
type: 'is-success',
queue: false,
});
this.$utils.toast(`'${d.name}' ${typMsg}`);
resolve();
});
});
@ -327,11 +329,7 @@ export default Vue.extend({
} else {
const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) {
this.$buefy.toast.open({
message: 'Invalid campaign',
type: 'is-danger',
queue: false,
});
this.$utils.toast('Invalid campaign');
return;
}

View file

@ -116,59 +116,61 @@
</b-table-column>
<b-table-column class="actions" width="13%" align="right">
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Start" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canPause(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'paused'))">
<b-tooltip label="Pause" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canResume(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Send" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canSchedule(props.row)"
@click.prevent="$utils.confirm(`This campaign will start automatically at the
scheduled date and time. Schedule now?`,
() => changeCampaignStatus(props.row, 'scheduled'))">
<b-tooltip label="Schedule" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="previewCampaign(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone campaign`,
{ placeholder: 'Campaign name', value: `Copy of ${props.row.name}`},
(name) => cloneCampaign(name, props.row))">
<b-tooltip label="Clone" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))">
<b-tooltip label="Cancel" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canDelete(props.row)"
@click.prevent="$utils.confirm(`Delete '${props.row.name}'?`,
() => deleteCampaign(props.row))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
<div>
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Start" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canPause(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'paused'))">
<b-tooltip label="Pause" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canResume(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Send" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canSchedule(props.row)"
@click.prevent="$utils.confirm(`This campaign will start automatically at the
scheduled date and time. Schedule now?`,
() => changeCampaignStatus(props.row, 'scheduled'))">
<b-tooltip label="Schedule" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="previewCampaign(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone campaign`,
{ placeholder: 'Campaign name', value: `Copy of ${props.row.name}`},
(name) => cloneCampaign(name, props.row))">
<b-tooltip label="Clone" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))">
<b-tooltip label="Cancel" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canDelete(props.row)"
@click.prevent="$utils.confirm(`Delete '${props.row.name}'?`,
() => deleteCampaign(props.row))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.campaigns">

View file

@ -7,22 +7,34 @@
<section v-if="isFree()" class="wrap-small">
<form @submit.prevent="onSubmit" class="box">
<div>
<b-field label="Mode">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe">Subscribe</b-radio>
<b-radio v-model="form.mode" name="mode"
native-value="blacklist">Blacklist</b-radio>
<div class="columns">
<div class="column">
<b-field label="Mode">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe">Subscribe</b-radio>
<b-radio v-model="form.mode" name="mode"
native-value="blacklist">Blacklist</b-radio>
</div>
</b-field>
</div>
</b-field>
<b-field v-if="form.mode === 'subscribe'"
label="Overwrite?"
message="Overwrite name and attribs of existing subscribers?">
<div>
<b-switch v-model="form.overwrite" name="overwrite" />
<div class="column">
<b-field v-if="form.mode === 'subscribe'"
label="Overwrite?"
message="Overwrite name and attribs of existing subscribers?">
<div>
<b-switch v-model="form.overwrite" name="overwrite" />
</div>
</b-field>
</div>
</b-field>
<div class="column">
<b-field label="CSV delimiter" message="Default delimiter is comma."
class="delimiter">
<b-input v-model="form.delim" name="delim"
placeholder="," maxlength="1" required />
</b-field>
</div>
</div>
<list-selector v-if="form.mode === 'subscribe'"
label="Lists"
@ -33,13 +45,8 @@
:all="lists.results"
></list-selector>
<hr />
<b-field label="CSV delimiter" message="Default delimiter is comma."
class="delimiter">
<b-input v-model="form.delim" name="delim"
placeholder="," maxlength="1" required />
</b-field>
<b-field label="CSV or ZIP file">
<b-field label="CSV or ZIP file" label-position="on-border">
<b-upload v-model="form.file" drag-drop expanded required>
<div class="has-text-centered section">
<p>

View file

@ -2,21 +2,21 @@
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
</p>
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">{{ data.type }}</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New list</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
</p>
</header>
<section expanded class="modal-card-body">
<b-field label="Name">
<b-field label="Name" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Type"
<b-field label="Type" label-position="on-border"
message="Public lists are open to the world to subscribe
and their names may appear on public pages such as the subscription
management page.">
@ -26,7 +26,7 @@
</b-select>
</b-field>
<b-field label="Opt-in"
<b-field label="Opt-in" label-position="on-border"
message="Double opt-in sends an e-mail to the subscriber asking for
confirmation. On Double opt-in lists, campaigns are only sent to
confirmed subscribers.">

View file

@ -22,6 +22,7 @@
</b-table-column>
<b-table-column field="type" label="Type" sortable>
<div>
<b-tag :class="props.row.type">{{ props.row.type }}</b-tag>
{{ ' ' }}
<b-tag>
@ -38,6 +39,7 @@
Send opt-in campaign
</b-tooltip>
</router-link>
</div>
</b-table-column>
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
@ -54,21 +56,23 @@
</b-table-column>
<b-table-column class="actions" align="right">
<router-link :to="`/campaign/new?list_id=${props.row.id}`">
<b-tooltip label="Send campaign" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="deleteList(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<div>
<router-link :to="`/campaign/new?list_id=${props.row.id}`">
<b-tooltip label="Send campaign" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="deleteList(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
</template>
@ -78,7 +82,7 @@
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="450">
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600">
<list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
</b-modal>
</section>

View file

@ -16,16 +16,16 @@
<section class="wrap-small">
<form @submit.prevent="onSubmit">
<b-tabs type="is-boxed" :animated="false">
<b-tab-item label="General">
<b-tab-item label="General" label-position="on-border">
<div class="items">
<b-field label="Logo URL"
<b-field label="Logo URL" label-position="on-border"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.logo_url']" name="app.logo_url"
placeholder='https://listmonk.yoursite.com/logo.png' :maxlength="300" />
</b-field>
<b-field label="Favicon URL"
<b-field label="Favicon URL" label-position="on-border"
message="(Optional) full URL to the static favicon to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.favicon_url']" name="app.favicon_url"
@ -33,7 +33,7 @@
</b-field>
<hr />
<b-field label="Default 'from' email"
<b-field label="Default 'from' email" label-position="on-border"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.from_email']" name="app.from_email"
@ -41,7 +41,7 @@
pattern="(.+?)\s<(.+?)@(.+?)>" :maxlength="300" />
</b-field>
<b-field label="Admin notification e-mails"
<b-field label="Admin notification e-mails" label-position="on-border"
message="Comma separated list of e-mail addresses to which admin
notifications such as import updates, campaign completion,
failure etc. should be sent.">
@ -54,7 +54,7 @@
<b-tab-item label="Performance">
<div class="items">
<b-field label="Concurrency"
<b-field label="Concurrency" label-position="on-border"
message="Maximum concurrent worker (threads) that will attempt to send messages
simultaneously.">
<b-numberinput v-model="form['app.concurrency']"
@ -62,7 +62,7 @@
placeholder="5" min="1" max="10000" />
</b-field>
<b-field label="Message rate"
<b-field label="Message rate" label-position="on-border"
message="Maximum number of messages to be sent out per second
per worker in a second. If concurrency = 10 and message_rate = 10,
then up to 10x10=100 messages may be pushed out every second.
@ -74,7 +74,7 @@
placeholder="5" min="1" max="100000" />
</b-field>
<b-field label="Batch size"
<b-field label="Batch size" label-position="on-border"
message="The number of subscribers to pull from the databse in a single iteration.
Each iteration pulls subscribers from the database, sends messages to them,
and then moves on to the next iteration to pull the next batch.
@ -85,7 +85,7 @@
placeholder="1000" min="1" max="100000" />
</b-field>
<b-field label="Maximum error threshold"
<b-field label="Maximum error threshold" label-position="on-border"
message="The number of errors (eg: SMTP timeouts while e-mailing) a running
campaign should tolerate before it is paused for manual
investigation or intervention. Set to 0 to never pause.">
@ -125,7 +125,7 @@
<b-tab-item label="Media uploads">
<div class="items">
<b-field label="Provider">
<b-field label="Provider" label-position="on-border">
<b-select v-model="form['upload.provider']" name="upload.provider">
<option value="filesystem">filesystem</option>
<option value="s3">s3</option>
@ -133,14 +133,14 @@
</b-field>
<div class="block" v-if="form['upload.provider'] === 'filesystem'">
<b-field label="Upload path"
<b-field label="Upload path" label-position="on-border"
message="Path to the directory where media will be uploaded.">
<b-input v-model="form['upload.filesystem.upload_path']"
name="app.upload_path" placeholder='/home/listmonk/uploads'
:maxlength="200" />
</b-field>
<b-field label="Upload URI"
<b-field label="Upload URI" label-position="on-border"
message="Upload URI that's visible to the outside world.
The media uploaded to upload_path will be publicly accessible
under {root_url}/{}, for instance, https://listmonk.yoursite.com/uploads.">
@ -150,43 +150,65 @@
</div><!-- filesystem -->
<div class="block" v-if="form['upload.provider'] === 's3'">
<b-field label="AWS access key">
<b-input v-model="form['upload.s3.aws_access_key_id']"
name="upload.s3.aws_access_key_id" :maxlength="200" />
</b-field>
<b-field label="AWS access secret">
<b-input v-model="form['upload.s3.aws_secret_access_key']"
name="upload.s3.aws_secret_access_key" type="password" :maxlength="200" />
</b-field>
<b-field label="Region">
<b-input v-model="form['upload.s3.aws_default_region']"
name="upload.s3.aws_default_region"
:maxlength="200" placeholder="ap-south-1" />
</b-field>
<b-field label="Bucket">
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket" :maxlength="200" placeholder="" />
</b-field>
<b-field label="Bucket path"
message="Path inside the bucket to upload files. Default is /">
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket_path" :maxlength="200" placeholder="/" />
</b-field>
<b-field label="Bucket type">
<b-select v-model="form['upload.s3.bucket_type']"
name="upload.s3.bucket_type">
<option value="private">private</option>
<option value="public">public</option>
</b-select>
</b-field>
<b-field label="Upload expiry"
message="(Optional) Specify TTL (in seconds) for the generated presigned URL.
Only applicable for private buckets
(s, m, h, d for seconds, minutes, hours, days).">
<b-input v-model="form['upload.s3.expiry']"
name="upload.s3.expiry"
placeholder="14d" :pattern="regDuration" :maxlength="10" />
</b-field>
<div class="columns">
<div class="column is-3">
<b-field label="Region" label-position="on-border" expanded>
<b-input v-model="form['upload.s3.aws_default_region']"
name="upload.s3.aws_default_region"
:maxlength="200" placeholder="ap-south-1" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="AWS access key" label-position="on-border" expanded>
<b-input v-model="form['upload.s3.aws_access_key_id']"
name="upload.s3.aws_access_key_id" :maxlength="200" />
</b-field>
<b-field label="AWS access secret" label-position="on-border" expanded>
<b-input v-model="form['upload.s3.aws_secret_access_key']"
name="upload.s3.aws_secret_access_key" type="password"
:maxlength="200" />
</b-field>
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field label="Bucket type" label-position="on-border">
<b-select v-model="form['upload.s3.bucket_type']"
name="upload.s3.bucket_type" expanded>
<option value="private">private</option>
<option value="public">public</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="Bucket" label-position="on-border" expanded>
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket" :maxlength="200" placeholder="" />
</b-field>
<b-field label="Bucket path" label-position="on-border"
message="Path inside the bucket to upload files. Default is /" expanded>
<b-input v-model="form['upload.s3.bucket_path']"
name="upload.s3.bucket_path" :maxlength="200" placeholder="/" />
</b-field>
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field label="Upload expiry" label-position="on-border"
message="(Optional) Specify TTL (in seconds) for the generated presigned URL.
Only applicable for private buckets
(s, m, h, d for seconds, minutes, hours, days)." expanded>
<b-input v-model="form['upload.s3.expiry']"
name="upload.s3.expiry"
placeholder="14d" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
</div>
</div><!-- s3 -->
</div>
</b-tab-item><!-- media -->
@ -211,14 +233,14 @@
<div class="column" :class="{'disabled': !item.enabled}">
<div class="columns">
<div class="column is-8">
<b-field label="Host"
<b-field label="Host" label-position="on-border"
message="SMTP server's host address.">
<b-input v-model="item.host" name="host"
placeholder='smtp.yourmailserver.net' :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field label="Port"
<b-field label="Port" label-position="on-border"
message="SMTP server's port.">
<b-numberinput v-model="item.port" name="port" type="is-light"
controls-position="compact"
@ -229,7 +251,7 @@
<div class="columns">
<div class="column is-2">
<b-field label="Auth protocol">
<b-field label="Auth protocol" label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol">
<option value="none">none</option>
<option value="cram">cram</option>
@ -240,12 +262,12 @@
</div>
<div class="column">
<b-field grouped>
<b-field label="Username" expanded>
<b-field label="Username" label-position="on-border" expanded>
<b-input v-model="item.username"
:disabled="item.auth_protocol === 'none'"
name="username" placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field label="Password" expanded
<b-field label="Password" label-position="on-border" expanded
message="Enter a value to change. Otherwise, leave empty.">
<b-input v-model="item.password"
:disabled="item.auth_protocol === 'none'"
@ -259,7 +281,7 @@
<div class="columns">
<div class="column is-6">
<b-field label="HELO hostname"
<b-field label="HELO hostname" label-position="on-border"
message="Optional. Some SMTP servers require a FQDN in the hostname.
By default, HELLOs go with 'localhost'. Set this if a custom
hostname should be used.">
@ -285,7 +307,7 @@
<div class="columns">
<div class="column is-3">
<b-field label="Max. connections"
<b-field label="Max. connections" label-position="on-border"
message="Maximum concurrent connections to the SMTP server.">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
@ -293,7 +315,7 @@
</b-field>
</div>
<div class="column is-3">
<b-field label="Retries"
<b-field label="Retries" label-position="on-border"
message="The number of times a message should be retried
if sending fails.">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
@ -303,7 +325,7 @@
</b-field>
</div>
<div class="column is-3">
<b-field label="Idle timeout"
<b-field label="Idle timeout" label-position="on-border"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-input v-model="item.idle_timeout" name="idle_timeout"
@ -311,7 +333,7 @@
</b-field>
</div>
<div class="column is-3">
<b-field label="Wait timeout"
<b-field label="Wait timeout" label-position="on-border"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-input v-model="item.wait_timeout" name="wait_timeout"
@ -341,7 +363,7 @@ import { models } from '../constants';
export default Vue.extend({
data() {
return {
regDuration: '[0-9]+(ms|s|m|h)',
regDuration: '[0-9]+(ms|s|m|h|d)',
isLoading: true,
// formCopy is a stringified copy of the original settings against which

View file

@ -2,8 +2,7 @@
<form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4>Manage lists</h4>
<p>{{ numSubscribers }} subscriber(s) selected</p>
<h4 class="title is-size-5">Manage lists</h4>
</header>
<section expanded class="modal-card-body">

View file

@ -12,16 +12,17 @@
</p>
</header>
<section expanded class="modal-card-body">
<b-field label="E-mail">
<b-field label="E-mail" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" :ref="'focus'"
placeholder="E-mail" required></b-input>
</b-field>
<b-field label="Name">
<b-field label="Name" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" placeholder="Name"></b-input>
</b-field>
<b-field label="Status" message="Blacklisted subscribers will never receive any e-mails.">
<b-field label="Status" label-position="on-border"
message="Blacklisted subscribers will never receive any e-mails.">
<b-select v-model="form.status" placeholder="Status" required>
<option value="enabled">Enabled</option>
<option value="blacklisted">Blacklisted</option>
@ -37,7 +38,7 @@
:all="lists.results"
></list-selector>
<b-field label="Attributes"
<b-field label="Attributes" label-position="on-border"
message='Attributes are defined as a JSON map, for example:
{"job": "developer", "location": "Mars", "has_rocket": true}.'>
<b-input v-model="form.strAttribs" type="textarea" />

View file

@ -140,22 +140,24 @@
</b-table-column>
<b-table-column class="actions" align="right">
<a :href="`/api/subscribers/${props.row.id}/export`">
<b-tooltip label="Download data" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<div>
<a :href="`/api/subscribers/${props.row.id}/export`">
<b-tooltip label="Download data" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.subscribers">
@ -170,7 +172,7 @@
</b-modal>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="750">
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600">
<subscriber-form :data="curItem" :isEditing="isEditing"
@finished="querySubscribers"></subscriber-form>
</b-modal>

View file

@ -11,12 +11,12 @@
<h4 v-else>New template</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Name">
<b-field label="Name" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Raw HTML">
<b-field label="Raw HTML" label-position="on-border">
<b-input v-model="form.body" type="textarea" required />
</b-field>

View file

@ -29,28 +29,30 @@
</b-table-column>
<b-table-column class="actions" align="right">
<a href="#" @click.prevent="previewTemplate(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))">
<b-tooltip label="Make default" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<div>
<a href="#" @click.prevent="previewTemplate(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))">
<b-tooltip label="Make default" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
</template>