Przeglądaj źródła

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 lat temu
rodzic
commit
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
 ### 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

+ 42 - 42
frontend/fontello/config.json

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

+ 1 - 1
frontend/public/index.html

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

+ 94 - 84
frontend/src/App.vue

@@ -1,93 +1,103 @@
 <template>
 <template>
   <div id="app">
   <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">
           <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>
           </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>
-      </div>
 
 
-      <router-view :key="$route.fullPath" />
+        <router-view :key="$route.fullPath" />
+      </div>
     </div>
     </div>
 
 
     <b-loading v-if="!isLoaded" active>
     <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/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";

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

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

BIN
frontend/src/assets/icons/fontello.woff2


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

@@ -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%;
-  
-  > .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;
     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 {
   .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 {
-  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 */
 /* 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 {
-      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 {
   section.dashboard label {
     min-width: auto;
     min-width: auto;
   }
   }
-}
+}

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

@@ -1,7 +1,6 @@
 <template>
 <template>
   <div class="field">
   <div class="field">
-    <b-field :label="label  + (selectedItems ? ` (${selectedItems.length})` : '')">
-      <div :class="classes">
+      <div :class="['list-tags', ...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

+ 3 - 3
frontend/src/utils.js

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

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

@@ -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?">
-                    <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 />
                 <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({
-            message: `'${d.name}' ${typMsg}`,
-            type: 'is-success',
-            queue: false,
-          });
+          this.$utils.toast(`'${d.name}' ${typMsg}`);
           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({
-          message: 'Invalid campaign',
-          type: 'is-danger',
-          queue: false,
-        });
+        this.$utils.toast('Invalid campaign');
         return;
         return;
       }
       }
 
 

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

@@ -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)"
-                @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>
             </b-table-column>
         </template>
         </template>
         <template slot="empty" v-if="!loading.campaigns">
         <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">
     <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>
-              <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>
             </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>
             </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>

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

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

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

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

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

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

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

@@ -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>
-        <p>{{ numSubscribers }} subscriber(s) selected</p>
+        <h4 class="title is-size-5">Manage lists</h4>
       </header>
       </header>
 
 
       <section expanded class="modal-card-body">
       <section expanded class="modal-card-body">

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

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

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

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

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

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

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

@@ -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)">
-                <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>
             </b-table-column>
         </template>
         </template>