浏览代码

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.
Kailash Nadh 5 年之前
父节点
当前提交
e2e65b1bc0

+ 2 - 1
README.md

@@ -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

+ 42 - 42
frontend/fontello/config.json

@@ -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",

+ 1 - 1
frontend/public/index.html

@@ -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>

+ 94 - 84
frontend/src/App.vue

@@ -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>
+        </template>
+        <template slot="end">
+            <b-navbar-item tag="div"></b-navbar-item>
+        </template>
+    </b-navbar>
+
+    <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 :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 :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.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: '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.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: 'campaign', params: {id: 'new'}}" tag="router-link"
+                    :active="activeItem.campaign"
+                    icon="plus" label="Create new"></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: '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>
-          <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 :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 :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.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: '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.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: 'campaign', params: {id: 'new'}}" tag="router-link"
-                  :active="activeItem.campaign"
-                  icon="plus" label="Create new"></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: '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>
 
-      <router-view :key="$route.fullPath" />
+        <router-view :key="$route.fullPath" />
+      </div>
     </div>
 
     <b-loading v-if="!isLoaded" active>

+ 1 - 0
frontend/src/assets/buefy.scss

@@ -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";

+ 3 - 0
frontend/src/assets/icons/fontello.css

@@ -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'; } /* '' */

二进制
frontend/src/assets/icons/fontello.woff2


+ 169 - 54
frontend/src/assets/style.scss

@@ -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;
+  .wrapper {
+    display: flex;
+    flex-direction: row;
+    min-height: 100vh;
+    margin-top: 0px;
+  }
 
-    .b-sidebar {
-      position: sticky;
-      top: 0px;
-    }
+  .sidebar {
+    flex-shrink: 1;
+    box-shadow: 0 0 3px $grey-lighter;
+    background: $white;
   }
-  > .main {
-    margin: 30px 30px 30px 45px;
+
+  .main {
+    background: $white;
+    margin-left: 15px;
+    padding: 30px;
     flex-grow: 1;
+    position: relative;
   }
 }
 
-.b-sidebar {
-  .logo {
-    padding: 15px;
+.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 {
+  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;
-      }
-      .icon.is-small {
-        scale: 1.4;
-      }
-    }
-    .logo {
-      text-align: center;
-      .full {
-        display: none;
-      }
-      .favicon {
-        display: block;
+        span:nth-child(2) {
+          display: none;
+        }
+        .icon.is-small {
+          scale: 1.4;
+        }
       }
-      .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;
   }
-}
+}

+ 4 - 4
frontend/src/components/ListSelector.vue

@@ -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

+ 3 - 3
frontend/src/utils.js

@@ -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,
     });
   };
 }

+ 29 - 31
frontend/src/views/Campaign.vue

@@ -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;
       }
 

+ 55 - 53
frontend/src/views/Campaigns.vue

@@ -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">

+ 27 - 20
frontend/src/views/Import.vue

@@ -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>

+ 6 - 6
frontend/src/views/ListForm.vue

@@ -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.">

+ 20 - 16
frontend/src/views/Lists.vue

@@ -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>

+ 82 - 60
frontend/src/views/Settings.vue

@@ -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

+ 1 - 2
frontend/src/views/SubscriberBulkList.vue

@@ -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">

+ 5 - 4
frontend/src/views/SubscriberForm.vue

@@ -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" />

+ 19 - 17
frontend/src/views/Subscribers.vue

@@ -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>

+ 2 - 2
frontend/src/views/TemplateForm.vue

@@ -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>
 

+ 24 - 22
frontend/src/views/Templates.vue

@@ -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>