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 ### 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` - 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. - `./listmonk --install` to setup the DB.
- Run `./listmonk` and visit `http://localhost:9000`. - 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. - 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 ### Configuration and customization

View File

@ -440,6 +440,48 @@
"content-save-outline" "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", "uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
"css": "vector-square", "css": "vector-square",
@ -1364,20 +1406,6 @@
"arrow-collapse-all" "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", "uid": "578692c5a0b505985bf797ee8ebce545",
"css": "arrow-down-thick", "css": "arrow-down-thick",
@ -1686,20 +1714,6 @@
"arrow-top-left" "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", "uid": "00e74cb9bfa86a1b90b39d2d8132c3b1",
"css": "arrow-up-thick", "css": "arrow-up-thick",
@ -12774,20 +12788,6 @@
"minecraft" "minecraft"
] ]
}, },
{
"uid": "80491c76df0c066833e0f8211903d37c",
"css": "minus",
"code": 983924,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M791 541H209V459H791V541Z",
"width": 1000
},
"search": [
"minus"
]
},
{ {
"uid": "4dae8d34e12ee29474c244f25a6cbc1c", "uid": "4dae8d34e12ee29474c244f25a6cbc1c",
"css": "minus-box", "css": "minus-box",

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>frontend/favicon.png" /> <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> <title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= BASE_URL %>api/config.js"></script> <script src="<%= BASE_URL %>api/config.js"></script>
</head> </head>

View File

@ -1,93 +1,103 @@
<template> <template>
<div id="app"> <div id="app">
<section class="sidebar"> <b-navbar :fixed-top="true">
<b-sidebar <template slot="brand">
type="is-white"
position="static"
mobile="reduce"
:fullheight="true"
:open="true"
:can-cancel="false"
>
<div>
<div class="logo"> <div class="logo">
<a href="/"><img class="full" src="@/assets/logo.svg"/></a> <router-link :to="{name: 'dashboard'}">
<img class="favicon" src="@/assets/favicon.png"/> <img class="full" src="@/assets/logo.svg"/>
<p class="is-size-7 has-text-grey version">{{ version }}</p> <img class="favicon" src="@/assets/favicon.png"/>
</router-link>
</div> </div>
<b-menu :accordion="false"> </template>
<b-menu-list> <template slot="end">
<b-menu-item :to="{name: 'dashboard'}" tag="router-link" <b-navbar-item tag="div"></b-navbar-item>
:active="activeItem.dashboard" </template>
icon="view-dashboard-variant-outline" label="Dashboard"> </b-navbar>
</b-menu-item><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists" <div class="wrapper">
icon="format-list-bulleted-square" label="Lists"> <section class="sidebar">
<b-menu-item :to="{name: 'lists'}" tag="router-link" <b-sidebar
:active="activeItem.lists" position="static"
icon="format-list-bulleted-square" label="All lists"></b-menu-item> 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" <b-menu-item :expanded="activeGroup.lists"
:active="activeItem.forms" icon="format-list-bulleted-square" label="Lists">
icon="newspaper-variant-outline" label="Forms"></b-menu-item> <b-menu-item :to="{name: 'lists'}" tag="router-link"
</b-menu-item><!-- lists --> :active="activeItem.lists"
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
<b-menu-item :expanded="activeGroup.subscribers" <b-menu-item :to="{name: 'forms'}" tag="router-link"
icon="account-multiple" label="Subscribers"> :active="activeItem.forms"
<b-menu-item :to="{name: 'subscribers'}" tag="router-link" icon="newspaper-variant-outline" label="Forms"></b-menu-item>
:active="activeItem.subscribers" </b-menu-item><!-- lists -->
icon="account-multiple" label="All subscribers"></b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link" <b-menu-item :expanded="activeGroup.subscribers"
:active="activeItem.import" icon="account-multiple" label="Subscribers">
icon="file-upload-outline" label="Import"></b-menu-item> <b-menu-item :to="{name: 'subscribers'}" tag="router-link"
</b-menu-item><!-- subscribers --> :active="activeItem.subscribers"
icon="account-multiple" label="All subscribers"></b-menu-item>
<b-menu-item :expanded="activeGroup.campaigns" <b-menu-item :to="{name: 'import'}" tag="router-link"
icon="rocket-launch-outline" label="Campaigns"> :active="activeItem.import"
<b-menu-item :to="{name: 'campaigns'}" tag="router-link" icon="file-upload-outline" label="Import"></b-menu-item>
:active="activeItem.campaigns" </b-menu-item><!-- subscribers -->
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link" <b-menu-item :expanded="activeGroup.campaigns"
:active="activeItem.campaign" icon="rocket-launch-outline" label="Campaigns">
icon="plus" label="Create new"></b-menu-item> <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" <b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.media" :active="activeItem.campaign"
icon="image-outline" label="Media"></b-menu-item> icon="plus" label="Create new"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link" <b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.templates" :active="activeItem.media"
icon="file-image-outline" label="Templates"></b-menu-item> icon="image-outline" label="Media"></b-menu-item>
</b-menu-item><!-- campaigns -->
<b-menu-item :to="{name: 'settings'}" tag="router-link" <b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.settings" :active="activeItem.templates"
icon="cog-outline" label="Settings"></b-menu-item> icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-list> </b-menu-item><!-- campaigns -->
</b-menu>
<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> </div>
</b-sidebar>
</section>
<!-- sidebar-->
<!-- body //--> <router-view :key="$route.fullPath" />
<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> </div>
<router-view :key="$route.fullPath" />
</div> </div>
<b-loading v-if="!isLoaded" active> <b-loading v-if="!isLoaded" active>

View File

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

View File

@ -71,3 +71,6 @@
.mdi-chevron-right:before { content: '\e81c'; } /* '' */ .mdi-chevron-right:before { content: '\e81c'; } /* '' */
.mdi-chevron-left:before { content: '\e81d'; } /* '' */ .mdi-chevron-left:before { content: '\e81d'; } /* '' */
.mdi-content-save-outline:before { content: '\e81e'; } /* '' */ .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 to set variables */
@import "~bulma/sass/utilities/_all"; @import "~bulma/sass/utilities/_all";
$body-family: "IBM Plex Sans", "Helvetica Neue", sans-serif; $body-family: "Inter", "Helvetica Neue", sans-serif;
$body-size: 15px; $body-size: 15px;
$background: $white-bis;
$body-background-color: $white-bis;
$primary: #7f2aff; $primary: #7f2aff;
$green: #4caf50; $green: #4caf50;
$turquoise: $green; $turquoise: $green;
$red: #ff5722; $red: #ff5722;
$link: $primary; $link: $primary;
$input-placeholder-color: $black-ter; $input-placeholder-color: $grey-light;
$colors: map-merge($colors, ( $colors: map-merge($colors, (
"turquoise": ($green, $green-invert), "turquoise": ($green, $green-invert),
@ -77,35 +79,58 @@ section {
} }
} }
.box {
box-shadow: 0 0 2px $grey-lighter;
}
/* Two column sidebar+body layout */ /* Two column sidebar+body layout */
#app { #app {
display: flex;
flex-direction: row;
min-height: 100%; min-height: 100%;
.wrapper {
> .sidebar { display: flex;
flex-shrink: 1; flex-direction: row;
box-shadow: 0 0 5px #eee; min-height: 100vh;
border-right: 1px solid #eee; margin-top: 0px;
.b-sidebar {
position: sticky;
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; 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 { .b-sidebar {
.logo { position: sticky;
padding: 15px; top: 75px;
}
.sidebar-content { .sidebar-content {
border-right: 1px solid #eee; background: transparent;
} }
.menu-list { .menu-list {
.router-link-exact-active { .router-link-exact-active {
@ -116,14 +141,14 @@ section {
margin-right: 0; margin-right: 0;
} }
> li { > 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; display: none;
} }
/* Toasts */
.notices .toast {
animation: none;
}
/* Fix for button primary colour. */ /* Fix for button primary colour. */
.button.is-primary { .button.is-primary {
@ -198,11 +219,39 @@ section {
} }
.autocomplete .dropdown-content { .autocomplete .dropdown-content {
background-color: $white-bis; background-color: $white-ter;
} }
.help { .input, .taginput .taginput-container.is-focusable, .textarea {
color: $grey; 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 */ /* Tags */
@ -267,6 +316,10 @@ section.dashboard {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.counts .column {
padding: 30px;
}
.level-item { .level-item {
background-color: $white-bis; background-color: $white-bis;
padding: 30px; padding: 30px;
@ -296,6 +349,11 @@ section.lists {
} }
} }
/* List selector */
.list-tags {
margin-bottom: 1rem;
}
/* Subscribers page */ /* Subscribers page */
.subscribers-controls { .subscribers-controls {
padding-bottom: 15px; 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) { @media screen and (max-width: 1450px) and (min-width: 769px) {
section.campaigns { section.campaigns {
/* Fold the stats labels until the card view */ /* Fold the stats labels until the card view */
@ -539,34 +631,57 @@ section.campaign {
} }
@media screen and (max-width: 1023px) { @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 */ /* Hide sidebar menu captions on mobile */
.b-sidebar .sidebar-content.is-mini-mobile { .b-sidebar .sidebar-content.is-mini-mobile {
.menu-list li { .menu-list {
margin-bottom: 30px; li {
margin-bottom: 30px;
span:nth-child(2) { span:nth-child(2) {
display: none; display: none;
}
.icon.is-small {
scale: 1.4;
}
} }
.icon.is-small { > li {
scale: 1.4; a {
} padding-left: 15px;
} }
.logo {
text-align: center;
.full {
display: none;
}
.favicon {
display: block;
}
.version {
display: none;
} }
} }
} }
#app > .content { td .tags {
margin: 15px; display: block;
text-align: right;
.tag:not(:last-child) {
margin-right: 0;
}
} }
} }
@ -574,4 +689,4 @@ section.campaign {
section.dashboard label { section.dashboard label {
min-width: auto; min-width: auto;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,12 +11,12 @@
<h4 v-else>New template</h4> <h4 v-else>New template</h4>
</header> </header>
<section expanded class="modal-card-body"> <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" <b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input> placeholder="Name" required></b-input>
</b-field> </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-input v-model="form.body" type="textarea" required />
</b-field> </b-field>

View File

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