Refactor and upgrade the frontend vue code to work with vite instead of webpack.

- Upgrade eslint and fix a massive number (~2500!) of linting errors from new rules.
- Upgrade babel core frontend dev dependency.
- Upgrade UI lib and other frontend deps.
- Refactor the Vue admin app to use `vite` instead of `webpack`.
- This was an extremely tedious and painstaking, trial-and-error
  alchemy job. My disdain for the Javascript "ecosystem" grows.
- Re-add custom admin appearance endpoints to the refactored Vue page.
- Remove obsolete vue-cli config.
- Re-auto-format all .vue files again to work with new linters.
This commit is contained in:
Kailash Nadh 2023-12-24 20:19:19 +05:30
parent 51af75cfef
commit af8b420d53
51 changed files with 2791 additions and 6883 deletions

View file

@ -14,8 +14,8 @@ FRONTEND_DIST = frontend/dist
FRONTEND_DEPS = \
$(FRONTEND_YARN_MODULES) \
frontend/package.json \
frontend/vue.config.js \
frontend/babel.config.js \
frontend/vite.config.js \
frontend/.eslintrc.js \
$(shell find frontend/fontello frontend/public frontend/src -type f)
BIN := listmonk
@ -57,7 +57,7 @@ build-frontend: $(FRONTEND_DIST)
# Run the JS frontend server in dev mode.
.PHONY: run-frontend
run-frontend:
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) serve
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) dev
# Run Go tests.
.PHONY: test

25
frontend/.eslintrc.js vendored
View file

@ -2,16 +2,29 @@ module.exports = {
root: true,
env: {
node: true,
// es2022: true,
},
plugins: ['vue'],
extends: [
'eslint:recommended',
'plugin:vue/essential',
'@vue/airbnb',
'plugin:vue/strongly-recommended',
'@vue/eslint-config-airbnb',
],
parserOptions: {
parser: '@babel/eslint-parser',
},
parser: 'vue-eslint-parser',
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'class-methods-use-this': 'off',
'vue/multi-word-component-names': 'off',
'vue/quote-props': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/no-child-content': 'off',
'vue/max-attributes-per-line': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-len': ['error', {
code: 200,
template: 200,
comments: 200,
}],
},
};

View file

@ -5,7 +5,7 @@ beforeEach(() => {
req.destroy();
});
cy.intercept('GET', '/api/health/**', (req) => {
cy.intercept('GET', '/api/health', (req) => {
req.reply({});
});
});

21
frontend/index.html vendored Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/admin/static/favicon.png" />
<link href="/admin/custom.css" rel="stylesheet" type="text/css">
<script src="/admin/custom.js" async defer></script>
<title>listmonk</title>
</head>
<body>
<noscript>
<strong>We're sorry but listmonk doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
frontend/jsconfig.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

41
frontend/package.json vendored
View file

@ -3,45 +3,42 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build-report": "vue-cli-service build --report",
"lint": "vue-cli-service lint"
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore src",
"prebuild": "eslint --ext .js,.vue --ignore-path .gitignore src"
},
"dependencies": {
"@tinymce/tinymce-vue": "^3",
"axios": "^1.6.0",
"buefy": "^0.9.10",
"axios": "^1.6.2",
"buefy": "^0.9.25",
"bulma": "^0.9.4",
"c3": "^0.7.20",
"codeflask": "^1.4.1",
"core-js": "^3.12.1",
"dayjs": "^1.10.4",
"dayjs": "^1.11.10",
"indent.js": "^0.3.5",
"qs": "^6.10.1",
"textversionjs": "^1.1.3",
"tinymce": "^5.10.9",
"turndown": "^7.0.0",
"vue": "^2.6.12",
"vue-i18n": "^8.22.2",
"turndown": "^7.1.2",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
"vue-router": "^3.2.0",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.23.3",
"@babel/eslint-parser": "^7.23.3",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-router": "~5.0.8",
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb": "^5.3.0",
"@vitejs/plugin-vue2": "^2.3.1",
"@vue/eslint-config-airbnb": "^7.0.1",
"cypress": "13.6.1",
"cypress-file-upload": "^5.0.2",
"eslint": "^7.27.0",
"eslint": "^8.56.0",
"eslint-define-config": "^2.0.0",
"eslint-plugin-import": "^2.23.3",
"eslint-plugin-vue": "^7.9.0",
"eslint-plugin-vue": "^9.19.2",
"sass": "^1.34.0",
"sass-loader": "^10.2.0",
"vite": "^5.0.10",
"vue-eslint-parser": "^9.3.2",
"vue-template-compiler": "^2.6.12"
}
}

View file

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<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 %>static/favicon.png" />
<link href="<%= BASE_URL %>custom.css" rel="stylesheet" type="text/css">
<script src="<%= BASE_URL %>custom.js" async defer></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

View file

@ -1,37 +1,30 @@
<template>
<div id="app">
<b-navbar :fixed-top="true" v-if="$root.isLoaded">
<template #brand>
<div class="logo">
<router-link :to="{name: 'dashboard'}">
<img class="full" src="@/assets/logo.svg"/>
<img class="favicon" src="@/assets/favicon.png"/>
</router-link>
</div>
</template>
<template #end>
<navigation v-if="isMobile" :isMobile="isMobile"
:activeItem="activeItem" :activeGroup="activeGroup" @toggleGroup="toggleGroup"
@doLogout="doLogout" />
<b-navbar-item v-else tag="div">
<a href="#" @click.prevent="doLogout">{{ $t('users.logout') }}</a>
</b-navbar-item>
</template>
<template #brand>
<div class="logo">
<router-link :to="{ name: 'dashboard' }">
<img class="full" src="@/assets/logo.svg" alt="" />
<img class="favicon" src="@/assets/favicon.png" alt="" />
</router-link>
</div>
</template>
<template #end>
<navigation v-if="isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-item v-else tag="div">
<a href="#" @click.prevent="doLogout">{{ $t('users.logout') }}</a>
</b-navbar-item>
</template>
</b-navbar>
<div class="wrapper" v-if="$root.isLoaded">
<section class="sidebar">
<b-sidebar
position="static"
mobile="hide"
:fullheight="true"
:open="true"
:can-cancel="false"
>
<b-sidebar position="static" mobile="hide" :fullheight="true" :open="true" :can-cancel="false">
<div>
<b-menu :accordion="false">
<navigation v-if="!isMobile" :isMobile="isMobile"
:activeItem="activeItem" :activeGroup="activeGroup" @toggleGroup="toggleGroup" />
<navigation v-if="!isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" />
</b-menu>
</div>
</b-sidebar>
@ -43,15 +36,15 @@
<div class="global-notices" v-if="serverConfig.needs_restart || serverConfig.update">
<div v-if="serverConfig.needs_restart" class="notification is-danger">
{{ $t('settings.needsRestart') }}
&mdash;
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm($t('settings.confirmRestart'), reloadApp)">
{{ $t('settings.restart') }}
{{ $t('settings.restart') }}
</b-button>
</div>
<div v-if="serverConfig.update" class="notification is-success">
{{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }}
<a :href="serverConfig.update.url" target="_blank">View</a>
<a :href="serverConfig.update.url" target="_blank" rel="noopener noreferer">View</a>
</div>
</div>
@ -157,7 +150,7 @@ export default Vue.extend({
...mapState(['serverConfig']),
version() {
return process.env.VUE_APP_VERSION;
return import.meta.env.VUE_APP_VERSION;
},
isMobile() {
@ -180,6 +173,6 @@ export default Vue.extend({
</script>
<style lang="scss">
@import "assets/style.scss";
@import "assets/icons/fontello.css";
@import "assets/style.scss";
@import "assets/icons/fontello.css";
</style>

View file

@ -6,7 +6,7 @@ import { models } from '../constants';
import Utils from '../utils';
const http = axios.create({
baseURL: process.env.VUE_APP_ROOT_URL || '/',
baseURL: import.meta.env.VUE_APP_ROOT_URL || '/',
withCredentials: false,
responseType: 'json',
@ -95,111 +95,176 @@ http.interceptors.response.use((resp) => {
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
// Health check endpoint that does not throw a toast.
export const getHealth = () => http.get('/api/health',
{ disableToast: true });
export const getHealth = () => http.get(
'/api/health',
{ disableToast: true },
);
export const reloadApp = () => http.post('/api/admin/reload');
// Dashboard
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
{ loading: models.dashboard });
export const getDashboardCounts = () => http.get(
'/api/dashboard/counts',
{ loading: models.dashboard },
);
export const getDashboardCharts = () => http.get('/api/dashboard/charts',
{ loading: models.dashboard });
export const getDashboardCharts = () => http.get(
'/api/dashboard/charts',
{ loading: models.dashboard },
);
// Lists.
export const getLists = (params) => http.get('/api/lists',
export const getLists = (params) => http.get(
'/api/lists',
{
params: (!params ? { per_page: 'all' } : params),
loading: models.lists,
store: models.lists,
});
},
);
export const queryLists = (params) => http.get('/api/lists',
export const queryLists = (params) => http.get(
'/api/lists',
{
params: (!params ? { per_page: 'all' } : params),
loading: models.lists,
});
},
);
export const getList = async (id) => http.get(`/api/lists/${id}`,
{ loading: models.list });
export const getList = async (id) => http.get(
`/api/lists/${id}`,
{ loading: models.list },
);
export const createList = (data) => http.post('/api/lists', data,
{ loading: models.lists });
export const createList = (data) => http.post(
'/api/lists',
data,
{ loading: models.lists },
);
export const updateList = (data) => http.put(`/api/lists/${data.id}`, data,
{ loading: models.lists });
export const updateList = (data) => http.put(
`/api/lists/${data.id}`,
data,
{ loading: models.lists },
);
export const deleteList = (id) => http.delete(`/api/lists/${id}`,
{ loading: models.lists });
export const deleteList = (id) => http.delete(
`/api/lists/${id}`,
{ loading: models.lists },
);
// Subscribers.
export const getSubscribers = async (params) => http.get('/api/subscribers',
export const getSubscribers = async (params) => http.get(
'/api/subscribers',
{
params,
loading: models.subscribers,
store: models.subscribers,
camelCase: (keyPath) => !keyPath.startsWith('.results.*.attribs'),
});
},
);
export const getSubscriber = async (id) => http.get(`/api/subscribers/${id}`,
{ loading: models.subscribers });
export const getSubscriber = async (id) => http.get(
`/api/subscribers/${id}`,
{ loading: models.subscribers },
);
export const getSubscriberBounces = async (id) => http.get(`/api/subscribers/${id}/bounces`,
{ loading: models.bounces });
export const getSubscriberBounces = async (id) => http.get(
`/api/subscribers/${id}/bounces`,
{ loading: models.bounces },
);
export const deleteSubscriberBounces = async (id) => http.delete(`/api/subscribers/${id}/bounces`,
{ loading: models.bounces });
export const deleteSubscriberBounces = async (id) => http.delete(
`/api/subscribers/${id}/bounces`,
{ loading: models.bounces },
);
export const deleteBounce = async (id) => http.delete(`/api/bounces/${id}`,
{ loading: models.bounces });
export const deleteBounce = async (id) => http.delete(
`/api/bounces/${id}`,
{ loading: models.bounces },
);
export const deleteBounces = async (params) => http.delete('/api/bounces',
{ params, loading: models.bounces });
export const deleteBounces = async (params) => http.delete(
'/api/bounces',
{ params, loading: models.bounces },
);
export const createSubscriber = (data) => http.post('/api/subscribers', data,
{ loading: models.subscribers });
export const createSubscriber = (data) => http.post(
'/api/subscribers',
data,
{ loading: models.subscribers },
);
export const updateSubscriber = (data) => http.put(`/api/subscribers/${data.id}`, data,
{ loading: models.subscribers });
export const updateSubscriber = (data) => http.put(
`/api/subscribers/${data.id}`,
data,
{ loading: models.subscribers },
);
export const sendSubscriberOptin = (id) => http.post(`/api/subscribers/${id}/optin`, {},
{ loading: models.subscribers });
export const sendSubscriberOptin = (id) => http.post(
`/api/subscribers/${id}/optin`,
{},
{ loading: models.subscribers },
);
export const deleteSubscriber = (id) => http.delete(`/api/subscribers/${id}`,
{ loading: models.subscribers });
export const deleteSubscriber = (id) => http.delete(
`/api/subscribers/${id}`,
{ loading: models.subscribers },
);
export const addSubscribersToLists = (data) => http.put('/api/subscribers/lists', data,
{ loading: models.subscribers });
export const addSubscribersToLists = (data) => http.put(
'/api/subscribers/lists',
data,
{ loading: models.subscribers },
);
export const addSubscribersToListsByQuery = (data) => http.put('/api/subscribers/query/lists',
data, { loading: models.subscribers });
export const addSubscribersToListsByQuery = (data) => http.put(
'/api/subscribers/query/lists',
data,
export const blocklistSubscribers = (data) => http.put('/api/subscribers/blocklist', data,
{ loading: models.subscribers });
{ loading: models.subscribers },
);
export const blocklistSubscribersByQuery = (data) => http.put('/api/subscribers/query/blocklist', data,
{ loading: models.subscribers });
export const blocklistSubscribers = (data) => http.put(
'/api/subscribers/blocklist',
data,
{ loading: models.subscribers },
);
export const deleteSubscribers = (params) => http.delete('/api/subscribers',
{ params, loading: models.subscribers });
export const blocklistSubscribersByQuery = (data) => http.put(
'/api/subscribers/query/blocklist',
data,
{ loading: models.subscribers },
);
export const deleteSubscribersByQuery = (data) => http.post('/api/subscribers/query/delete', data,
{ loading: models.subscribers });
export const deleteSubscribers = (params) => http.delete(
'/api/subscribers',
{ params, loading: models.subscribers },
);
export const deleteSubscribersByQuery = (data) => http.post(
'/api/subscribers/query/delete',
data,
{ loading: models.subscribers },
);
// Subscriber import.
export const importSubscribers = (data) => http.post('/api/import/subscribers', data);
export const getImportStatus = () => http.get('/api/import/subscribers');
export const getImportLogs = async () => http.get('/api/import/subscribers/logs',
{ camelCase: false });
export const getImportLogs = async () => http.get(
'/api/import/subscribers/logs',
{ camelCase: false },
);
export const stopImport = () => http.delete('/api/import/subscribers');
// Bounces.
export const getBounces = async (params) => http.get('/api/bounces',
{ params, loading: models.bounces });
export const getBounces = async (params) => http.get(
'/api/bounces',
{ params, loading: models.bounces },
);
// Campaigns.
export const getCampaigns = async (params) => http.get('/api/campaigns', {
@ -216,93 +281,162 @@ export const getCampaign = async (id) => http.get(`/api/campaigns/${id}`, {
export const getCampaignStats = async () => http.get('/api/campaigns/running/stats', {});
export const createCampaign = async (data) => http.post('/api/campaigns', data,
{ loading: models.campaigns });
export const createCampaign = async (data) => http.post(
'/api/campaigns',
data,
{ loading: models.campaigns },
);
export const getCampaignViewCounts = async (params) => http.get('/api/campaigns/analytics/views',
{ params, loading: models.campaigns });
export const getCampaignViewCounts = async (params) => http.get(
'/api/campaigns/analytics/views',
{ params, loading: models.campaigns },
);
export const getCampaignClickCounts = async (params) => http.get('/api/campaigns/analytics/clicks',
{ params, loading: models.campaigns });
export const getCampaignClickCounts = async (params) => http.get(
'/api/campaigns/analytics/clicks',
{ params, loading: models.campaigns },
);
export const getCampaignBounceCounts = async (params) => http.get('/api/campaigns/analytics/bounces',
{ params, loading: models.campaigns });
export const getCampaignBounceCounts = async (params) => http.get(
'/api/campaigns/analytics/bounces',
{ params, loading: models.campaigns },
);
export const getCampaignLinkCounts = async (params) => http.get('/api/campaigns/analytics/links',
{ params, loading: models.campaigns });
export const getCampaignLinkCounts = async (params) => http.get(
'/api/campaigns/analytics/links',
{ params, loading: models.campaigns },
);
export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
{ loading: models.campaigns });
export const convertCampaignContent = async (data) => http.post(
`/api/campaigns/${data.id}/content`,
data,
{ loading: models.campaigns },
);
export const testCampaign = async (data) => http.post(`/api/campaigns/${data.id}/test`, data,
{ loading: models.campaigns });
export const testCampaign = async (data) => http.post(
`/api/campaigns/${data.id}/test`,
data,
{ loading: models.campaigns },
);
export const updateCampaign = async (id, data) => http.put(`/api/campaigns/${id}`, data,
{ loading: models.campaigns });
export const updateCampaign = async (id, data) => http.put(
`/api/campaigns/${id}`,
data,
{ loading: models.campaigns },
);
export const changeCampaignStatus = async (id, status) => http.put(`/api/campaigns/${id}/status`,
{ status }, { loading: models.campaigns });
export const changeCampaignStatus = async (id, status) => http.put(
`/api/campaigns/${id}/status`,
{ status },
export const updateCampaignArchive = async (id, data) => http.put(`/api/campaigns/${id}/archive`, data,
{ loading: models.campaigns });
{ loading: models.campaigns },
);
export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
{ loading: models.campaigns });
export const updateCampaignArchive = async (id, data) => http.put(
`/api/campaigns/${id}/archive`,
data,
{ loading: models.campaigns },
);
export const deleteCampaign = async (id) => http.delete(
`/api/campaigns/${id}`,
{ loading: models.campaigns },
);
// Media.
export const getMedia = async (params) => http.get('/api/media',
{ params, loading: models.media, store: models.media });
export const getMedia = async (params) => http.get(
'/api/media',
{ params, loading: models.media, store: models.media },
);
export const uploadMedia = (data) => http.post('/api/media', data,
{ loading: models.media });
export const uploadMedia = (data) => http.post(
'/api/media',
data,
{ loading: models.media },
);
export const deleteMedia = (id) => http.delete(`/api/media/${id}`,
{ loading: models.media });
export const deleteMedia = (id) => http.delete(
`/api/media/${id}`,
{ loading: models.media },
);
// Templates.
export const createTemplate = async (data) => http.post('/api/templates', data,
{ loading: models.templates });
export const createTemplate = async (data) => http.post(
'/api/templates',
data,
{ loading: models.templates },
);
export const getTemplates = async () => http.get('/api/templates',
{ loading: models.templates, store: models.templates });
export const getTemplates = async () => http.get(
'/api/templates',
{ loading: models.templates, store: models.templates },
);
export const updateTemplate = async (data) => http.put(`/api/templates/${data.id}`, data,
{ loading: models.templates });
export const updateTemplate = async (data) => http.put(
`/api/templates/${data.id}`,
data,
{ loading: models.templates },
);
export const makeTemplateDefault = async (id) => http.put(`/api/templates/${id}/default`, {},
{ loading: models.templates });
export const makeTemplateDefault = async (id) => http.put(
`/api/templates/${id}/default`,
{},
{ loading: models.templates },
);
export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates });
export const deleteTemplate = async (id) => http.delete(
`/api/templates/${id}`,
{ loading: models.templates },
);
// Settings.
export const getServerConfig = async () => http.get('/api/config',
{ loading: models.serverConfig, store: models.serverConfig, camelCase: false });
export const getServerConfig = async () => http.get(
'/api/config',
{ loading: models.serverConfig, store: models.serverConfig, camelCase: false },
);
export const getSettings = async () => http.get('/api/settings',
{ loading: models.settings, store: models.settings, camelCase: false });
export const getSettings = async () => http.get(
'/api/settings',
{ loading: models.settings, store: models.settings, camelCase: false },
);
export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings });
export const updateSettings = async (data) => http.put(
'/api/settings',
data,
{ loading: models.settings },
);
export const testSMTP = async (data) => http.post('/api/settings/smtp/test', data,
{ loading: models.settings, disableToast: true });
export const testSMTP = async (data) => http.post(
'/api/settings/smtp/test',
data,
{ loading: models.settings, disableToast: true },
);
export const getLogs = async () => http.get('/api/logs',
{ loading: models.logs, camelCase: false });
export const getLogs = async () => http.get(
'/api/logs',
{ loading: models.logs, camelCase: false },
);
export const getLang = async (lang) => http.get(`/api/lang/${lang}`,
{ loading: models.lang, camelCase: false });
export const getLang = async (lang) => http.get(
`/api/lang/${lang}`,
{ loading: models.lang, camelCase: false },
);
export const logout = async () => http.get('/api/logout', {
auth: { username: 'wrong', password: 'wrong' },
});
export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete(`/api/maintenance/analytics/${typ}`,
{ loading: models.maintenance, params: { before_date: beforeDate } });
export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete(
`/api/maintenance/analytics/${typ}`,
{ loading: models.maintenance, params: { before_date: beforeDate } },
);
export const deleteGCSubscribers = async (typ) => http.delete(`/api/maintenance/subscribers/${typ}`,
{ loading: models.maintenance });
export const deleteGCSubscribers = async (typ) => http.delete(
`/api/maintenance/subscribers/${typ}`,
{ loading: models.maintenance },
);
export const deleteGCSubscriptions = async (beforeDate) => http.delete('/api/maintenance/subscriptions/unconfirmed',
{ loading: models.maintenance, params: { before_date: beforeDate } });
export const deleteGCSubscriptions = async (beforeDate) => http.delete(
'/api/maintenance/subscriptions/unconfirmed',
{ loading: models.maintenance, params: { before_date: beforeDate } },
);

View file

@ -1,5 +1,5 @@
/* Import Bulma to set variables */
@import "~bulma/sass/utilities/_all";
@import "../node_modules/bulma/sass/utilities/_all";
/* import inter-regular */
@font-face {
@ -49,8 +49,8 @@ $menu-item-active-color: $primary;
$modal-background-background-color: rgba(0, 0, 0, .30);
/* Import full Bulma and Buefy */
@import "~bulma";
@import "~buefy/src/scss/buefy";
@import "bulma";
@import "buefy/src/scss/buefy";
/* Custom style overrides */
html, body {

View file

@ -1,7 +1,6 @@
<template>
<div>
<b-modal scroll="keep" @close="close"
:aria-modal="true" :active="isVisible">
<b-modal scroll="keep" @close="close" :aria-modal="true" :active="isVisible">
<div>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
@ -9,7 +8,7 @@
</header>
</div>
<section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading>
<b-loading :active="isLoading" :is-full-page="false" />
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
<input type="hidden" name="template_id" :value="templateId" />
<input type="hidden" name="content_type" :value="contentType" />
@ -17,14 +16,13 @@
<input type="hidden" name="body" :value="body" />
</form>
<iframe id="iframe" name="iframe" ref="iframe"
:title="title"
:src="body ? 'about:blank' : previewURL"
@load="onLoaded"
></iframe>
<iframe id="iframe" name="iframe" ref="iframe" :title="title" :src="body ? 'about:blank' : previewURL"
@load="onLoaded" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="close">{{ $t('globals.buttons.close') }}</b-button>
<b-button @click="close">
{{ $t('globals.buttons.close') }}
</b-button>
</footer>
</div>
</b-modal>
@ -39,21 +37,18 @@ export default {
props: {
// Template or campaign ID.
id: Number,
title: String,
id: { type: Number, default: 0 },
title: { type: String, default: '' },
// campaign | template.
type: String,
type: { type: String, default: '' },
// campaign | tx.
templateType: String,
templateType: { type: String, default: '' },
body: String,
contentType: String,
templateId: {
type: Number,
default: 0,
},
body: { type: String, default: '' },
contentType: { type: String, default: '' },
templateId: { type: Number, default: 0 },
},
data() {

View file

@ -5,52 +5,48 @@
<div class="column is-6">
<b-field label="Format">
<div>
<b-radio v-model="form.radioFormat"
@input="onFormatChange" :disabled="disabled" name="format"
native-value="richtext"
data-cy="check-richtext">{{ $t('campaigns.richText') }}</b-radio>
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
native-value="richtext" data-cy="check-richtext">
{{ $t('campaigns.richText') }}
</b-radio>
<b-radio v-model="form.radioFormat"
@input="onFormatChange" :disabled="disabled" name="format"
native-value="html"
data-cy="check-html">{{ $t('campaigns.rawHTML') }}</b-radio>
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
native-value="html" data-cy="check-html">
{{ $t('campaigns.rawHTML') }}
</b-radio>
<b-radio v-model="form.radioFormat"
@input="onFormatChange" :disabled="disabled" name="format"
native-value="markdown"
data-cy="check-markdown">{{ $t('campaigns.markdown') }}</b-radio>
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
native-value="markdown" data-cy="check-markdown">
{{ $t('campaigns.markdown') }}
</b-radio>
<b-radio v-model="form.radioFormat"
@input="onFormatChange" :disabled="disabled" name="format"
native-value="plain"
data-cy="check-plain">{{ $t('campaigns.plainText') }}</b-radio>
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
native-value="plain" data-cy="check-plain">
{{ $t('campaigns.plainText') }}
</b-radio>
</div>
</b-field>
</div>
<div class="column is-6 has-text-right">
<b-button @click="onTogglePreview" type="is-primary"
icon-left="file-find-outline" data-cy="btn-preview">
{{ $t('campaigns.preview') }}
</b-button>
<b-button @click="onTogglePreview" type="is-primary" icon-left="file-find-outline" data-cy="btn-preview">
{{ $t('campaigns.preview') }}
</b-button>
</div>
</div>
<!-- wsywig //-->
<template v-if="isRichtextReady && form.format === 'richtext'">
<tiny-mce
v-model="form.body"
:disabled="disabled"
:init="richtextConf"
/>
<tiny-mce v-model="form.body" :disabled="disabled" :init="richtextConf" />
<b-modal scroll="keep" :width="1200"
:aria-modal="true" :active.sync="isRichtextSourceVisible">
<b-modal scroll="keep" :width="1200" :aria-modal="true" :active.sync="isRichtextSourceVisible">
<div>
<section expanded class="modal-card-body preview">
<html-editor v-model="richTextSourceBody" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="onFormatRichtextHTML">{{ $t('campaigns.formatHTML') }}</b-button>
<b-button @click="onFormatRichtextHTML">
{{ $t('campaigns.formatHTML') }}
</b-button>
<b-button @click="() => { this.isRichtextSourceVisible = false; }">
{{ $t('globals.buttons.close') }}
</b-button>
@ -61,14 +57,15 @@
</div>
</b-modal>
<b-modal scroll="keep" :width="750"
:aria-modal="true" :active.sync="isInsertHTMLVisible">
<b-modal scroll="keep" :width="750" :aria-modal="true" :active.sync="isInsertHTMLVisible">
<div>
<section expanded class="modal-card-body preview">
<html-editor v-model="insertHTMLSnippet" />
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="onFormatRichtextHTML">{{ $t('campaigns.formatHTML') }}</b-button>
<b-button @click="onFormatRichtextHTML">
{{ $t('campaigns.formatHTML') }}
</b-button>
<b-button @click="() => { this.isInsertHTMLVisible = false; }">
{{ $t('globals.buttons.close') }}
</b-button>
@ -84,19 +81,12 @@
<html-editor v-if="form.format === 'html'" v-model="form.body" />
<!-- plain text / markdown editor //-->
<b-input v-if="form.format === 'plain' || form.format === 'markdown'"
v-model="form.body" @input="onEditorChange"
<b-input v-if="form.format === 'plain' || form.format === 'markdown'" v-model="form.body" @input="onEditorChange"
type="textarea" name="content" ref="plainEditor" class="plain-editor" />
<!-- campaign preview //-->
<campaign-preview v-if="isPreviewing"
@close="onTogglePreview"
type="campaign"
:id="id"
:title="title"
:contentType="form.format"
:templateId="templateId"
:body="form.body"></campaign-preview>
<campaign-preview v-if="isPreviewing" @close="onTogglePreview" type="campaign" :id="id" :title="title"
:content-type="form.format" :template-id="templateId" :body="form.body" />
<!-- image picker -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
@ -110,17 +100,16 @@
</template>
<script>
import { mapState } from 'vuex';
import TurndownService from 'turndown';
import { indent } from 'indent.js';
import TurndownService from 'turndown';
import { mapState } from 'vuex';
import TinyMce from '@tinymce/tinymce-vue';
import 'tinymce';
import 'tinymce/icons/default';
import 'tinymce/themes/silver';
import 'tinymce/skins/ui/oxide/skin.css';
import 'tinymce/plugins/anchor';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/colorpicker';
import 'tinymce/plugins/contextmenu';
@ -140,12 +129,13 @@ import 'tinymce/plugins/textcolor';
import 'tinymce/plugins/visualblocks';
import 'tinymce/plugins/visualchars';
import 'tinymce/plugins/wordcount';
import TinyMce from '@tinymce/tinymce-vue';
import 'tinymce/skins/ui/oxide/skin.css';
import 'tinymce/themes/silver';
import { colors, uris } from '../constants';
import Media from '../views/Media.vue';
import CampaignPreview from './CampaignPreview.vue';
import HTMLEditor from './HTMLEditor.vue';
import Media from '../views/Media.vue';
import { colors, uris } from '../constants';
const turndown = new TurndownService();
@ -172,15 +162,12 @@ export default {
},
props: {
id: Number,
title: String,
body: String,
contentType: String,
templateId: {
type: Number,
default: 0,
},
disabled: Boolean,
id: { type: Number, default: 0 },
title: { type: String, default: '' },
body: { type: String, default: '' },
contentType: { type: String, default: '' },
templateId: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
},
data() {

View file

@ -1,12 +1,12 @@
<template>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
</p>
<p>{{ !label ? $t('globals.messages.emptyState') : label }}</p>
</div>
</section>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
</p>
<p>{{ !label ? $t('globals.messages.emptyState') : label }}</p>
</div>
</section>
</template>
<script>
@ -14,8 +14,8 @@ export default {
name: 'EmptyPlaceholder',
props: {
icon: String,
label: String,
icon: { type: String, default: '' },
label: { type: String, default: '' },
},
};
</script>

View file

@ -1,5 +1,5 @@
<template>
<div ref="htmlEditor" id="html-editor" class="html-editor"></div>
<div ref="htmlEditor" id="html-editor" class="html-editor" />
</template>
<script>
@ -8,11 +8,8 @@ import { colors } from '../constants';
export default {
props: {
value: String,
language: {
type: String,
default: 'html',
},
value: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: Boolean,
},

View file

@ -1,34 +1,19 @@
<template>
<div class="field list-selector">
<div :class="['list-tags', ...classes]">
<b-taglist>
<b-tag v-for="l in selectedItems"
:key="l.id"
:class="l.subscriptionStatus"
:closable="!$props.disabled"
:data-id="l.id"
@close="removeList(l.id)" class="list">
{{ l.name }} <sup v-if="l.optin === 'double'">{{ l.subscriptionStatus }}</sup>
</b-tag>
</b-taglist>
</div>
<div :class="['list-tags', ...classes]">
<b-taglist>
<b-tag v-for="l in selectedItems" :key="l.id" :class="l.subscriptionStatus" :closable="!$props.disabled"
:data-id="l.id" @close="removeList(l.id)" class="list">
{{ l.name }} <sup v-if="l.optin === 'double'">{{ l.subscriptionStatus }}</sup>
</b-tag>
</b-taglist>
</div>
<b-field :message="message"
:label="label + (selectedItems ? ` (${selectedItems.length})` : '')"
<b-field :message="message" :label="label + (selectedItems ? ` (${selectedItems.length})` : '')"
label-position="on-border">
<b-autocomplete
v-model="query"
:placeholder="placeholder"
clearable
dropdown-position="top"
:disabled="all.length === 0 || $props.disabled"
:keep-first="true"
:clear-on-select="true"
:open-on-focus="true"
:data="filteredLists"
@select="selectList"
field="name">
</b-autocomplete>
<b-autocomplete v-model="query" :placeholder="placeholder" clearable dropdown-position="top"
:disabled="all.length === 0 || $props.disabled" :keep-first="true" :clear-on-select="true" :open-on-focus="true"
:data="filteredLists" @select="selectList" field="name" />
</b-field>
</div>
</template>
@ -40,9 +25,9 @@ export default {
name: 'ListSelector',
props: {
label: String,
placeholder: String,
message: String,
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
message: { type: String, default: '' },
required: Boolean,
disabled: Boolean,
classes: {

View file

@ -1,22 +1,22 @@
<template>
<section class="log-view">
<b-loading :active="loading" :is-full-page="false" />
<div class="lines" ref="lines">
<template v-for="(l, i) in lines">
<span :set="line = splitLine(l)" :key="i" class="line">
<span class="timestamp" :title="line.file">{{ line.timestamp }}</span>
<span class="log-message">{{ line.message }}</span>
</span>
</template>
</div>
</section>
<section class="log-view">
<b-loading :active="loading" :is-full-page="false" />
<div class="lines" ref="lines">
<template v-for="(l, i) in lines">
<span :set="line = splitLine(l)" :key="i" class="line">
<span class="timestamp" :title="line.file">{{ line.timestamp }}</span>
<span class="log-message">{{ line.message }}</span>
</span>
</template>
</div>
</section>
</template>
<script>
// Regexp for splitting log lines in the following format to
// [timestamp] [file] [message].
// 2021/05/01 00:00:00 init.go:99: reading config: config.toml
const reFormatLine = new RegExp(/^([0-9\s:/]+) (.+?\.go:[0-9]+):\s/g);
const reFormatLine = /^([0-9\s:/]+) (.+?\.go:[0-9]+):\s/g;
export default {
name: 'LogView',

View file

@ -1,96 +1,64 @@
<template>
<b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')">
</b-menu-item><!-- dashboard -->
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
v-on:update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
@update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')">
<b-menu-item :to="{name: 'lists'}" tag="router-link" :active="activeItem.lists"
data-cy="all-lists" icon="format-list-bulleted-square" :label="$t('menu.allLists')">
</b-menu-item>
<b-menu-item :to="{name: 'forms'}" tag="router-link" :active="activeItem.forms"
class="forms" icon="newspaper-variant-outline" :label="$t('menu.forms')">
</b-menu-item>
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"
icon="format-list-bulleted-square" :label="$t('menu.allLists')" />
<b-menu-item :to="{ name: 'forms' }" tag="router-link" :active="activeItem.forms" class="forms"
icon="newspaper-variant-outline" :label="$t('menu.forms')" />
</b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
data-cy="subscribers" v-on:update:active="(state) => toggleGroup('subscribers', state)"
icon="account-multiple" :label="$t('globals.terms.subscribers')">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
:label="$t('menu.allSubscribers')">
</b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link" :active="activeItem.import"
data-cy="import" icon="file-upload-outline" :label="$t('menu.import')">
</b-menu-item>
<b-menu-item :to="{name: 'bounces'}" tag="router-link" :active="activeItem.bounces"
data-cy="bounces" icon="email-bounce" :label="$t('globals.terms.bounces')">
</b-menu-item>
<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers" data-cy="subscribers"
@update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
:label="$t('globals.terms.subscribers')">
<b-menu-item :to="{ name: 'subscribers' }" tag="router-link" :active="activeItem.subscribers"
data-cy="all-subscribers" icon="account-multiple" :label="$t('menu.allSubscribers')" />
<b-menu-item :to="{ name: 'import' }" tag="router-link" :active="activeItem.import" data-cy="import"
icon="file-upload-outline" :label="$t('menu.import')" />
<b-menu-item :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces" data-cy="bounces"
icon="email-bounce" :label="$t('globals.terms.bounces')" />
</b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns" :active="activeGroup.campaigns"
data-cy="campaigns" v-on:update:active="(state) => toggleGroup('campaigns', state)"
icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link" :active="activeItem.campaigns"
data-cy="all-campaigns" icon="rocket-launch-outline" :label="$t('menu.allCampaigns')">
</b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign" data-cy="new-campaign" icon="plus"
:label="$t('menu.newCampaign')">
</b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link" :active="activeItem.media"
data-cy="media" icon="image-outline" :label="$t('menu.media')">
</b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link" :active="activeItem.templates"
data-cy="templates" icon="file-image-outline" :label="$t('globals.terms.templates')">
</b-menu-item>
<b-menu-item :to="{name: 'campaignAnalytics'}" tag="router-link"
:active="activeItem.campaignAnalytics" data-cy="analytics" icon="chart-bar"
:label="$t('globals.terms.analytics')">
</b-menu-item>
<b-menu-item :expanded="activeGroup.campaigns" :active="activeGroup.campaigns" data-cy="campaigns"
@update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
:label="$t('globals.terms.campaigns')">
<b-menu-item :to="{ name: 'campaigns' }" tag="router-link" :active="activeItem.campaigns" data-cy="all-campaigns"
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')" />
<b-menu-item :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" :active="activeItem.campaign"
data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
<b-menu-item :to="{ name: 'media' }" tag="router-link" :active="activeItem.media" data-cy="media"
icon="image-outline" :label="$t('menu.media')" />
<b-menu-item :to="{ name: 'templates' }" tag="router-link" :active="activeItem.templates" data-cy="templates"
icon="file-image-outline" :label="$t('globals.terms.templates')" />
<b-menu-item :to="{ name: 'campaignAnalytics' }" tag="router-link" :active="activeItem.campaignAnalytics"
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
</b-menu-item><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings"
data-cy="settings" v-on:update:active="(state) => toggleGroup('settings', state)"
icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{name: 'settings'}" tag="router-link" :active="activeItem.settings"
data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')">
</b-menu-item>
<b-menu-item :to="{name: 'maintenance'}" tag="router-link" :active="activeItem.maintenance"
data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')">
</b-menu-item>
<b-menu-item :to="{name: 'logs'}" tag="router-link" :active="activeItem.logs"
data-cy="logs" icon="newspaper-variant-outline" :label="$t('menu.logs')">
</b-menu-item>
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings"
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings"
icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance" data-cy="maintenance"
icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs" data-cy="logs"
icon="newspaper-variant-outline" :label="$t('menu.logs')" />
</b-menu-item><!-- settings -->
<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')"
@click.prevent="doLogout">
</b-menu-item>
<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" />
</b-menu-list>
</template>
<script>
export default {
name: 'navigation',
name: 'Navigation',
props: {
activeItem: Object,
activeGroup: Object,
activeItem: { type: Object, default: () => { } },
activeGroup: { type: Object, default: () => { } },
isMobile: Boolean,
},

View file

@ -14,8 +14,8 @@ export const models = Object.freeze({
});
// Ad-hoc URIs that are used outside of vuex requests.
const rootURL = process.env.VUE_APP_ROOT_URL || '/';
const baseURL = process.env.BASE_URL.replace(/\/$/, '');
const rootURL = import.meta.env.VUE_APP_ROOT_URL || '/';
const baseURL = import.meta.env.BASE_URL.replace(/\/$/, '');
export const uris = Object.freeze({
previewCampaign: '/api/campaigns/:id/preview',

View file

@ -9,115 +9,115 @@ const routes = [
path: '/404',
name: '404_page',
meta: { title: '404' },
component: () => import(/* webpackChunkName: "main" */ '../views/404.vue'),
component: () => import('../views/404.vue'),
},
{
path: '/',
name: 'dashboard',
meta: { title: '' },
component: () => import(/* webpackChunkName: "main" */ '../views/Dashboard.vue'),
component: () => import('../views/Dashboard.vue'),
},
{
path: '/lists',
name: 'lists',
meta: { title: 'globals.terms.lists', group: 'lists' },
component: () => import(/* webpackChunkName: "main" */ '../views/Lists.vue'),
component: () => import('../views/Lists.vue'),
},
{
path: '/lists/forms',
name: 'forms',
meta: { title: 'forms.title', group: 'lists' },
component: () => import(/* webpackChunkName: "main" */ '../views/Forms.vue'),
component: () => import('../views/Forms.vue'),
},
{
path: '/lists/:id',
name: 'list',
meta: { title: 'globals.terms.lists', group: 'lists' },
component: () => import(/* webpackChunkName: "main" */ '../views/Lists.vue'),
component: () => import('../views/Lists.vue'),
},
{
path: '/subscribers',
name: 'subscribers',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
component: () => import('../views/Subscribers.vue'),
},
{
path: '/subscribers/import',
name: 'import',
meta: { title: 'import.title', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Import.vue'),
component: () => import('../views/Import.vue'),
},
{
path: '/subscribers/bounces',
name: 'bounces',
meta: { title: 'globals.terms.bounces', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Bounces.vue'),
component: () => import('../views/Bounces.vue'),
},
{
path: '/subscribers/lists/:listID',
name: 'subscribers_list',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
component: () => import('../views/Subscribers.vue'),
},
{
path: '/subscribers/:id',
name: 'subscriber',
meta: { title: 'globals.terms.subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
component: () => import('../views/Subscribers.vue'),
},
{
path: '/campaigns',
name: 'campaigns',
meta: { title: 'globals.terms.campaigns', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Campaigns.vue'),
component: () => import('../views/Campaigns.vue'),
},
{
path: '/campaigns/media',
name: 'media',
meta: { title: 'globals.terms.media', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Media.vue'),
component: () => import('../views/Media.vue'),
},
{
path: '/campaigns/templates',
name: 'templates',
meta: { title: 'globals.terms.templates', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
component: () => import('../views/Templates.vue'),
},
{
path: '/campaigns/analytics',
name: 'campaignAnalytics',
meta: { title: 'analytics.title', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/CampaignAnalytics.vue'),
component: () => import('../views/CampaignAnalytics.vue'),
},
{
path: '/campaigns/:id',
name: 'campaign',
meta: { title: 'globals.terms.campaign', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Campaign.vue'),
component: () => import('../views/Campaign.vue'),
},
{
path: '/settings',
name: 'settings',
meta: { title: 'globals.terms.settings', group: 'settings' },
component: () => import(/* webpackChunkName: "main" */ '../views/Settings.vue'),
component: () => import('../views/Settings.vue'),
},
{
path: '/settings/logs',
name: 'logs',
meta: { title: 'logs.title', group: 'settings' },
component: () => import(/* webpackChunkName: "main" */ '../views/Logs.vue'),
component: () => import('../views/Logs.vue'),
},
{
path: '/settings/maintenance',
name: 'maintenance',
meta: { title: 'maintenance.title', group: 'settings' },
component: () => import(/* webpackChunkName: "main" */ '../views/Maintenance.vue'),
component: () => import('../views/Maintenance.vue'),
},
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
base: import.meta.env.BASE_URL,
routes,
scrollBehavior(to) {

View file

@ -101,7 +101,7 @@ export default class Utils {
}
return out.toFixed(2) + pfx;
}
};
formatNumber(v) {
return this.intlNumFormat.format(v);
@ -122,7 +122,7 @@ export default class Utils {
}
return ids.map((id) => parseInt(id, 10));
}
};
// https://stackoverflow.com/a/12034334
escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);
@ -178,7 +178,7 @@ export default class Utils {
camelString = (str) => {
const s = str.replace(/[-_\s]+(.)?/g, (match, chr) => (chr ? chr.toUpperCase() : ''));
return s.substr(0, 1).toLowerCase() + s.substr(1);
}
};
// camelKeys recursively camelCases all keys in a given object (array or {}).
// For each key it traverses, it passes a dot separated key path to an optional testFunc() bool.
@ -233,5 +233,5 @@ export default class Utils {
p[key] = val;
localStorage.setItem(prefKey, JSON.stringify(p));
}
};
}

View file

@ -1,6 +1,8 @@
<template>
<section class="page-404">
<h1 class="title">404</h1>
<h1 class="title">
404
</h1>
</section>
</template>

View file

@ -2,13 +2,14 @@
<section class="bounces">
<header class="page-header columns">
<div class="column is-two-thirds">
<h1 class="title is-4">{{ $t('globals.terms.bounces') }}
<span v-if="bounces.total > 0">({{ bounces.total }})</span></h1>
<h1 class="title is-4">
{{ $t('globals.terms.bounces') }}
<span v-if="bounces.total > 0">({{ bounces.total }})</span>
</h1>
</div>
<div class="column has-text-right buttons">
<b-button v-if="bulk.checked.length > 0 || bulk.all" type="is-primary"
icon-left="trash-can-outline" data-cy="btn-delete"
@click.prevent="$utils.confirm(null, () => deleteBounces())">
<b-button v-if="bulk.checked.length > 0 || bulk.all" type="is-primary" icon-left="trash-can-outline"
data-cy="btn-delete" @click.prevent="$utils.confirm(null, () => deleteBounces())">
{{ $t('globals.buttons.clear') }}
</b-button>
<b-button v-if="bounces.total" icon-left="trash-can-outline" data-cy="btn-delete"
@ -18,28 +19,19 @@
</div>
</header>
<b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces"
default-sort="createdAt"
checkable
@check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked"
detailed
show-detail-icon
@details-open="(row) => $buefy.toast.open(`Expanded ${row.user.first_name}`)"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="bounces.perPage" :total="bounces.total"
backend-sorting @sort="onSort">
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')"
:td-attrs="$utils.tdID" sortable>
<router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId }}">
<b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces" default-sort="createdAt" checkable
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" detailed show-detail-icon
@details-open="(row) => $buefy.toast.open(`Expanded ${row.user.first_name}`)" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="bounces.perPage"
:total="bounces.total" backend-sorting @sort="onSort">
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" :td-attrs="$utils.tdID" sortable>
<router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId } }">
{{ props.row.email }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="campaign" :label="$tc('globals.terms.campaign')"
sortable>
<router-link v-if="props.row.campaign"
:to="{ name: 'bounces', query: { campaign_id: props.row.campaign.id }}">
<b-table-column v-slot="props" field="campaign" :label="$tc('globals.terms.campaign')" sortable>
<router-link v-if="props.row.campaign" :to="{ name: 'bounces', query: { campaign_id: props.row.campaign.id } }">
{{ props.row.campaign.name }}
</router-link>
<span v-else>-</span>
@ -57,22 +49,20 @@
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="created_at"
:label="$t('globals.fields.createdAt')" sortable>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt, true) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => deleteBounce(props.row))"
data-cy="btn-delete">
<a v-if="!props.row.isDefault" href="#" @click.prevent="$utils.confirm(null, () => deleteBounce(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="trash-can-outline" size="is-small" />
<b-icon icon="trash-can-outline" size="is-small" />
</span>
</div>
</b-table-column>
@ -163,8 +153,10 @@ export default Vue.extend({
deleteBounces(all) {
const fnSuccess = () => {
this.getBounces();
this.$utils.toast(this.$t('globals.messages.deletedCount',
{ name: this.$tc('globals.terms.bounces'), num: this.bounces.total }));
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.bounces'), num: this.bounces.total },
));
};
if (all) {

View file

@ -14,29 +14,32 @@
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</span>
</p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
<h4 v-else class="title is-4">{{ $t('campaigns.newCampaign') }}</h4>
<h4 v-if="isEditing" class="title is-4">
{{ data.name }}
</h4>
<h4 v-else class="title is-4">
{{ $t('campaigns.newCampaign') }}
</h4>
</div>
<div class="column is-6">
<div class="buttons">
<b-field grouped v-if="isEditing && canEdit">
<b-field expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline" data-cy="btn-save">
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
<b-field expanded v-if="canStart">
<b-button expanded @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline" data-cy="btn-start">
<b-button expanded @click="startCampaign" :loading="loading.campaigns" type="is-primary"
icon-left="rocket-launch-outline" data-cy="btn-start">
{{ $t('campaigns.start') }}
</b-button>
</b-field>
<b-field expanded v-if="canSchedule">
<b-button expanded @click="startCampaign"
:loading="loading.campaigns"
type="is-primary" icon-left="clock-start" data-cy="btn-schedule">
<b-button expanded @click="startCampaign" :loading="loading.campaigns" type="is-primary"
icon-left="clock-start" data-cy="btn-schedule">
{{ $t('campaigns.schedule') }}
</b-button>
</b-field>
@ -45,85 +48,72 @@
</div>
</header>
<b-loading :active="loading.campaigns"></b-loading>
<b-loading :active="loading.campaigns" />
<b-tabs type="is-boxed" :animated="false" v-model="activeTab" @input="onTab">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border"
value="campaign" icon="rocket-launch-outline">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border" value="campaign"
icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="() => onSubmit(isNew ? 'create' : 'update')">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
name="name" :disabled="!canEdit"
:placeholder="$t('globals.fields.name')" required></b-input>
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name" :disabled="!canEdit"
:placeholder="$t('globals.fields.name')" required />
</b-field>
<b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject"
name="subject" :disabled="!canEdit"
:placeholder="$t('campaigns.subject')" required></b-input>
<b-input :maxlength="200" v-model="form.subject" name="subject" :disabled="!canEdit"
:placeholder="$t('campaigns.subject')" required />
</b-field>
<b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail"
name="from_email" :disabled="!canEdit"
:placeholder="$t('campaigns.fromAddressPlaceholder')" required></b-input>
<b-input :maxlength="200" v-model="form.fromEmail" name="from_email" :disabled="!canEdit"
:placeholder="$t('campaigns.fromAddressPlaceholder')" required />
</b-field>
<list-selector
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
:disabled="!canEdit"
:label="$t('globals.terms.lists')"
:placeholder="$t('campaigns.sendToLists')"
></list-selector>
<list-selector v-model="form.lists" :selected="form.lists" :all="lists.results" :disabled="!canEdit"
:label="$t('globals.terms.lists')" :placeholder="$t('campaigns.sendToLists')" />
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.templateId"
name="template" :disabled="!canEdit" required>
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.templateId" name="template"
:disabled="!canEdit" required>
<template v-for="t in templates">
<option v-if="t.type === 'campaign'"
:value="t.id" :key="t.id">{{ t.name }}</option>
<option v-if="t.type === 'campaign'" :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger"
name="messenger" :disabled="!canEdit" required>
<option v-for="m in messengers"
:value="m" :key="m">{{ m }}</option>
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger" name="messenger"
:disabled="!canEdit" required>
<option v-for="m in messengers" :value="m" :key="m">
{{ m }}
</option>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" :disabled="!canEdit"
ellipsis icon="tag-outline" :placeholder="$t('globals.terms.tags')" />
<b-taginput v-model="form.tags" name="tags" :disabled="!canEdit" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" />
</b-field>
<hr />
<div class="columns">
<div class="column is-4">
<b-field :label="$t('campaigns.sendLater')" data-cy="btn-send-later">
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field>
</div>
<div class="column">
<br />
<b-field v-if="form.sendLater" data-cy="send_at"
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
:placeholder="$t('campaigns.dateAndTime')"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
horizontal-time-picker>
</b-datetimepicker>
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
:placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime" horizontal-time-picker />
</b-field>
</div>
</div>
@ -134,18 +124,17 @@
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}
</a>
</p>
<b-field v-if="form.headersStr !== '[]' || isHeadersVisible"
label-position="on-border" :message="$t('campaigns.customHeadersHelp')">
<b-field v-if="form.headersStr !== '[]' || isHeadersVisible" label-position="on-border"
:message="$t('campaigns.customHeadersHelp')">
<b-input v-model="form.headersStr" name="headers" type="textarea"
placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]'
placeholder="[{&quot;X-Custom&quot;: &quot;value&quot;}, {&quot;X-Custom2&quot;: &quot;value&quot;}]"
:disabled="!canEdit" />
</b-field>
</div>
<hr />
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary"
:loading="loading.campaigns" data-cy="btn-continue">
<b-button native-type="submit" type="is-primary" :loading="loading.campaigns" data-cy="btn-continue">
{{ $t('campaigns.continue') }}
</b-button>
</b-field>
@ -154,18 +143,19 @@
<div class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<b-taginput v-model="form.testEmails"
:before-adding="$utils.validateEmail" :disabled="isNew"
ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
</b-field>
<b-field>
<b-button @click="() => onSubmit('test')" :loading="loading.campaigns"
:disabled="isNew" type="is-primary" icon-left="email-outline">
{{ $t('campaigns.send') }}
</b-button>
</b-field>
<h3 class="title is-size-6">
{{ $t('campaigns.sendTest') }}
</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<b-taginput v-model="form.testEmails" :before-adding="$utils.validateEmail" :disabled="isNew" ellipsis
icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
</b-field>
<b-field>
<b-button @click="() => onSubmit('test')" :loading="loading.campaigns" :disabled="isNew"
type="is-primary" icon-left="email-outline">
{{ $t('campaigns.send') }}
</b-button>
</b-field>
</div>
</div>
</div>
@ -173,34 +163,26 @@
</b-tab-item><!-- campaign -->
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew" value="content">
<editor
v-model="form.content"
:id="data.id"
:title="data.name"
:templateId="form.templateId"
:contentType="data.contentType"
:body="data.body"
:disabled="!canEdit"
/>
<editor v-model="form.content" :id="data.id" :title="data.name" :template-id="form.templateId"
:content-type="data.contentType" :body="data.body" :disabled="!canEdit" />
<div class="columns">
<div class="column is-6">
<p v-if="!isAttachFieldVisible" class="is-size-6 has-text-grey">
<a href="#" @click.prevent="onShowAttachField()" data-cy="btn-attach">
<b-icon icon="file-upload-outline" size="is-small" />
{{ $t('campaigns.addAttachments') }}
{{ $t('campaigns.addAttachments') }}
</a>
</p>
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')"
label-position="on-border" expanded data-cy="media">
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline"
ref="media" field="filename" @focus="onOpenAttach" :disabled="!canEdit" />
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border" expanded
data-cy="media">
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename"
@focus="onOpenAttach" :disabled="!canEdit" />
</b-field>
</div>
<div class="column has-text-right">
<p v-if="canEdit && form.content.contentType !== 'plain'"
class="is-size-6 has-text-grey">
<p v-if="canEdit && form.content.contentType !== 'plain'" class="is-size-6 has-text-grey">
<a v-if="form.altbody === null" href="#" @click.prevent="onAddAltBody">
<b-icon icon="text" size="is-small" /> {{ $t('campaigns.addAltText') }}
</a>
@ -213,23 +195,20 @@
</div>
<div v-if="canEdit && form.content.contentType !== 'plain'" class="alt-body">
<b-input v-if="form.altbody !== null" v-model="form.altbody"
type="textarea" :disabled="!canEdit" />
<b-input v-if="form.altbody !== null" v-model="form.altbody" type="textarea" :disabled="!canEdit" />
</div>
</b-tab-item><!-- content -->
<b-tab-item :label="$t('campaigns.archive')" icon="newspaper-variant-outline"
value="archive" :disabled="isNew">
<b-tab-item :label="$t('campaigns.archive')" icon="newspaper-variant-outline" value="archive" :disabled="isNew">
<section class="wrap">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive"
:message="$t('campaigns.archiveHelp')">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive" :message="$t('campaigns.archiveHelp')">
<div class="columns">
<div class="column">
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank"
:class="{'has-text-grey-light': !form.archive}">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" />
</a>
</div>
@ -239,31 +218,31 @@
<div class="columns">
<div class="column is-8">
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.template')"
v-model="form.archiveTemplateId" name="template"
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.archiveTemplateId" name="template"
:disabled="!canArchive || !form.archive" required>
<template v-for="t in templates">
<option v-if="t.type === 'campaign'"
:value="t.id" :key="t.id">{{ t.name }}</option>
<option v-if="t.type === 'campaign'" :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
</div>
<div class="column has-text-right">
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'"
class="button" href="#" @click.prevent="onFillArchiveMeta">{}</a>
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button" href="#"
@click.prevent="onFillArchiveMeta">{}</a>
</div>
</div>
<b-field :label="$t('campaigns.archiveMeta')"
:message="$t('campaigns.archiveMetaHelp')" label-position="on-border">
<b-input v-model="form.archiveMetaStr" name="archive_meta" type="textarea"
data-cy="archive-meta" :disabled="!canArchive || !form.archive" rows="20" />
<b-field :label="$t('campaigns.archiveMeta')" :message="$t('campaigns.archiveMetaHelp')"
label-position="on-border">
<b-input v-model="form.archiveMetaStr" name="archive_meta" type="textarea" data-cy="archive-meta"
:disabled="!canArchive || !form.archive" rows="20" />
</b-field>
<b-field v-if="!canEdit && canArchive">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline" data-cy="btn-archive-save">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-archive-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
@ -282,13 +261,13 @@
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
import htmlToPlainText from 'textversionjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
import Editor from '../components/Editor.vue';
import ListSelector from '../components/ListSelector.vue';
import Media from './Media.vue';
const TABS = ['campaign', 'content', 'archive'];
@ -568,7 +547,8 @@ export default Vue.extend({
return;
}
this.$utils.confirm(null,
this.$utils.confirm(
null,
() => {
// First save the campaign.
this.updateCampaign().then(() => {
@ -586,7 +566,8 @@ export default Vue.extend({
this.$router.push({ name: 'campaigns' });
});
});
});
},
);
},
},

View file

@ -1,16 +1,17 @@
<template>
<section class="analytics content relative">
<h1 class="title is-4">{{ $t('analytics.title') }}</h1>
<h1 class="title is-4">
{{ $t('analytics.title') }}
</h1>
<hr />
<form @submit.prevent="onSubmit">
<div class="columns">
<div class="column is-6">
<b-field :label="$t('globals.terms.campaigns')" label-position="on-border">
<b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis
icon="tag-outline" :placeholder="$t('globals.terms.campaigns')"
autocomplete :allow-new="false" :before-adding="isCampaignSelected"
@typing="queryCampaigns" field="name" :loading="isSearchLoading"></b-taginput>
<b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.campaigns')" autocomplete :allow-new="false"
:before-adding="isCampaignSelected" @typing="queryCampaigns" field="name" :loading="isSearchLoading" />
</b-field>
</div>
@ -18,19 +19,13 @@
<div class="columns">
<div class="column is-6">
<b-field data-cy="from" :label="$t('analytics.fromDate')" label-position="on-border">
<b-datetimepicker
v-model="form.from"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
<b-datetimepicker v-model="form.from" icon="calendar-clock" :timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onFromDateChange" />
</b-field>
</div>
<div class="column is-6">
<b-field data-cy="to" :label="$t('analytics.toDate')" label-position="on-border">
<b-datetimepicker
v-model="form.to"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
<b-datetimepicker v-model="form.to" icon="calendar-clock" :timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onToDateChange" />
</b-field>
</div>
@ -38,8 +33,8 @@
</div><!-- columns -->
<div class="column is-1">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="form.campaigns.length === 0" data-cy="btn-search"></b-button>
<b-button native-type="submit" type="is-primary" icon-left="magnify" :disabled="form.campaigns.length === 0"
data-cy="btn-search" />
</div>
</div><!-- columns -->
</form>
@ -48,7 +43,9 @@
<template v-if="settings['privacy.individual_tracking']">
{{ $t('analytics.isUnique') }}
</template>
<template v-else>{{ $t('analytics.nonUnique') }}</template>
<template v-else>
{{ $t('analytics.nonUnique') }}
</template>
</p>
<section class="charts mt-5">
@ -59,10 +56,10 @@
{{ v.name }}
<span class="has-text-grey-light">({{ $utils.niceNumber(counts[k]) }})</span>
</h4>
<div :ref="`chart-${k}`" :id="`chart-${k}`"></div>
<div :ref="`chart-${k}`" :id="`chart-${k}`" />
</div>
<div class="column is-2 donut-container">
<div :ref="`donut-${k}`" :id="`donut-${k}`" class="donut"></div>
<div :ref="`donut-${k}`" :id="`donut-${k}`" class="donut" />
</div>
</div>
</section>
@ -70,14 +67,14 @@
</template>
<style lang="css">
@import "~c3/c3.css";
@import "c3/c3.css";
</style>
<script>
import c3 from 'c3';
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
import c3 from 'c3';
import { colors } from '../constants';
const chartColorRed = '#ee7d5b';

View file

@ -2,14 +2,14 @@
<section class="campaigns">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">{{ $t('globals.terms.campaigns') }}
<h1 class="title is-4">
{{ $t('globals.terms.campaigns') }}
<span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded :to="{name: 'campaign', params:{id: 'new'}}"
tag="router-link" class="btn-new"
<b-button expanded :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" class="btn-new"
type="is-primary" icon-left="plus" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
@ -17,14 +17,9 @@
</div>
</header>
<b-table
:data="campaigns.results"
:loading="loading.campaigns"
:row-class="highlightedRow"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
hoverable backend-sorting @sort="onSort">
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow" paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
@ -43,12 +38,11 @@
</div>
</template>
<b-table-column v-slot="props" cell-class="status" field="status"
:label="$t('globals.fields.status')" width="10%" sortable
:td-attrs="$utils.tdID" header-class="cy-status">
<b-table-column v-slot="props" cell-class="status" field="status" :label="$t('globals.fields.status')" width="10%"
sortable :td-attrs="$utils.tdID" header-class="cy-status">
<div>
<p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
<router-link :to="{ name: 'campaign', params: { id: props.row.id } }">
<b-tag :class="props.row.status">
{{ $t(`campaigns.status.${props.row.status}`) }}
</b-tag>
@ -71,50 +65,53 @@
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" width="25%"
sortable header-class="cy-name">
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" width="25%" sortable
header-class="cy-name">
<div>
<p>
<b-tag v-if="props.row.type === 'optin'" class="is-small">
{{ $t('lists.optin') }}
</b-tag>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
{{ props.row.name }}</router-link>
<router-link :to="{ name: 'campaign', params: { id: props.row.id } }">
{{ props.row.name }}
</router-link>
</p>
<p class="is-size-7 has-text-grey">
{{ props.row.subject }}
</p>
<p class="is-size-7 has-text-grey">{{ props.row.subject }}</p>
<b-taglist>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">
{{ t }}
</b-tag>
</b-taglist>
</div>
</b-table-column>
<b-table-column v-slot="props" cell-class="lists" field="lists"
:label="$t('globals.terms.lists')" width="15%">
<b-table-column v-slot="props" cell-class="lists" field="lists" :label="$t('globals.terms.lists')" width="15%">
<ul>
<li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
<router-link :to="{ name: 'subscribers_list', params: { listID: l.id } }">
{{ l.name }}
</router-link>
</li>
</ul>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('campaigns.timestamps')"
width="19%" sortable header-class="cy-timestamp">
<b-table-column v-slot="props" field="created_at" :label="$t('campaigns.timestamps')" width="19%" sortable
header-class="cy-timestamp">
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label>{{ $t('globals.fields.createdAt') }}</label>
<label for="#">{{ $t('globals.fields.createdAt') }}</label>
<span>{{ $utils.niceDate(props.row.createdAt, true) }}</span>
</p>
<p v-if="stats.startedAt">
<label>{{ $t('campaigns.startedAt') }}</label>
<label for="#">{{ $t('campaigns.startedAt') }}</label>
<span>{{ $utils.niceDate(stats.startedAt, true) }}</span>
</p>
<p v-if="isDone(props.row)">
<label>{{ $t('campaigns.ended') }}</label>
<label for="#">{{ $t('campaigns.ended') }}</label>
<span>{{ $utils.niceDate(stats.updatedAt, true) }}</span>
</p>
<p v-if="stats.startedAt && stats.updatedAt"
class="is-capitalized">
<label><b-icon icon="alarm" size="is-small" /></label>
<p v-if="stats.startedAt && stats.updatedAt" class="is-capitalized">
<label for="#"><b-icon icon="alarm" size="is-small" /></label>
<span>{{ $utils.duration(stats.startedAt, stats.updatedAt) }}</span>
</p>
</div>
@ -123,41 +120,40 @@
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="15%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label>{{ $t('campaigns.views') }}</label>
<label for="#">{{ $t('campaigns.views') }}</label>
<span>{{ $utils.formatNumber(props.row.views) }}</span>
</p>
<p>
<label>{{ $t('campaigns.clicks') }}</label>
<label for="#">{{ $t('campaigns.clicks') }}</label>
<span>{{ $utils.formatNumber(props.row.clicks) }}</span>
</p>
<p>
<label>{{ $t('campaigns.sent') }}</label>
<label for="#">{{ $t('campaigns.sent') }}</label>
<span>
{{ $utils.formatNumber(stats.sent) }} /
{{ $utils.formatNumber(stats.toSend) }}
</span>
</p>
<p>
<label>{{ $t('globals.terms.bounces') }}</label>
<label for="#">{{ $t('globals.terms.bounces') }}</label>
<span>
<router-link :to="{name: 'bounces', query: { campaign_id: props.row.id }}">
<router-link :to="{ name: 'bounces', query: { campaign_id: props.row.id } }">
{{ $utils.formatNumber(props.row.bounces) }}
</router-link>
</span>
</p>
<p v-if="stats.rate">
<label><b-icon icon="speedometer" size="is-small"></b-icon></label>
<label for="#"><b-icon icon="speedometer" size="is-small" /></label>
<span class="send-rate">
<b-tooltip
:label="`${stats.netRate} / ${$t('campaigns.rateMinuteShort')} @
${$utils.duration(stats.startedAt, stats.updatedAt)}`"
type="is-dark">
<b-tooltip :label="`${stats.netRate} / ${$t('campaigns.rateMinuteShort')} @
${$utils.duration(stats.startedAt, stats.updatedAt)}`" type="is-dark">
{{ stats.rate.toFixed(0) }} / {{ $t('campaigns.rateMinuteShort') }}
</b-tooltip>
</span>
</p>
<p v-if="isRunning(props.row.id)">
<label>{{ $t('campaigns.progress') }}
<label for="#">
{{ $t('campaigns.progress') }}
<span class="spinner is-tiny">
<b-loading :is-full-page="false" active />
</span>
@ -172,76 +168,78 @@
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
<div>
<!-- start / pause / resume / scheduled -->
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
<a v-if="canStart(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start"
:aria-label="$t('campaigns.start')">
<b-tooltip :label="$t('campaigns.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'))" data-cy="btn-pause">
<a v-if="canPause(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
:aria-label="$t('campaigns.pause')">
<b-tooltip :label="$t('campaigns.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'))" data-cy="btn-resume">
<a v-if="canResume(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-resume"
:aria-label="$t('campaigns.send')">
<b-tooltip :label="$t('campaigns.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($t('campaigns.confirmSchedule'),
() => changeCampaignStatus(props.row, 'scheduled'))" data-cy="btn-schedule">
<a v-if="canSchedule(props.row)" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<!-- placeholder for finished campaigns -->
<a v-if="!canCancel(props.row)
&& !canSchedule(props.row) && !canStart(props.row)" data-disabled>
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
aria-label=" ">
<b-icon icon="rocket-launch-outline" size="is-small" />
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel">
<a v-if="canCancel(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))" data-cy="btn-cancel"
:aria-label="$t('globals.buttons.cancel')">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a v-else data-disabled>
<a v-else href="#" data-disabled aria-label=" ">
<b-icon icon="cancel" size="is-small" />
</a>
<a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
<a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview"
:aria-label="$t('campaigns.preview')">
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{ placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }) },
(name) => cloneCampaign(name, props.row))"
data-cy="btn-clone">
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }),
},
(name) => cloneCampaign(name, props.row))" data-cy="btn-clone"
:aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<router-link :to="{ name: 'campaignAnalytics', query: { 'id': props.row.id }}">
<router-link :to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
<b-icon icon="chart-bar" size="is-small" />
</b-tooltip>
</router-link>
<a href=""
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
() => deleteCampaign(props.row))" data-cy="btn-delete">
<b-icon icon="trash-can-outline" size="is-small" />
<a href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
</b-table-column>
@ -251,11 +249,8 @@
</template>
</b-table>
<campaign-preview v-if="previewItem"
type='campaign'
:id="previewItem.id"
:title="previewItem.name"
@close="closePreview"></campaign-preview>
<campaign-preview v-if="previewItem" type="campaign" :id="previewItem.id" :title="previewItem.name"
@close="closePreview" />
</section>
</template>

View file

@ -2,7 +2,9 @@
<section class="dashboard content">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-5">{{ $utils.niceDate(new Date()) }}</h1>
<h1 class="title is-5">
{{ $utils.niceDate(new Date()) }}
</h1>
</div>
</header>
@ -26,19 +28,19 @@
<div class="column is-6">
<ul class="no has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.lists.public) }}</label>
<label for="#">{{ $utils.niceNumber(counts.lists.public) }}</label>
{{ $t('lists.types.public') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.private) }}</label>
<label for="#">{{ $utils.niceNumber(counts.lists.private) }}</label>
{{ $t('lists.types.private') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
<label for="#">{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
{{ $t('lists.optins.single') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
<label for="#">{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
{{ $t('lists.optins.double') }}
</li>
</ul>
@ -60,7 +62,7 @@
<div class="column is-6">
<ul class="no has-text-grey">
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
<label :data-cy="`campaigns-${status}`">{{ num }}</label>
<label for="#" :data-cy="`campaigns-${status}`">{{ num }}</label>
{{ $t(`campaigns.status.${status}`) }}
<span v-if="status === 'running'" class="spinner is-tiny">
<b-loading :is-full-page="false" active />
@ -89,11 +91,11 @@
<div class="column is-6">
<ul class="no has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
<label for="#">{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
{{ $t('subscribers.status.blocklisted') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
<label for="#">{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
{{ $t('dashboard.orphanSubs') }}
</li>
</ul>
@ -119,14 +121,16 @@
<article class="tile is-child notification charts">
<div class="columns">
<div class="column is-6">
<h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
<div ref="chart-views"></div>
<h3 class="title is-size-6">
{{ $t('dashboard.campaignViews') }}
</h3><br />
<div ref="chart-views" />
</div>
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">
{{ $t('dashboard.linkClicks') }}
</h3><br />
<div ref="chart-clicks"></div>
<div ref="chart-clicks" />
</div>
</div>
</article>
@ -138,13 +142,13 @@
</template>
<style lang="css">
@import "~c3/c3.css";
@import "c3/c3.css";
</style>
<script>
import Vue from 'vue';
import c3 from 'c3';
import dayjs from 'dayjs';
import Vue from 'vue';
import { colors } from '../constants';
export default Vue.extend({

View file

@ -1,6 +1,8 @@
<template>
<section class="forms content relative">
<h1 class="title is-4">{{ $t('forms.title') }}</h1>
<h1 class="title is-4">
{{ $t('forms.title') }}
</h1>
<hr />
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
@ -16,8 +18,9 @@
<b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no" data-cy="lists">
<li v-for="l in publicLists" :key="l.id">
<b-checkbox v-model="checked"
:native-value="l.uuid">{{ l.name }}</b-checkbox>
<b-checkbox v-model="checked" :native-value="l.uuid">
{{ l.name }}
</b-checkbox>
</li>
</ul>
@ -25,8 +28,10 @@
<hr />
<h4>{{ $t('forms.publicSubPage') }}</h4>
<p>
<a :href="`${settings['app.root_url']}/subscription/form`"
target="_blank" data-cy="url">{{ settings['app.root_url'] }}/subscription/form</a>
<a :href="`${settings['app.root_url']}/subscription/form`" target="_blank" rel="noopener noreferer"
data-cy="url">
{{ settings['app.root_url'] }}/subscription/form
</a>
</p>
</template>
</div>
@ -38,11 +43,11 @@
<!-- eslint-disable max-len -->
<pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ settings['app.root_url'] }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
&lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;input type=&quot;hidden&quot; name=&quot;nonce&quot; /&gt;
&lt;p&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;{{ $t('public.subName') }}&quot; /&gt;&lt;/p&gt;
&lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;input type=&quot;hidden&quot; name=&quot;nonce&quot; /&gt;
&lt;p&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;{{ $t('public.subName') }}&quot; /&gt;&lt;/p&gt;
<template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)">
&lt;p&gt;
&lt;input id=&quot;{{ id }}&quot; type=&quot;checkbox&quot; name=&quot;l&quot; checked value=&quot;{{ l.uuid }}&quot; /&gt;
@ -59,7 +64,6 @@
&lt;/form&gt;</pre>
</div>
</div><!-- columns -->
</section>
</template>

View file

@ -1,7 +1,9 @@
<template>
<section class="import">
<h1 class="title is-4">{{ $t('import.title') }}</h1>
<b-loading :active="isLoading"></b-loading>
<h1 class="title is-4">
{{ $t('import.title') }}
</h1>
<b-loading :active="isLoading" />
<section v-if="isFree()" class="wrap">
<form @submit.prevent="onSubmit" class="box">
@ -10,39 +12,29 @@
<div class="column">
<b-field :label="$t('import.mode')" :addons="false">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe"
data-cy="check-subscribe">{{ $t('import.subscribe') }}</b-radio>
<b-radio v-model="form.mode" name="mode" native-value="subscribe" data-cy="check-subscribe">
{{ $t('import.subscribe') }}
</b-radio>
<br />
<b-radio v-model="form.mode" name="mode"
native-value="blocklist"
data-cy="check-blocklist">{{ $t('import.blocklist') }}</b-radio>
<b-radio v-model="form.mode" name="mode" native-value="blocklist" data-cy="check-blocklist">
{{ $t('import.blocklist') }}
</b-radio>
</div>
</b-field>
</div>
<div class="column">
<b-field :label="$t('globals.fields.status')" :addons="false">
<template v-if="form.mode === 'subscribe'">
<b-radio
v-model="form.subStatus"
name="subStatus"
native-value="unconfirmed"
<b-radio v-model="form.subStatus" name="subStatus" native-value="unconfirmed"
data-cy="check-unconfirmed">
{{ $t('subscribers.status.unconfirmed') }}
</b-radio>
<b-radio
v-model="form.subStatus"
name="subStatus"
native-value="confirmed"
data-cy="check-confirmed">
<b-radio v-model="form.subStatus" name="subStatus" native-value="confirmed" data-cy="check-confirmed">
{{ $t('subscribers.status.confirmed') }}
</b-radio>
</template>
<b-radio v-else
v-model="form.subStatus"
name="subStatus"
native-value="unsubscribed"
<b-radio v-else v-model="form.subStatus" name="subStatus" native-value="unsubscribed"
data-cy="check-unsubscribed">
{{ $t('subscribers.status.unsubscribed') }}
</b-radio>
@ -50,8 +42,7 @@
</div>
<div class="column">
<b-field v-if="form.mode === 'subscribe'"
:label="$t('import.overwrite')"
<b-field v-if="form.mode === 'subscribe'" :label="$t('import.overwrite')"
:message="$t('import.overwriteHelp')">
<div>
<b-switch v-model="form.overwrite" name="overwrite" data-cy="overwrite" />
@ -60,28 +51,22 @@
</div>
<div class="column">
<b-field :label="$t('import.csvDelim')" :message="$t('import.csvDelimHelp')"
class="delimiter">
<b-field :label="$t('import.csvDelim')" :message="$t('import.csvDelimHelp')" 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="$t('globals.terms.lists')"
:placeholder="$t('import.listSubHelp')"
:message="$t('import.listSubHelp')"
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<list-selector v-if="form.mode === 'subscribe'" :label="$t('globals.terms.lists')"
:placeholder="$t('import.listSubHelp')" :message="$t('import.listSubHelp')" v-model="form.lists"
:selected="form.lists" :all="lists.results" />
<hr />
<b-field :label="$t('import.csvFile')" label-position="on-border">
<b-upload v-model="form.file" drag-drop expanded>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
<b-icon icon="file-upload-outline" size="is-large" />
</p>
<p>{{ $t('import.csvFileHelp') }}</p>
</div>
@ -94,62 +79,66 @@
</div>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
:disabled="!form.file || (form.mode === 'subscribe' && form.lists.length === 0)"
:loading="isProcessing">{{ $t('import.upload') }}</b-button>
:disabled="!form.file || (form.mode === 'subscribe' && form.lists.length === 0)" :loading="isProcessing">
{{ $t('import.upload') }}
</b-button>
</div>
</div>
</form>
<br /><br />
<div class="import-help">
<h5 class="title is-size-6">{{ $t('import.instructions') }}</h5>
<h5 class="title is-size-6">
{{ $t('import.instructions') }}
</h5>
<p>{{ $t('import.instructionsHelp') }}</p>
<br />
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code>
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code>
</blockquote>
<hr />
<h5 class="title is-size-6">{{ $t('import.csvExample') }}</h5>
<h5 class="title is-size-6">
{{ $t('import.csvExample') }}
</h5>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code><br />
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code><br />
<code className="csv-row">
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>"{""age"": 42, ""planet"": ""Mars""}"</span>
</code><br />
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>"{""age"": 42, ""planet"": ""Mars""}"</span>
</code><br />
<code className="csv-row">
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>"{""age"": 24, ""job"": ""Time Traveller""}"</span>
</code>
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>"{""age"": 24, ""job"": ""Time Traveller""}"</span>
</code>
</blockquote>
</div>
</section><!-- upload //-->
<section v-if="isRunning() || isDone()" class="wrap status box has-text-centered">
<b-progress :value="progress" show-value type="is-success"></b-progress>
<b-progress :value="progress" show-value type="is-success" />
<br />
<p :class="['is-size-5', 'is-capitalized',
{'has-text-success': status.status === 'finished'},
{'has-text-danger': (status.status === 'failed' || status.status === 'stopped')}]">
{{ status.status }}</p>
<p
:class="['is-size-5', 'is-capitalized', { 'has-text-success': status.status === 'finished' }, { 'has-text-danger': (status.status === 'failed' || status.status === 'stopped') }]">
{{ status.status }}
</p>
<p>{{ $t('import.recordsCount', { num: status.imported, total: status.total }) }}</p>
<br />
<p>
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline"
type="is-primary">
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline" type="is-primary">
{{ isDone() ? $t('import.importDone') : $t('import.stopImport') }}
</b-button>
</p>
@ -175,8 +164,8 @@ export default Vue.extend({
},
props: {
data: {},
isEditing: null,
data: { type: Object, default: () => { } },
isEditing: { type: Boolean, default: false },
},
data() {

View file

@ -9,45 +9,58 @@
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">
{{ $t(`lists.types.${data.type}`) }}
</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>{{ $t('lists.newList') }}</h4>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('lists.newList') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required></b-input>
:placeholder="$t('globals.fields.name')" required />
</b-field>
<b-field :label="$t('lists.type')" label-position="on-border"
:message="$t('lists.typeHelp')">
<b-field :label="$t('lists.type')" label-position="on-border" :message="$t('lists.typeHelp')">
<b-select v-model="form.type" name="type" :placeholder="$t('lists.typeHelp')" required>
<option value="private">{{ $t('lists.types.private') }}</option>
<option value="public">{{ $t('lists.types.public') }}</option>
<option value="private">
{{ $t('lists.types.private') }}
</option>
<option value="public">
{{ $t('lists.types.public') }}
</option>
</b-select>
</b-field>
<b-field :label="$t('lists.optin')" label-position="on-border"
:message="$t('lists.optinHelp')">
<b-field :label="$t('lists.optin')" label-position="on-border" :message="$t('lists.optinHelp')">
<b-select v-model="form.optin" name="optin" placeholder="Opt-in type" required>
<option value="single">{{ $t('lists.optins.single') }}</option>
<option value="double">{{ $t('lists.optins.double') }}</option>
<option value="single">
{{ $t('lists.optins.single') }}
</option>
<option value="double">
{{ $t('lists.optins.double') }}
</option>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" ellipsis
icon="tag-outline" :placeholder="$t('globals.terms.tags')"></b-taginput>
<b-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" />
</b-field>
<b-field :label="$t('globals.fields.description')" label-position="on-border">
<b-input :maxlength="2000" v-model="form.description" name="description" type="textarea"
:placeholder="$t('globals.fields.description')"></b-input>
:placeholder="$t('globals.fields.description')" />
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.lists" data-cy="btn-save">{{ $t('globals.buttons.save') }}</b-button>
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
@ -61,8 +74,8 @@ export default Vue.extend({
name: 'ListForm',
props: {
data: {},
isEditing: null,
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
},
data() {

View file

@ -9,33 +9,25 @@
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new"
@click="showNewForm" data-cy="btn-new">
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table
:data="lists.results"
:loading="loading.lists"
hoverable default-sort="createdAt"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="lists.perPage" :total="lists.total"
backend-sorting @sort="onSort"
>
<b-table :data="lists.results" :loading="loading.lists" hoverable default-sort="createdAt" paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="lists.perPage" :total="lists.total" backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getLists">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
icon="magnify" ref="query" data-cy="query" />
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
data-cy="btn-query" />
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</div>
@ -44,24 +36,23 @@
</div>
</template>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
header-class="cy-name" sortable width="25%"
paginated backend-pagination pagination-position="both"
:td-attrs="$utils.tdID"
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable
width="25%" paginated backend-pagination pagination-position="both" :td-attrs="$utils.tdID"
@page-change="onPageChange">
<div>
<a :href="`/lists/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<a :href="`/lists/${props.row.id}`" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-taglist>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
<b-tag class="is-small" v-for="t in props.row.tags" :key="t">
{{ t }}
</b-tag>
</b-taglist>
</div>
</b-table-column>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')"
header-class="cy-type" sortable width="15%">
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" header-class="cy-type" sortable
width="15%">
<div class="tags">
<b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $t(`lists.types.${props.row.type}`) }}
@ -69,15 +60,14 @@
{{ ' ' }}
<b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
<b-icon :icon="props.row.optin === 'double' ? 'account-check-outline' : 'account-off-outline'"
size="is-small" />
{{ ' ' }}
{{ $t(`lists.optins.${props.row.optin}`) }}
</b-tag>{{ ' ' }}
<a v-if="props.row.optin === 'double'" class="is-size-7 send-optin"
href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))"
data-cy="btn-send-optin-campaign">
<a v-if="props.row.optin === 'double'" class="is-size-7 send-optin" href="#"
@click="$utils.confirm(null, () => createOptinCampaign(props.row))" data-cy="btn-send-optin-campaign">
<b-tooltip :label="$t('lists.sendOptinCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
{{ $t('lists.sendOptinCampaign') }}
@ -86,21 +76,18 @@
</div>
</b-table-column>
<b-table-column v-slot="props" field="subscriber_count"
:label="$t('globals.terms.subscribers')" header-class="cy-subscribers"
numeric sortable centered>
<b-table-column v-slot="props" field="subscriber_count" :label="$t('globals.terms.subscribers')"
header-class="cy-subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ $utils.formatNumber(props.row.subscriberCount) }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="subscriber_counts"
header-class="cy-subscribers" width="10%">
<b-table-column v-slot="props" field="subscriber_counts" header-class="cy-subscribers" width="10%">
<div class="fields stats">
<p v-for="(count, status) in filterStatuses(props.row)" :key="status">
<label>{{ $tc(`subscribers.status.${status}`, count) }}</label>
<router-link :to="`/subscribers/lists/${props.row.id}?subscription_status=${status}`"
:class="status">
<label for="#">{{ $tc(`subscribers.status.${status}`, count) }}</label>
<router-link :to="`/subscribers/lists/${props.row.id}?subscription_status=${status}`" :class="status">
{{ $utils.formatNumber(count) }}
</router-link>
</p>
@ -109,11 +96,11 @@
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
@ -124,20 +111,21 @@
</b-tooltip>
</router-link>
<a href="" @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<router-link :to="{name: 'import', query: { list_id: props.row.id }}"
data-cy="btn-import">
<router-link :to="{ name: 'import', query: { list_id: props.row.id } }" data-cy="btn-import">
<b-tooltip :label="$t('import.title')" type="is-dark">
<b-icon icon="file-upload-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="" @click.prevent="deleteList(props.row)" data-cy="btn-delete">
<a href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
@ -146,14 +134,13 @@
</b-table-column>
<template #empty v-if="!loading.lists">
<empty-placeholder />
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600"
@close="onFormClose">
<list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600" @close="onFormClose">
<list-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
@ -161,8 +148,8 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListForm from './ListForm.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import ListForm from './ListForm.vue';
export default Vue.extend({
components: {

View file

@ -1,8 +1,10 @@
<template>
<section class="logs content relative">
<h1 class="title is-4">{{ $t('logs.title') }}</h1>
<h1 class="title is-4">
{{ $t('logs.title') }}
</h1>
<hr />
<log-view :loading="loading.logs" :lines="lines"></log-view>
<log-view :loading="loading.logs" :lines="lines" />
</section>
</template>

View file

@ -1,6 +1,8 @@
<template>
<section class="maintenance wrap">
<h1 class="title is-4">{{ $t('maintenance.title') }}</h1>
<h1 class="title is-4">
{{ $t('maintenance.title') }}
</h1>
<hr />
<p class="has-text-grey">
{{ $t('maintenance.help') }}
@ -8,98 +10,110 @@
<br />
<div class="box">
<h4 class="is-size-4">{{ $t('globals.terms.subscribers') }}</h4><br />
<h4 class="is-size-4">
{{ $t('globals.terms.subscribers') }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data" message="$t('maintenance.orphanHelp')">
<b-select v-model="subscriberType" expanded>
<option value="orphan">{{ $t('dashboard.orphanSubs') }}</option>
<option value="blocklisted">{{ $t('subscribers.status.blocklisted') }}</option>
<option value="orphan">
{{ $t('dashboard.orphanSubs') }}
</option>
<option value="blocklisted">
{{ $t('subscribers.status.blocklisted') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-5"></div>
<div class="column is-5" />
<div class="column">
<br />
<b-field>
<b-button class="is-primary" :loading="loading.maintenance"
@click="deleteSubscribers" expanded>{{ $t('globals.buttons.delete') }}</b-button>
<b-button class="is-primary" :loading="loading.maintenance" @click="deleteSubscribers" expanded>
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- subscribers -->
<div class="box mt-6">
<h4 class="is-size-4">{{ $tc('globals.terms.subscriptions', 2) }}</h4><br />
<h4 class="is-size-4">
{{ $tc('globals.terms.subscriptions', 2) }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data">
<b-select v-model="subscriptionType" expanded>
<option value="optin">{{ $t('maintenance.maintenance.unconfirmedOptins') }}</option>
<option value="optin">
{{ $t('maintenance.maintenance.unconfirmedOptins') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('maintenance.olderThan')">
<b-datepicker
v-model="subscriptionDate"
required expanded
icon="calendar-clock"
:date-formatter="formatDateTime">
</b-datepicker>
<b-datepicker v-model="subscriptionDate" required expanded icon="calendar-clock"
:date-formatter="formatDateTime" />
</b-field>
</div>
<div class="column is-1"></div>
<div class="column is-1" />
<div class="column">
<br />
<b-field>
<b-button class="is-primary" :loading="loading.maintenance"
@click="deleteSubscriptions" expanded>{{ $t('globals.buttons.delete') }}</b-button>
<b-button class="is-primary" :loading="loading.maintenance" @click="deleteSubscriptions" expanded>
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- subscriptions -->
<div class="box mt-6">
<h4 class="is-size-4">{{ $t('globals.terms.analytics') }}</h4><br />
<h4 class="is-size-4">
{{ $t('globals.terms.analytics') }}
</h4><br />
<div class="columns">
<div class="column is-4">
<b-field label="Data">
<b-select v-model="analyticsType" expanded>
<option selected value="all">{{ $t('globals.terms.all') }}</option>
<option value="views">{{ $t('dashboard.campaignViews') }}</option>
<option value="clicks">{{ $t('dashboard.linkClicks') }}</option>
<option selected value="all">
{{ $t('globals.terms.all') }}
</option>
<option value="views">
{{ $t('dashboard.campaignViews') }}
</option>
<option value="clicks">
{{ $t('dashboard.linkClicks') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('maintenance.olderThan')">
<b-datepicker
v-model="analyticsDate"
required expanded
icon="calendar-clock"
:date-formatter="formatDateTime">
</b-datepicker>
<b-datepicker v-model="analyticsDate" required expanded icon="calendar-clock"
:date-formatter="formatDateTime" />
</b-field>
</div>
<div class="column is-1"></div>
<div class="column is-1" />
<div class="column">
<br />
<b-field>
<b-button expanded class="is-primary" :loading="loading.maintenance"
@click="deleteAnalytics">{{ $t('globals.buttons.delete') }}</b-button>
<b-button expanded class="is-primary" :loading="loading.maintenance" @click="deleteAnalytics">
{{ $t('globals.buttons.delete') }}
</b-button>
</b-field>
</div>
</div>
</div><!-- analytics -->
</section>
</template>
<script>
import dayjs from 'dayjs';
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
export default Vue.extend({
components: {
@ -125,8 +139,10 @@ export default Vue.extend({
null,
() => {
this.$api.deleteGCSubscribers(this.subscriberType).then((data) => {
this.$utils.toast(this.$t('globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscribers', 2), num: data.count }));
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscribers', 2), num: data.count },
));
});
},
);
@ -137,8 +153,10 @@ export default Vue.extend({
null,
() => {
this.$api.deleteGCSubscriptions(this.subscriptionDate).then((data) => {
this.$utils.toast(this.$t('globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscriptions', 2), num: data.count }));
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
{ name: this.$tc('globals.terms.subscriptions', 2), num: data.count },
));
});
},
);

View file

@ -1,63 +1,56 @@
<template>
<section class="media-files">
<h1 class="title is-4">{{ $t('media.title') }}
<h1 class="title is-4">
{{ $t('media.title') }}
<span v-if="media.length > 0">({{ media.length }})</span>
<span class="has-text-grey-light"> / {{ settings['upload.provider'] }}</span>
</h1>
<b-loading :active="isProcessing || loading.media"></b-loading>
<b-loading :active="isProcessing || loading.media" />
<section class="wrap">
<form @submit.prevent="onSubmit" class="box">
<div>
<b-field :label="$t('media.uploadImage')">
<b-upload
v-model="form.files"
drag-drop
multiple
xaccept=".png,.jpg,.jpeg,.gif,.svg"
expanded>
<b-upload v-model="form.files" drag-drop multiple xaccept=".png,.jpg,.jpeg,.gif,.svg" expanded>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
<b-icon icon="file-upload-outline" size="is-large" />
</p>
<p>{{ $t('media.uploadHelp') }}</p>
</div>
</b-upload>
</b-field>
<div class="tags" v-if="form.files.length > 0">
<b-tag v-for="(f, i) in form.files" :key="i" size="is-medium"
closable @close="removeUploadFile(i)">
<b-tag v-for="(f, i) in form.files" :key="i" size="is-medium" closable @close="removeUploadFile(i)">
{{ f.name }}
</b-tag>
</div>
<div class="buttons">
<b-button native-type="submit" type="is-primary" icon-left="file-upload-outline"
:disabled="form.files.length === 0"
:loading="isProcessing">{{ $tc('media.upload') }}</b-button>
:disabled="form.files.length === 0" :loading="isProcessing">
{{ $tc('media.upload') }}
</b-button>
</div>
</div>
</form>
</section>
<section class="wrap gallery mt-6">
<b-table :data="media.results" :hoverable="true" :loading="loading.media"
default-sort="createdAt" :paginated="true" backend-pagination pagination-position="both"
@page-change="onPageChange"
:current-page="media.page" :per-page="media.perPage" :total="media.total">
<b-table :data="media.results" :hoverable="true" :loading="loading.media" default-sort="createdAt" :paginated="true"
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="media.page"
:per-page="media.perPage" :total="media.total">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="onQueryMedia">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
icon="magnify" ref="query" data-cy="query" />
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
data-cy="btn-query" />
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</div>
@ -66,32 +59,30 @@
</div>
</template>
<b-table-column v-slot="props" field="name" width="40%"
:label="$t('globals.fields.name')">
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url"
target="_blank" class="link" :title="props.row.filename">
<b-table-column v-slot="props" field="name" width="40%" :label="$t('globals.fields.name')">
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url" target="_blank" rel="noopener noreferer"
class="link" :title="props.row.filename">
{{ props.row.filename }}
</a>
</b-table-column>
<b-table-column v-slot="props" field="thumb" width="30%">
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url"
target="_blank" class="thumb box">
<img v-if="props.row.thumbUrl" :src="props.row.thumbUrl" :title="props.row.filename" />
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url" target="_blank" rel="noopener noreferer"
class="thumb box">
<img v-if="props.row.thumbUrl" :src="props.row.thumbUrl" :title="props.row.filename" alt="" />
<span v-else class="ext">
{{ props.row.filename.split(".").pop() }}
</span>
</a>
</b-table-column>
<b-table-column v-slot="props" field="created_at" width="25%"
:label="$t('globals.fields.createdAt')" sortable>
<b-table-column v-slot="props" field="created_at" width="25%" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt, true) }}
</b-table-column>
<b-table-column v-slot="props" field="actions" width="5%" cell-class="has-text-right">
<a href="" @click.prevent="$utils.confirm(null, () => onDeleteMedia(props.row.id))"
data-cy="btn-delete">
<a href="#" @click.prevent="$utils.confirm(null, () => onDeleteMedia(props.row.id))" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
@ -120,7 +111,7 @@ export default Vue.extend({
props: {
isModal: Boolean,
type: String,
type: { type: String, default: '' },
},
data() {

View file

@ -4,15 +4,15 @@
<b-loading :is-full-page="true" v-if="loading.settings || isLoading" active />
<header class="columns page-header">
<div class="column is-half">
<h1 class="title is-4">{{ $t('settings.title') }}
<h1 class="title is-4">
{{ $t('settings.title') }}
<span class="has-text-grey-light">({{ serverConfig.version }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded :disabled="!hasFormChanged"
type="is-primary" icon-left="content-save-outline" native-type="submit"
class="isSaveEnabled" data-cy="btn-save">
<b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline"
native-type="submit" class="isSaveEnabled" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</b-field>
@ -21,44 +21,43 @@
<hr />
<section class="wrap" v-if="form">
<b-tabs type="is-boxed" :animated="false" v-model="tab">
<b-tab-item :label="$t('settings.general.name')" label-position="on-border">
<general-settings :form="form" :key="key" />
</b-tab-item><!-- general -->
<b-tabs type="is-boxed" :animated="false" v-model="tab">
<b-tab-item :label="$t('settings.general.name')" label-position="on-border">
<general-settings :form="form" :key="key" />
</b-tab-item><!-- general -->
<b-tab-item :label="$t('settings.performance.name')">
<performance-settings :form="form" :key="key" />
</b-tab-item><!-- performance -->
<b-tab-item :label="$t('settings.performance.name')">
<performance-settings :form="form" :key="key" />
</b-tab-item><!-- performance -->
<b-tab-item :label="$t('settings.privacy.name')">
<privacy-settings :form="form" :key="key" />
</b-tab-item><!-- privacy -->
<b-tab-item :label="$t('settings.privacy.name')">
<privacy-settings :form="form" :key="key" />
</b-tab-item><!-- privacy -->
<b-tab-item :label="$t('settings.security.name')">
<security-settings :form="form" :key="key" />
</b-tab-item><!-- security -->
<b-tab-item :label="$t('settings.security.name')">
<security-settings :form="form" :key="key" />
</b-tab-item><!-- security -->
<b-tab-item :label="$t('settings.media.title')">
<media-settings :form="form" :key="key" />
</b-tab-item><!-- media -->
<b-tab-item :label="$t('settings.media.title')">
<media-settings :form="form" :key="key" />
</b-tab-item><!-- media -->
<b-tab-item :label="$t('settings.smtp.name')">
<smtp-settings :form="form" :key="key" />
</b-tab-item><!-- mail servers -->
<b-tab-item :label="$t('settings.smtp.name')">
<smtp-settings :form="form" :key="key" />
</b-tab-item><!-- mail servers -->
<b-tab-item :label="$t('settings.bounces.name')">
<bounce-settings :form="form" :key="key" />
</b-tab-item><!-- bounces -->
<b-tab-item :label="$t('settings.bounces.name')">
<bounce-settings :form="form" :key="key" />
</b-tab-item><!-- bounces -->
<b-tab-item :label="$t('settings.messengers.name')">
<messenger-settings :form="form" :key="key" />
</b-tab-item><!-- messengers -->
<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->
</b-tabs>
<b-tab-item :label="$t('settings.messengers.name')">
<messenger-settings :form="form" :key="key" />
</b-tab-item><!-- messengers -->
<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->
</b-tabs>
</section>
</section>
</form>
@ -67,15 +66,15 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import AppearanceSettings from './settings/appearance.vue';
import BounceSettings from './settings/bounces.vue';
import GeneralSettings from './settings/general.vue';
import MediaSettings from './settings/media.vue';
import MessengerSettings from './settings/messengers.vue';
import PerformanceSettings from './settings/performance.vue';
import PrivacySettings from './settings/privacy.vue';
import SecuritySettings from './settings/security.vue';
import MediaSettings from './settings/media.vue';
import SmtpSettings from './settings/smtp.vue';
import BounceSettings from './settings/bounces.vue';
import MessengerSettings from './settings/messengers.vue';
import AppearanceSettings from './settings/appearance.vue';
export default Vue.extend({
components: {

View file

@ -2,50 +2,43 @@
<form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4 class="title is-size-5">{{ $t('subscribers.manageLists') }}</h4>
<h4 class="title is-size-5">
{{ $t('subscribers.manageLists') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Action">
<div>
<b-radio v-model="form.action" name="action" native-value="add"
data-cy="check-list-add">
<b-radio v-model="form.action" name="action" native-value="add" data-cy="check-list-add">
{{ $t('globals.buttons.add') }}
</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove"
data-cy="check-list-remove">
<b-radio v-model="form.action" name="action" native-value="remove" data-cy="check-list-remove">
{{ $t('globals.buttons.remove') }}
</b-radio>
<b-radio
v-model="form.action"
name="action"
native-value="unsubscribe"
data-cy="check-list-unsubscribe"
>{{ $t('subscribers.markUnsubscribed') }}</b-radio>
<b-radio v-model="form.action" name="action" native-value="unsubscribe" data-cy="check-list-unsubscribe">
{{ $t('subscribers.markUnsubscribed') }}
</b-radio>
</div>
</b-field>
<list-selector
label="Target lists"
placeholder="Lists to apply to"
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<list-selector label="Target lists" placeholder="Lists to apply to" v-model="form.lists" :selected="form.lists"
:all="lists.results" />
<b-field :message="$t('subscribers.preconfirmHelp')">
<b-checkbox v-model="form.preconfirm" data-cy="preconfirm"
:native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
<b-checkbox v-model="form.preconfirm" data-cy="preconfirm" :native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:disabled="form.lists.length === 0">{{ $t('globals.buttons.save') }}</b-button>
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :disabled="form.lists.length === 0">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
@ -62,7 +55,7 @@ export default Vue.extend({
},
props: {
numSubscribers: Number,
numSubscribers: { type: Number, default: 0 },
},
data() {

View file

@ -5,8 +5,12 @@
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">
{{ $t(`subscribers.status.${data.status}`) }}
</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('subscribers.newSubscriber') }}
</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: <span data-cy="id">{{ data.id }}</span> /
@ -17,48 +21,42 @@
<section expanded class="modal-card-body">
<b-field :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :ref="'focus'"
:placeholder="$t('subscribers.email')" required></b-input>
:placeholder="$t('subscribers.email')" required />
</b-field>
<div class="columns">
<div class="column is-8">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')"></b-input>
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('globals.fields.status')" label-position="on-border"
:message="$t('subscribers.blocklistedHelp')">
<b-select v-model="form.status" name="status"
:placeholder="$t('globals.fields.status')" required expanded>
<option value="enabled">{{ $t('subscribers.status.enabled') }}</option>
<option value="blocklisted">{{ $t('subscribers.status.blocklisted') }}</option>
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required expanded>
<option value="enabled">
{{ $t('subscribers.status.enabled') }}
</option>
<option value="blocklisted">
{{ $t('subscribers.status.blocklisted') }}
</option>
</b-select>
</b-field>
</div>
</div>
<list-selector
:label="$t('subscribers.lists')"
:placeholder="$t('subscribers.listsPlaceholder')"
:message="$t('subscribers.listsHelp')"
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<list-selector :label="$t('subscribers.lists')" :placeholder="$t('subscribers.listsPlaceholder')"
:message="$t('subscribers.listsHelp')" v-model="form.lists" :selected="form.lists" :all="lists.results" />
<div class="columns mb-5">
<div class="column is-7">
<b-field :message="$t('subscribers.preconfirmHelp')">
<b-checkbox v-model="form.preconfirm"
:native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
<b-checkbox v-model="form.preconfirm" :native-value="true" :disabled="!hasOptinList">
{{ $t('subscribers.preconfirm') }}
</b-checkbox>
</b-field>
</div>
<div class="column is-5 has-text-right" v-if="isEditing">
<a href="" @click.prevent="sendOptinConfirmation"
:class="{'is-disabled': !hasOptinList}">
<a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }">
<b-icon icon="email-outline" size="is-small" />
{{ $t('subscribers.sendOptinConfirm') }}</a>
</div>
@ -68,8 +66,7 @@
<div>
<h5>{{ $t('subscribers.attribs') }}</h5>
<b-input v-model="form.strAttribs" name="attribs" type="textarea" />
<a href="https://listmonk.app/docs/concepts"
target="_blank" rel="noopener noreferrer" class="is-size-7">
<a href="https://listmonk.app/docs/concepts" target="_blank" rel="noopener noreferrer" class="is-size-7">
{{ $t('globals.buttons.learnMore') }} <b-icon icon="link-variant" size="is-small" />
</a>
</div>
@ -77,85 +74,81 @@
<div class="mb-5" v-if="data.lists">
<h5>{{ $tc('globals.terms.subscriptions', 2) }} ({{ data.lists.length }})</h5>
<b-table :data="data.lists" hoverable default-sort="createdAt" class="subscriptions"
>
<b-table-column v-slot="props" field="name"
:label="$tc('globals.terms.list', 1)">
<b-table :data="data.lists" hoverable default-sort="createdAt" class="subscriptions">
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list', 1)">
<div>
<router-link :to="`/lists/${props.row.id}`">
{{ props.row.name }}
</router-link>
<br />
<b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
<b-icon :icon="props.row.optin === 'double' ? 'account-check-outline' : 'account-off-outline'"
size="is-small" />
{{ ' ' }}
{{ $t(`lists.optins.${props.row.optin}`) }}
</b-tag>{{ ' ' }}
</div>
</b-table-column>
<b-table-column v-slot="props" field="status" cell-class="status"
:label="$t('globals.fields.status')">
<b-table-column v-slot="props" field="status" cell-class="status" :label="$t('globals.fields.status')">
<b-tag :class="`status-${props.row.subscriptionStatus}`">
{{ $t(`subscribers.status.${props.row.subscriptionStatus}`) }}
</b-tag>
<template v-if="props.row.optin === 'double'
&& props.row.subscriptionMeta.optinIp">
<template v-if="props.row.optin === 'double' && props.row.subscriptionMeta.optinIp">
<br /><span class="is-size-7">{{ props.row.subscriptionMeta.optinIp }}</span>
</template>
</b-table-column>
<b-table-column v-slot="props" field="createdAt"
:label="$t('globals.fields.createdAt')">
<b-table-column v-slot="props" field="createdAt" :label="$t('globals.fields.createdAt')">
{{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
</b-table-column>
<b-table-column v-slot="props" field="updatedAt"
:label="$t('globals.fields.updatedAt')">
<b-table-column v-slot="props" field="updatedAt" :label="$t('globals.fields.updatedAt')">
{{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
</b-table-column>
</b-table>
</div>
<div class="bounces" v-show="bounces.length > 0">
<a href="#" class="is-size-6" disabed="true"
@click.prevent="toggleBounces">
<b-icon icon="email-bounce"></b-icon>
<a href="#" class="is-size-6" disabed="true" @click.prevent="toggleBounces">
<b-icon icon="email-bounce" />
{{ $t('bounces.view') }} ({{ bounces.length }})
</a>
<a href="#" class="is-size-6 is-pulled-right" disabed="true"
@click.prevent="deleteBounces" v-if="isBounceVisible">
<b-icon icon="trash-can-outline"></b-icon>
<a href="#" class="is-size-6 is-pulled-right" disabed="true" @click.prevent="deleteBounces"
v-if="isBounceVisible">
<b-icon icon="trash-can-outline" />
{{ $t('globals.buttons.delete') }}
</a>
<div v-if="isBounceVisible" class="mt-4">
<ol class="is-size-7">
<li v-for="b in bounces" :key="b.id" class="mb-2">
<div v-if="b.campaign">
<router-link :to="{ name: 'bounces', query: { campaign_id: b.campaign.id } }">
{{ b.campaign.name }}
</router-link>
</div>
{{ $utils.niceDate(b.createdAt, true) }}
<span class="is-pulled-right">
<a href="#" @click.prevent="toggleMeta(b.id)">
{{ b.source }}
<b-icon :icon="visibleMeta[b.id] ? 'arrow-up' : 'arrow-down'" />
</a>
</span>
<span class="is-clearfix"></span>
<pre v-if="visibleMeta[b.id]">{{ b.meta }}</pre>
<div v-if="b.campaign">
<router-link :to="{ name: 'bounces', query: { campaign_id: b.campaign.id } }">
{{ b.campaign.name }}
</router-link>
</div>
{{ $utils.niceDate(b.createdAt, true) }}
<span class="is-pulled-right">
<a href="#" @click.prevent="toggleMeta(b.id)">
{{ b.source }}
<b-icon :icon="visibleMeta[b.id] ? 'arrow-up' : 'arrow-down'" />
</a>
</span>
<span class="is-clearfix" />
<pre v-if="visibleMeta[b.id]">{{ b.meta }}</pre>
</li>
</ol>
</div>
</div>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.subscribers">{{ $t('globals.buttons.save') }}</b-button>
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.subscribers">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
@ -174,7 +167,7 @@ export default Vue.extend({
props: {
data: {
type: Object,
default: () => {},
default: () => { },
},
isEditing: Boolean,
},
@ -316,8 +309,12 @@ export default Vue.extend({
try {
attribs = JSON.parse(str);
} catch (e) {
this.$utils.toast(`${this.$t('subscribers.invalidJSON')}: ${e.toString()}`,
'is-danger', 3000);
this.$utils.toast(
`${this.$t('subscribers.invalidJSON')}: ${e.toString()}`,
'is-danger',
3000,
);
return null;
}
if (attribs instanceof Array) {

View file

@ -2,7 +2,8 @@
<section class="subscribers">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
<h1 class="title is-4">
{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">
(<span data-cy="count">{{ subscribers.total }}</span>)
</span>
@ -13,8 +14,7 @@
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded type="is-primary" icon-left="plus"
@click="showNewForm" data-cy="btn-new" class="btn-new">
<b-button expanded type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new" class="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
@ -29,32 +29,29 @@
<b-field addons>
<b-input @input="onSimpleQueryInput" v-model="queryInput" expanded
:placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced" data-cy="search"></b-input>
:disabled="isSearchAdvanced" data-cy="search" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="isSearchAdvanced" data-cy="btn-search"></b-button>
<b-button native-type="submit" type="is-primary" icon-left="magnify" :disabled="isSearchAdvanced"
data-cy="btn-search" />
</p>
</b-field>
<div v-if="isSearchAdvanced">
<b-input v-model="queryParams.queryExp"
@keydown.native.enter="onAdvancedQueryEnter"
type="textarea" ref="queryExp"
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
data-cy="query">
</b-input>
<b-input v-model="queryParams.queryExp" @keydown.native.enter="onAdvancedQueryEnter" type="textarea"
ref="queryExp" placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
data-cy="query" />
<span class="is-size-6 has-text-grey">
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank" rel="noopener noreferrer">
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank" rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}.
</a>
</span>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
icon-left="magnify" data-cy="btn-query">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel"
data-cy="btn-query-reset">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query">
{{
$t('subscribers.query') }}
</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel" data-cy="btn-query-reset">
{{ $t('subscribers.reset') }}
</b-button>
</div>
@ -72,139 +69,122 @@
</section><!-- control -->
<br />
<b-table
:data="subscribers.results"
:loading="loading.subscribers"
@check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="actions">
<a class="a" href='' @click.prevent="exportSubscribers"
data-cy="btn-export-subscribers">
<b-icon icon="cloud-download-outline" size="is-small" />
{{ $t('subscribers.export') }}
<b-table :data="subscribers.results" :loading="loading.subscribers" @check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
@page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage"
:total="subscribers.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="actions">
<a class="a" href="#" @click.prevent="exportSubscribers" data-cy="btn-export-subscribers">
<b-icon icon="cloud-download-outline" size="is-small" />
{{ $t('subscribers.export') }}
</a>
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
</a>
<template v-if="bulk.checked.length > 0">
<a class="a" href='' @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
</a>
<a class="a" href='' @click.prevent="deleteSubscribers"
data-cy="btn-delete-subscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<a class="a" href='' @click.prevent="blocklistSubscribers"
data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
</a>
<span class="a">
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash;
<a href="" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
</a>
</span>
<a class="a" href="#" @click.prevent="deleteSubscribers" data-cy="btn-delete-subscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<a class="a" href="#" @click.prevent="blocklistSubscribers" data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
</a>
<span class="a">
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash;
<a href="#" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
</a>
</span>
</template>
</div>
</template>
</span>
</template>
</div>
</template>
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')"
header-class="cy-status" :td-attrs="$utils.tdID" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">
{{ $t(`subscribers.status.${props.row.status}`) }}
</b-tag>
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
:td-attrs="$utils.tdID" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">
{{ $t(`subscribers.status.${props.row.status}`) }}
</b-tag>
</a>
</b-table-column>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" header-class="cy-email" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
{{ props.row.email }}
</a>
<b-taglist>
<template v-for="l in props.row.lists">
<router-link :to="`/subscribers/lists/${l.id}`" :key="l.id" style="padding-right:0.5em;">
<b-tag :class="l.subscriptionStatus" size="is-small" :key="l.id">
{{ l.name }}
<sup v-if="l.optin === 'double' || l.subscriptionStatus == 'unsubscribed'">
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
</sup>
</b-tag>
</router-link>
</template>
</b-taglist>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
</b-table-column>
<b-table-column v-slot="props" field="lists" :label="$t('globals.terms.lists')" header-class="cy-lists" centered>
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download"
:aria-label="$t('subscribers.downloadData')">
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')"
header-class="cy-email" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.email }}
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<b-taglist>
<template v-for="l in props.row.lists">
<router-link :to="`/subscribers/lists/${l.id}`"
v-bind:key="l.id" style="padding-right:0.5em;">
<b-tag :class="l.subscriptionStatus" size="is-small" :key="l.id">
{{ l.name }}
<sup v-if="l.optin === 'double' || l.subscriptionStatus == 'unsubscribed'">
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
</sup>
</b-tag>
</router-link>
</template>
</b-taglist>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
header-class="cy-name" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.name }}
<a href="#" @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</div>
</b-table-column>
<b-table-column v-slot="props" field="lists" :label="$t('globals.terms.lists')"
header-class="cy-lists" centered>
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download">
<b-tooltip :label="$t('subscribers.downloadData')" 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)" data-cy="btn-edit">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.subscribers">
<empty-placeholder />
</template>
<template #empty v-if="!loading.subscribers">
<empty-placeholder />
</template>
</b-table>
<!-- Manage list modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible"
:width="500" class="has-overflow">
<subscriber-bulk-list :numSubscribers="this.numSelectedSubscribers"
@finished="bulkChangeLists" />
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="500" class="has-overflow">
<subscriber-bulk-list :num-subscribers="this.numSelectedSubscribers" @finished="bulkChangeLists" />
</b-modal>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="800"
@close="onFormClose">
<subscriber-form :data="curItem" :isEditing="isEditing"
@finished="querySubscribers"></subscriber-form>
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="800" @close="onFormClose">
<subscriber-form :data="curItem" :is-editing="isEditing" @finished="querySubscribers" />
</b-modal>
</section>
</template>
@ -212,10 +192,10 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import SubscriberForm from './SubscriberForm.vue';
import SubscriberBulkList from './SubscriberBulkList.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import { uris } from '../constants';
import SubscriberBulkList from './SubscriberBulkList.vue';
import SubscriberForm from './SubscriberForm.vue';
export default Vue.extend({
components: {
@ -445,8 +425,10 @@ export default Vue.extend({
}).then(() => {
this.querySubscribers();
this.$utils.toast(this.$t('subscribers.subscribersDeleted',
{ num: this.numSelectedSubscribers }));
this.$utils.toast(this.$t(
'subscribers.subscribersDeleted',
{ num: this.numSelectedSubscribers },
));
});
};
}

View file

@ -3,31 +3,37 @@
<form @submit.prevent="onSubmit">
<div class="modal-card content template-modal-content" style="width: auto">
<header class="modal-card-head">
<b-button @click="previewTemplate"
class="is-pulled-right" type="is-primary"
icon-left="file-find-outline">{{ $t('templates.preview') }}</b-button>
<b-button @click="previewTemplate" class="is-pulled-right" type="is-primary" icon-left="file-find-outline">
{{ $t('templates.preview') }}
</b-button>
<template v-if="isEditing">
<h4>{{ data.name }}</h4>
<p class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: <span data-cy="id">{{ data.id }}</span>
</p>
</template>
<h4 v-else>{{ $t('templates.newTemplate') }}</h4>
<template v-if="isEditing">
<h4>{{ data.name }}</h4>
<p class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: <span data-cy="id">{{ data.id }}</span>
</p>
</template>
<h4 v-else>
{{ $t('templates.newTemplate') }}
</h4>
</header>
<section expanded class="modal-card-body">
<div class="columns">
<div class="column is-9">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required />
:placeholder="$t('globals.fields.name')" required />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('globals.fields.type')" label-position="on-border">
<b-select v-model="form.type" :disabled="isEditing" expanded>
<option value="campaign">{{ $tc('globals.terms.campaign') }}</option>
<option value="tx">{{ $tc('globals.terms.tx') }}</option>
<option value="campaign">
{{ $tc('globals.terms.campaign') }}
</option>
<option value="tx">
{{ $tc('globals.terms.tx') }}
</option>
</b-select>
</b-field>
</div>
@ -41,8 +47,7 @@
</div>
</div>
<b-field v-if="form.body !== null"
:label="$t('templates.rawHTML')" label-position="on-border">
<b-field v-if="form.body !== null" :label="$t('templates.rawHTML')" label-position="on-border">
<html-editor v-model="form.body" name="body" />
</b-field>
@ -50,24 +55,23 @@
<template v-if="form.type === 'campaign'">
{{ $t('templates.placeholderHelp', { placeholder: egPlaceholder }) }}
</template>
<a target="_blank" href="https://listmonk.app/docs/templating">
<a target="_blank" rel="noopener noreferer" href="https://listmonk.app/docs/templating">
{{ $t('globals.buttons.learnMore') }}
</a>
</p>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.templates">{{ $t('globals.buttons.save') }}</b-button>
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.templates">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
<campaign-preview v-if="previewItem"
type='template'
:title="previewItem.name"
:templateType="previewItem.type"
:body="form.body"
@close="closePreview"></campaign-preview>
<campaign-preview v-if="previewItem" type="template" :title="previewItem.name" :template-type="previewItem.type"
:body="form.body" @close="closePreview" />
</section>
</template>
@ -84,8 +88,8 @@ export default Vue.extend({
},
props: {
data: Object,
isEditing: null,
data: { type: Object, default: () => { } },
isEditing: { type: Boolean, default: false },
},
data() {

View file

@ -2,41 +2,39 @@
<section class="templates">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">{{ $t('globals.terms.templates') }}
<span v-if="templates.length > 0">({{ templates.length }})</span></h1>
<h1 class="title is-4">
{{ $t('globals.terms.templates') }}
<span v-if="templates.length > 0">({{ templates.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new"
@click="showNewForm">
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="templates" :hoverable="true" :loading="loading.templates"
default-sort="createdAt">
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
:td-attrs="$utils.tdID" sortable>
<b-table :data="templates" :hoverable="true" :loading="loading.templates" default-sort="createdAt">
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" :td-attrs="$utils.tdID" sortable>
<a href="#" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-tag v-if="props.row.isDefault">{{ $t('templates.default') }}</b-tag>
<b-tag v-if="props.row.isDefault">
{{ $t('templates.default') }}
</b-tag>
<p class="is-size-7 has-text-grey" v-if="props.row.type === 'tx'">
{{ props.row.subject }}
</p>
</p>
</b-table-column>
<b-table-column v-slot="props" field="type"
:label="$t('globals.fields.type')" sortable>
<b-tag v-if="props.row.type === 'campaign'"
:class="props.row.type" :data-cy="`type-${props.row.type}`">
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
<b-tag v-if="props.row.type === 'campaign'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $tc('globals.terms.campaign', 1) }}
</b-tag>
<b-tag v-else
:class="props.row.type" :data-cy="`type-${props.row.type}`">
<b-tag v-else :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $tc('globals.terms.tx', 1) }}
</b-tag>
</b-table-column>
@ -45,56 +43,54 @@
{{ props.row.id }}
</b-table-column>
<b-table-column v-slot="props" field="createdAt"
:label="$t('globals.fields.createdAt')" sortable>
<b-table-column v-slot="props" field="createdAt" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updatedAt"
:label="$t('globals.fields.updatedAt')" sortable>
<b-table-column v-slot="props" field="updatedAt" :label="$t('globals.fields.updatedAt')" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a href="#" @click.prevent="previewTemplate(props.row)" data-cy="btn-preview">
<a href="#" @click.prevent="previewTemplate(props.row)" data-cy="btn-preview"
:aria-label="$t('templates.preview')">
<b-tooltip :label="$t('templates.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}`},
(name) => cloneTemplate(name, props.row))"
data-cy="btn-clone">
<a href="#" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}` },
(name) => cloneTemplate(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault && props.row.type !== 'tx'" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"
data-cy="btn-set-default">
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))" data-cy="btn-set-default"
:aria-label="$t('templates.makeDefault')">
<b-tooltip :label="$t('templates.makeDefault')" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="check-circle-outline" size="is-small" />
<b-icon icon="check-circle-outline" size="is-small" />
</span>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"
data-cy="btn-delete">
<a v-if="!props.row.isDefault" href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<span v-else class="a has-text-grey-light">
<b-icon icon="trash-can-outline" size="is-small" />
<b-icon icon="trash-can-outline" size="is-small" />
</span>
</div>
</b-table-column>
@ -105,27 +101,22 @@
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible"
:width="1200" :can-cancel="false" class="template-modal">
<template-form :data="curItem" :isEditing="isEditing"
@finished="formFinished"></template-form>
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="1200" :can-cancel="false"
class="template-modal">
<template-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
<campaign-preview v-if="previewItem"
type='template'
:id="previewItem.id"
:templateType="previewItem.type"
:title="previewItem.name"
@close="closePreview"></campaign-preview>
<campaign-preview v-if="previewItem" type="template" :id="previewItem.id" :template-type="previewItem.type"
:title="previewItem.name" @close="closePreview" />
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import TemplateForm from './TemplateForm.vue';
import CampaignPreview from '../components/CampaignPreview.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import TemplateForm from './TemplateForm.vue';
export default Vue.extend({
components: {

View file

@ -7,13 +7,11 @@
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<html-editor v-model="data['appearance.admin.custom_css']" name="body"
language="css" />
<html-editor v-model="data['appearance.admin.custom_css']" name="body" language="css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<html-editor v-model="data['appearance.admin.custom_js']" name="body"
language="css" />
<html-editor v-model="data['appearance.admin.custom_js']" name="body" language="css" />
</b-field>
</b-tab-item><!-- admin -->
@ -23,13 +21,11 @@
</div>
<b-field :label="$t('settings.appearance.customCSS')" label-position="on-border">
<html-editor v-model="data['appearance.public.custom_css']" name="body"
language="css" />
<html-editor v-model="data['appearance.public.custom_css']" name="body" language="css" />
</b-field>
<b-field :label="$t('settings.appearance.customJS')" label-position="on-border">
<html-editor v-model="data['appearance.public.custom_js']" name="body"
language="js" />
<html-editor v-model="data['appearance.public.custom_js']" name="body" language="js" />
</b-field>
</b-tab-item><!-- public -->
</b-tabs>
@ -48,7 +44,7 @@ export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -8,26 +8,32 @@
</div>
<div class="column">
<div v-for="typ in bounceTypes" :key="typ" class="columns">
<div class="column is-2" :class="{'disabled': !data['bounce.enabled']}"
:label="$t('settings.bounces.count')" label-position="on-border">
{{ $t(`bounces.${typ}`) }}
<div class="column is-2" :class="{ disabled: !data['bounce.enabled'] }" :label="$t('settings.bounces.count')"
label-position="on-border">
{{ $t(`bounces.${typ}`) }}
</div>
<div class="column is-4" :class="{'disabled': !data['bounce.enabled']}">
<div class="column is-4" :class="{ disabled: !data['bounce.enabled'] }">
<b-field :label="$t('settings.bounces.count')" label-position="on-border"
:message="$t('settings.bounces.countHelp')" data-cy="btn-bounce-count">
<b-numberinput v-model="data['bounce.actions'][typ]['count']"
name="bounce.count" type="is-light"
<b-numberinput v-model="data['bounce.actions'][typ]['count']" name="bounce.count" type="is-light"
controls-position="compact" placeholder="3" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4" :class="{'disabled': !data['bounce.enabled']}">
<div class="column is-4" :class="{ disabled: !data['bounce.enabled'] }">
<b-field :label="$t('settings.bounces.action')" label-position="on-border">
<b-select name="bounce.action" v-model="data['bounce.actions'][typ]['action']"
expanded>
<option value="none">{{ $t('globals.terms.none') }}</option>
<option value="unsubscribe">{{ $t('email.unsub') }}</option>
<option value="blocklist">{{ $t('settings.bounces.blocklist') }}</option>
<option value="delete">{{ $t('globals.buttons.delete') }}</option>
<b-select name="bounce.action" v-model="data['bounce.actions'][typ]['action']" expanded>
<option value="none">
{{ $t('globals.terms.none') }}
</option>
<option value="unsubscribe">
{{ $t('email.unsub') }}
</option>
<option value="blocklist">
{{ $t('settings.bounces.blocklist') }}
</option>
<option value="delete">
{{ $t('globals.buttons.delete') }}
</option>
</b-select>
</b-field>
</div>
@ -36,141 +42,131 @@
</div><!-- columns -->
<div class="mb-6">
<b-field :label="$t('settings.bounces.enableWebhooks')"
data-cy="btn-enable-bounce-webhook">
<b-switch v-model="data['bounce.webhooks_enabled']"
:disabled="!data['bounce.enabled']"
name="webhooks_enabled" :native-value="true"
data-cy="btn-enable-bounce-webhook" />
<b-field :label="$t('settings.bounces.enableWebhooks')" data-cy="btn-enable-bounce-webhook">
<b-switch v-model="data['bounce.webhooks_enabled']" :disabled="!data['bounce.enabled']" name="webhooks_enabled"
:native-value="true" data-cy="btn-enable-bounce-webhook" />
<p class="has-text-grey">
<a href="https://listmonk.app/docs/bounces" target="_blank">{{ $t('globals.buttons.learnMore') }} &rarr;</a>
<a href="https://listmonk.app/docs/bounces" target="_blank" rel="noopener noreferer">{{
$t('globals.buttons.learnMore') }} &rarr;</a>
</p>
</b-field>
<div class="box" v-if="data['bounce.webhooks_enabled']">
<div class="columns">
<div class="column">
<b-field :label="$t('settings.bounces.enableSES')">
<b-switch v-model="data['bounce.ses_enabled']"
name="ses_enabled" :native-value="true" data-cy="btn-enable-bounce-ses" />
</b-field>
</div>
<div class="columns">
<div class="column">
<b-field :label="$t('settings.bounces.enableSES')">
<b-switch v-model="data['bounce.ses_enabled']" name="ses_enabled" :native-value="true"
data-cy="btn-enable-bounce-ses" />
</b-field>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enableSendgrid')">
<b-switch v-model="data['bounce.sendgrid_enabled']"
name="sendgrid_enabled" :native-value="true"
data-cy="btn-enable-bounce-sendgrid" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.sendgridKey')"
:message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.sendgrid_key']" type="password"
:disabled="!data['bounce.sendgrid_enabled']"
name="sendgrid_enabled" :native-value="true"
data-cy="btn-enable-bounce-sendgrid" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enableSendgrid')">
<b-switch v-model="data['bounce.sendgrid_enabled']" name="sendgrid_enabled" :native-value="true"
data-cy="btn-enable-bounce-sendgrid" />
</b-field>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enablePostmark')">
<b-switch v-model="data['bounce.postmark'].enabled"
name="postmark_enabled" :native-value="true"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkUsername')"
:message="$t('settings.bounces.postmarkUsernameHelp')">
<b-input v-model="data['bounce.postmark'].username" type="text"
:disabled="!data['bounce.postmark'].enabled"
name="postmark_username"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkPassword')"
:message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.postmark'].password" type="password"
:disabled="!data['bounce.postmark'].enabled"
name="postmark_password"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.sendgridKey')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.sendgrid_key']" type="password" :disabled="!data['bounce.sendgrid_enabled']"
name="sendgrid_enabled" :native-value="true" data-cy="btn-enable-bounce-sendgrid" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enablePostmark')">
<b-switch v-model="data['bounce.postmark'].enabled" name="postmark_enabled" :native-value="true"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkUsername')"
:message="$t('settings.bounces.postmarkUsernameHelp')">
<b-input v-model="data['bounce.postmark'].username" type="text" :disabled="!data['bounce.postmark'].enabled"
name="postmark_username" data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.postmarkPassword')" :message="$t('globals.messages.passwordChange')">
<b-input v-model="data['bounce.postmark'].password" type="password"
:disabled="!data['bounce.postmark'].enabled" name="postmark_password"
data-cy="btn-enable-bounce-postmark" />
</b-field>
</div>
</div>
</div>
</div>
<!-- bounce mailbox -->
<b-field :label="$t('settings.bounces.enableMailbox')">
<b-switch v-if="data['bounce.mailboxes']"
v-model="data['bounce.mailboxes'][0].enabled"
:disabled="!data['bounce.enabled']"
name="enabled" :native-value="true" data-cy="btn-enable-bounce-mailbox" />
<b-switch v-if="data['bounce.mailboxes']" v-model="data['bounce.mailboxes'][0].enabled"
:disabled="!data['bounce.enabled']" name="enabled" :native-value="true" data-cy="btn-enable-bounce-mailbox" />
</b-field>
<template v-if="data['bounce.enabled'] && data['bounce.mailboxes'][0].enabled">
<div class="block box" v-for="(item, n) in data['bounce.mailboxes']" :key="n">
<div class="columns">
<div class="column" :class="{'disabled': !item.enabled}">
<div class="column" :class="{ disabled: !item.enabled }">
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.type')" label-position="on-border">
<b-select v-model="item.type" name="type">
<option value="pop">POP</option>
<option value="pop">
POP
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
:message="$t('settings.mailserver.hostHelp')">
<b-input v-model="item.host" name="host"
placeholder='bounce.yourmailserver.net' :maxlength="200" />
<b-input v-model="item.host" name="host" placeholder="bounce.yourmailserver.net" :maxlength="200" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
:message="$t('settings.mailserver.portHelp')">
<b-numberinput v-model="item.port" name="port" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
<b-numberinput v-model="item.port" name="port" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
</div><!-- host -->
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.mailserver.authProtocol')"
label-position="on-border">
<b-field :label="$t('settings.mailserver.authProtocol')" label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol">
<option value="none">none</option>
<option v-if="item.type === 'pop'" value="userpass">userpass</option>
<option value="none">
none
</option>
<option v-if="item.type === 'pop'" value="userpass">
userpass
</option>
<template v-else>
<option value="cram">cram</option>
<option value="plain">plain</option>
<option value="login">login</option>
<option value="cram">
cram
</option>
<option value="plain">
plain
</option>
<option value="login">
login
</option>
</template>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.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 :label="$t('settings.mailserver.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="$t('settings.mailserver.password')"
label-position="on-border" expanded
<b-field :label="$t('settings.mailserver.password')" label-position="on-border" expanded
:message="$t('settings.mailserver.passwordHelp')">
<b-input v-model="item.password"
:disabled="item.auth_protocol === 'none'"
name="password" type="password"
:placeholder="$t('settings.mailserver.passwordHelp')"
:maxlength="200" />
<b-input v-model="item.password" :disabled="item.auth_protocol === 'none'" name="password"
type="password" :placeholder="$t('settings.mailserver.passwordHelp')" :maxlength="200" />
</b-field>
</b-field>
</div>
@ -179,24 +175,21 @@
<div class="columns">
<div class="column is-6">
<b-field grouped>
<b-field :label="$t('settings.mailserver.tls')" expanded
:message="$t('settings.mailserver.tlsHelp')">
<b-field :label="$t('settings.mailserver.tls')" expanded :message="$t('settings.mailserver.tlsHelp')">
<b-switch v-model="item.tls_enabled" name="item.tls_enabled" />
</b-field>
<b-field :label="$t('settings.mailserver.skipTLS')" expanded
:message="$t('settings.mailserver.skipTLSHelp')">
<b-switch v-model="item.tls_skip_verify"
:disabled="!item.tls_enabled" name="item.tls_skip_verify" />
<b-switch v-model="item.tls_skip_verify" :disabled="!item.tls_enabled" name="item.tls_skip_verify" />
</b-field>
</b-field>
</div>
<div class="column"></div>
<div class="column" />
<div class="column is-4">
<b-field :label="$t('settings.bounces.scanInterval')" expanded
label-position="on-border"
<b-field :label="$t('settings.bounces.scanInterval')" expanded label-position="on-border"
:message="$t('settings.bounces.scanIntervalHelp')">
<b-input v-model="item.scan_interval" name="scan_interval"
placeholder="15m" :pattern="regDuration" :maxlength="10" />
<b-input v-model="item.scan_interval" name="scan_interval" placeholder="15m" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div><!-- TLS -->
@ -214,7 +207,7 @@ import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -1,29 +1,29 @@
<template>
<div class="items">
<b-field :label="$t('settings.general.siteName')" label-position="on-border">
<b-input v-model="data['app.site_name']" name="app.site_name"
:label="$t('settings.general.siteName')" :maxlength="300" required />
<b-input v-model="data['app.site_name']" name="app.site_name" :label="$t('settings.general.siteName')"
:maxlength="300" required />
</b-field>
<b-field :label="$t('settings.general.rootURL')" label-position="on-border"
:message="$t('settings.general.rootURLHelp')">
<b-input v-model="data['app.root_url']" name="app.root_url"
placeholder='https://listmonk.yoursite.com' :maxlength="300" required />
<b-input v-model="data['app.root_url']" name="app.root_url" placeholder="https://listmonk.yoursite.com"
:maxlength="300" required />
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('settings.general.logoURL')" label-position="on-border"
:message="$t('settings.general.logoURLHelp')">
<b-input v-model="data['app.logo_url']" name="app.logo_url"
placeholder='https://listmonk.yoursite.com/logo.png' :maxlength="300" />
<b-input v-model="data['app.logo_url']" name="app.logo_url" placeholder="https://listmonk.yoursite.com/logo.png"
:maxlength="300" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.general.faviconURL')" label-position="on-border"
:message="$t('settings.general.faviconURLHelp')">
<b-input v-model="data['app.favicon_url']" name="app.favicon_url"
placeholder='https://listmonk.yoursite.com/favicon.png' :maxlength="300" />
placeholder="https://listmonk.yoursite.com/favicon.png" :maxlength="300" />
</b-field>
</div>
</div>
@ -32,33 +32,31 @@
<b-field :label="$t('settings.general.fromEmail')" label-position="on-border"
:message="$t('settings.general.fromEmailHelp')">
<b-input v-model="data['app.from_email']" name="app.from_email"
placeholder='Listmonk <noreply@listmonk.yoursite.com>'
pattern="(.+?)\s<(.+?)@(.+?)>" :maxlength="300" />
placeholder="Listmonk <noreply@listmonk.yoursite.com>" pattern="(.+?)\s<(.+?)@(.+?)>" :maxlength="300" />
</b-field>
<b-field :label="$t('settings.general.adminNotifEmails')" label-position="on-border"
:message="$t('settings.general.adminNotifEmailsHelp')">
<b-taginput v-model="data['app.notify_emails']" name="app.notify_emails"
:before-adding="(v) => v.match(/(.+?)@(.+?)/)"
placeholder='you@yoursite.com' />
:before-adding="(v) => v.match(/(.+?)@(.+?)/)" placeholder="you@yoursite.com" />
</b-field>
<hr />
<div>
<h2 class="is-size-4 mb-5">{{ $tc('globals.terms.subscriptions', 2) }}</h2>
<h2 class="is-size-4 mb-5">
{{ $tc('globals.terms.subscriptions', 2) }}
</h2>
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.general.enablePublicSubPage')"
:message="$t('settings.general.enablePublicSubPageHelp')">
<b-switch v-model="data['app.enable_public_subscription_page']"
name="app.enable_public_subscription_page" />
<b-switch v-model="data['app.enable_public_subscription_page']" name="app.enable_public_subscription_page" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.general.sendOptinConfirm')"
:message="$t('settings.general.sendOptinConfirmHelp')">
<b-switch v-model="data['app.send_optin_confirmation']"
name="app.send_optin_confirmation" />
<b-switch v-model="data['app.send_optin_confirmation']" name="app.send_optin_confirmation" />
</b-field>
</div>
</div>
@ -66,41 +64,41 @@
<hr />
<div>
<h2 class="is-size-4 mb-5">{{ $t('campaigns.archive') }}</h2>
<h2 class="is-size-4 mb-5">
{{ $t('campaigns.archive') }}
</h2>
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.general.enablePublicArchive')"
:message="$t('settings.general.enablePublicArchiveHelp')">
<b-switch v-model="data['app.enable_public_archive']"
name="app.enable_public_archive" />
<b-switch v-model="data['app.enable_public_archive']" name="app.enable_public_archive" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.general.enablePublicArchiveRSSContent')"
:message="$t('settings.general.enablePublicArchiveRSSContentHelp')">
<b-switch v-model="data['app.enable_public_archive_rss_content']"
name="app.enable_public_archive_rss_content" />
name="app.enable_public_archive_rss_content" />
</b-field>
</div>
</div>
</div>
<hr />
<b-field :label="$t('settings.general.checkUpdates')"
:message="$t('settings.general.checkUpdatesHelp')">
<b-switch v-model="data['app.check_updates']"
name="app.check_updates" />
<b-field :label="$t('settings.general.checkUpdates')" :message="$t('settings.general.checkUpdatesHelp')">
<b-switch v-model="data['app.check_updates']" name="app.check_updates" />
</b-field>
<hr />
<b-field :label="$t('settings.general.language')" label-position="on-border" :addons="false">
<b-select v-model="data['app.lang']" name="app.lang">
<option v-for="l in serverConfig.langs" :key="l.code" :value="l.code">
{{ l.name }}
</option>
<option v-for="l in serverConfig.langs" :key="l.code" :value="l.code">
{{ l.name }}
</option>
</b-select>
<p class="mt-2">
<a href="https://listmonk.app/docs/i18n/#additional-language-packs" target="_blank">{{ $t('globals.buttons.more') }} &rarr;</a>
<a href="https://listmonk.app/docs/i18n/#additional-language-packs" target="_blank" rel="noopener noreferer">{{
$t('globals.buttons.more') }} &rarr;</a>
</p>
</b-field>
</div>
@ -113,7 +111,7 @@ import { mapState } from 'vuex';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -4,16 +4,19 @@
<div class="column">
<b-field :label="$t('settings.media.provider')" label-position="on-border">
<b-select v-model="data['upload.provider']" name="upload.provider">
<option value="filesystem">filesystem</option>
<option value="s3">s3</option>
<option value="filesystem">
filesystem
</option>
<option value="s3">
s3
</option>
</b-select>
</b-field>
</div>
<div class="column is-10">
<b-field :label="$t('settings.media.upload.extensions')" label-position="on-border"
expanded>
<b-taginput v-model="data['upload.extensions']" name="tags" ellipsis
icon="tag-outline" placeholder="jpg, png, gif .."></b-taginput>
<b-field :label="$t('settings.media.upload.extensions')" label-position="on-border" expanded>
<b-taginput v-model="data['upload.extensions']" name="tags" ellipsis icon="tag-outline"
placeholder="jpg, png, gif .." />
</b-field>
</div>
</div>
@ -22,42 +25,35 @@
<div class="block" v-if="data['upload.provider'] === 'filesystem'">
<b-field :label="$t('settings.media.upload.path')" label-position="on-border"
:message="$t('settings.media.upload.pathHelp')">
<b-input v-model="data['upload.filesystem.upload_path']"
name="app.upload_path" placeholder='/home/listmonk/uploads'
:maxlength="200" required />
<b-input v-model="data['upload.filesystem.upload_path']" name="app.upload_path"
placeholder="/home/listmonk/uploads" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.media.upload.uri')" label-position="on-border"
:message="$t('settings.media.upload.uriHelp')">
<b-input v-model="data['upload.filesystem.upload_uri']"
name="app.upload_uri" placeholder='/uploads' :maxlength="200"
required pattern="^\/(.+?)" />
<b-input v-model="data['upload.filesystem.upload_uri']" name="app.upload_uri" placeholder="/uploads"
:maxlength="200" required pattern="^\/(.+?)" />
</b-field>
</div><!-- filesystem -->
<div class="block" v-if="data['upload.provider'] === 's3'">
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.media.s3.region')"
label-position="on-border" expanded>
<b-field :label="$t('settings.media.s3.region')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.aws_default_region']" @input="onS3URLChange"
name="upload.s3.aws_default_region"
:maxlength="200" placeholder="ap-south-1" />
name="upload.s3.aws_default_region" :maxlength="200" placeholder="ap-south-1" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.media.s3.key')"
label-position="on-border" expanded>
<b-input v-model="data['upload.s3.aws_access_key_id']"
name="upload.s3.aws_access_key_id" :maxlength="200" />
<b-field :label="$t('settings.media.s3.key')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.aws_access_key_id']" name="upload.s3.aws_access_key_id"
:maxlength="200" />
</b-field>
<b-field :label="$t('settings.media.s3.secret')"
label-position="on-border" expanded
<b-field :label="$t('settings.media.s3.secret')" label-position="on-border" expanded
message="Enter a value to change.">
<b-input v-model="data['upload.s3.aws_secret_access_key']"
name="upload.s3.aws_secret_access_key" type="password"
:maxlength="200" />
<b-input v-model="data['upload.s3.aws_secret_access_key']" name="upload.s3.aws_secret_access_key"
type="password" :maxlength="200" />
</b-field>
</b-field>
</div>
@ -66,8 +62,7 @@
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.media.s3.bucketType')" label-position="on-border">
<b-select v-model="data['upload.s3.bucket_type']"
name="upload.s3.bucket_type" expanded>
<b-select v-model="data['upload.s3.bucket_type']" name="upload.s3.bucket_type" expanded>
<option value="private">
{{ $t('settings.media.s3.bucketTypePrivate') }}
</option>
@ -79,16 +74,14 @@
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.media.s3.bucket')"
label-position="on-border" expanded>
<b-input v-model="data['upload.s3.bucket']" @input="onS3URLChange"
name="upload.s3.bucket" :maxlength="200" placeholder="" />
<b-field :label="$t('settings.media.s3.bucket')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.bucket']" @input="onS3URLChange" name="upload.s3.bucket" :maxlength="200"
placeholder="" />
</b-field>
<b-field :label="$t('settings.media.s3.bucketPath')"
label-position="on-border"
<b-field :label="$t('settings.media.s3.bucketPath')" label-position="on-border"
:message="$t('settings.media.s3.bucketPathHelp')" expanded>
<b-input v-model="data['upload.s3.bucket_path']"
name="upload.s3.bucket_path" :maxlength="200" placeholder="/" />
<b-input v-model="data['upload.s3.bucket_path']" name="upload.s3.bucket_path" :maxlength="200"
placeholder="/" />
</b-field>
</b-field>
</div>
@ -96,26 +89,22 @@
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.media.s3.uploadExpiry')"
label-position="on-border"
<b-field :label="$t('settings.media.s3.uploadExpiry')" label-position="on-border"
:message="$t('settings.media.s3.uploadExpiryHelp')" expanded>
<b-input v-model="data['upload.s3.expiry']"
name="upload.s3.expiry"
placeholder="14d" :pattern="regDuration" :maxlength="10" />
<b-input v-model="data['upload.s3.expiry']" name="upload.s3.expiry" placeholder="14d" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
<div class="column is-9">
<b-field :label="$t('settings.media.s3.url')" label-position="on-border"
:message="$t('settings.media.s3.urlHelp')" expanded>
<b-input v-model="data['upload.s3.url']"
name="upload.s3.url" :disabled="!data['upload.s3.bucket']" required
<b-input v-model="data['upload.s3.url']" name="upload.s3.url" :disabled="!data['upload.s3.bucket']" required
placeholder="https://s3.$region.amazonaws.com" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.media.s3.publicURL')" label-position="on-border" expanded>
<b-input v-model="data['upload.s3.public_url']"
:message="$t('settings.media.s3.publicURLHelp')"
name="upload.s3.public_url" :disabled="!data['upload.s3.bucket']"
placeholder="https://files.yourdomain.com" :maxlength="200" />
<b-input v-model="data['upload.s3.public_url']" :message="$t('settings.media.s3.publicURLHelp')"
name="upload.s3.public_url" :disabled="!data['upload.s3.bucket']" placeholder="https://files.yourdomain.com"
:maxlength="200" />
</b-field>
</div>
</div>
@ -130,7 +119,7 @@ import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -5,32 +5,29 @@
<div class="columns">
<div class="column is-2">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" />
<b-switch v-model="item.enabled" name="enabled" :native-value="true" />
</b-field>
<b-field>
<a @click.prevent="$utils.confirm(null, () => removeMessenger(n))"
href="#" class="is-size-7">
<a @click.prevent="$utils.confirm(null, () => removeMessenger(n))" href="#" class="is-size-7">
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
</div><!-- first column -->
<div class="column" :class="{'disabled': !item.enabled}">
<div class="column" :class="{ disabled: !item.enabled }">
<div class="columns">
<div class="column is-4">
<b-field :label="$t('globals.fields.name')" label-position="on-border"
:message="$t('settings.messengers.nameHelp')">
<b-input v-model="item.name" name="name"
placeholder='mymessenger' :maxlength="200" />
<b-input v-model="item.name" name="name" placeholder="mymessenger" :maxlength="200" />
</b-field>
</div>
<div class="column is-8">
<b-field :label="$t('settings.messengers.url')" label-position="on-border"
:message="$t('settings.messengers.urlHelp')">
<b-input v-model="item.root_url" name="root_url"
placeholder='https://postback.messenger.net/path' :maxlength="200" />
<b-input v-model="item.root_url" name="root_url" placeholder="https://postback.messenger.net/path"
:maxlength="200" />
</b-field>
</div>
</div><!-- host -->
@ -38,17 +35,13 @@
<div class="columns">
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.messengers.username')"
label-position="on-border" expanded>
<b-field :label="$t('settings.messengers.username')" label-position="on-border" expanded>
<b-input v-model="item.username" name="username" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.messengers.password')"
label-position="on-border" expanded
<b-field :label="$t('settings.messengers.password')" label-position="on-border" expanded
:message="$t('globals.messages.passwordChange')">
<b-input v-model="item.password"
name="password" type="password"
:placeholder="$t('globals.messages.passwordChange')"
:maxlength="200" />
<b-input v-model="item.password" name="password" type="password"
:placeholder="$t('globals.messages.passwordChange')" :maxlength="200" />
</b-field>
</b-field>
</div>
@ -57,30 +50,24 @@
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.messengers.maxConns')"
label-position="on-border"
<b-field :label="$t('settings.messengers.maxConns')" label-position="on-border"
:message="$t('settings.messengers.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.messengers.retries')"
label-position="on-border"
<b-field :label="$t('settings.messengers.retries')" label-position="on-border"
:message="$t('settings.messengers.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
type="is-light"
controls-position="compact"
placeholder="2" min="1" max="1000" />
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries" type="is-light"
controls-position="compact" placeholder="2" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4">
<b-field :label="$t('settings.messengers.timeout')"
label-position="on-border"
<b-field :label="$t('settings.messengers.timeout')" label-position="on-border"
:message="$t('settings.messengers.timeoutHelp')">
<b-input v-model="item.timeout" name="timeout"
placeholder="5s" :pattern="regDuration" :maxlength="10" />
<b-input v-model="item.timeout" name="timeout" placeholder="5s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div>
@ -103,7 +90,7 @@ import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -2,31 +2,26 @@
<div class="items">
<b-field :label="$t('settings.performance.concurrency')" label-position="on-border"
:message="$t('settings.performance.concurrencyHelp')">
<b-numberinput v-model="data['app.concurrency']"
name="app.concurrency" type="is-light"
placeholder="5" min="1" max="10000" />
<b-numberinput v-model="data['app.concurrency']" name="app.concurrency" type="is-light" placeholder="5" min="1"
max="10000" />
</b-field>
<b-field :label="$t('settings.performance.messageRate')" label-position="on-border"
:message="$t('settings.performance.messageRateHelp')">
<b-numberinput v-model="data['app.message_rate']"
name="app.message_rate" type="is-light"
placeholder="5" min="1" max="100000" />
<b-numberinput v-model="data['app.message_rate']" name="app.message_rate" type="is-light" placeholder="5" min="1"
max="100000" />
</b-field>
<b-field :label="$t('settings.performance.batchSize')" label-position="on-border"
:message="$t('settings.performance.batchSizeHelp')">
<b-numberinput v-model="data['app.batch_size']"
name="app.batch_size" type="is-light"
placeholder="1000" min="1" max="100000" />
<b-numberinput v-model="data['app.batch_size']" name="app.batch_size" type="is-light" placeholder="1000" min="1"
max="100000" />
</b-field>
<b-field :label="$t('settings.performance.maxErrThreshold')"
label-position="on-border"
<b-field :label="$t('settings.performance.maxErrThreshold')" label-position="on-border"
:message="$t('settings.performance.maxErrThresholdHelp')">
<b-numberinput v-model="data['app.max_send_errors']"
name="app.max_send_errors" type="is-light"
placeholder="1999" min="0" max="100000" />
<b-numberinput v-model="data['app.max_send_errors']" name="app.max_send_errors" type="is-light" placeholder="1999"
min="0" max="100000" />
</b-field>
<div>
@ -34,35 +29,24 @@
<div class="column is-6">
<b-field :label="$t('settings.performance.slidingWindow')"
:message="$t('settings.performance.slidingWindowHelp')">
<b-switch v-model="data['app.message_sliding_window']"
name="app.message_sliding_window" />
<b-switch v-model="data['app.message_sliding_window']" name="app.message_sliding_window" />
</b-field>
</div>
<div class="column is-3"
:class="{'disabled': !data['app.message_sliding_window']}">
<b-field :label="$t('settings.performance.slidingWindowRate')"
label-position="on-border"
<div class="column is-3" :class="{ disabled: !data['app.message_sliding_window'] }">
<b-field :label="$t('settings.performance.slidingWindowRate')" label-position="on-border"
:message="$t('settings.performance.slidingWindowRateHelp')">
<b-numberinput v-model="data['app.message_sliding_window_rate']"
name="sliding_window_rate" type="is-light"
controls-position="compact"
:disabled="!data['app.message_sliding_window']"
placeholder="25" min="1" max="10000000" />
<b-numberinput v-model="data['app.message_sliding_window_rate']" name="sliding_window_rate" type="is-light"
controls-position="compact" :disabled="!data['app.message_sliding_window']" placeholder="25" min="1"
max="10000000" />
</b-field>
</div>
<div class="column is-3"
:class="{'disabled': !data['app.message_sliding_window']}">
<b-field :label="$t('settings.performance.slidingWindowDuration')"
label-position="on-border"
<div class="column is-3" :class="{ disabled: !data['app.message_sliding_window'] }">
<b-field :label="$t('settings.performance.slidingWindowDuration')" label-position="on-border"
:message="$t('settings.performance.slidingWindowDurationHelp')">
<b-input v-model="data['app.message_sliding_window_duration']"
name="sliding_window_duration"
:disabled="!data['app.message_sliding_window']"
placeholder="1h" :pattern="regDuration" :maxlength="10" />
<b-input v-model="data['app.message_sliding_window_duration']" name="sliding_window_duration"
:disabled="!data['app.message_sliding_window']" placeholder="1h" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
</div>
@ -77,7 +61,7 @@ import { regDuration } from '../../constants';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -2,51 +2,35 @@
<div class="items">
<b-field :label="$t('settings.privacy.individualSubTracking')"
:message="$t('settings.privacy.individualSubTrackingHelp')">
<b-switch v-model="data['privacy.individual_tracking']"
name="privacy.individual_tracking" />
<b-switch v-model="data['privacy.individual_tracking']" name="privacy.individual_tracking" />
</b-field>
<b-field :label="$t('settings.privacy.listUnsubHeader')"
:message="$t('settings.privacy.listUnsubHeaderHelp')">
<b-switch v-model="data['privacy.unsubscribe_header']"
name="privacy.unsubscribe_header" />
<b-field :label="$t('settings.privacy.listUnsubHeader')" :message="$t('settings.privacy.listUnsubHeaderHelp')">
<b-switch v-model="data['privacy.unsubscribe_header']" name="privacy.unsubscribe_header" />
</b-field>
<b-field :label="$t('settings.privacy.allowBlocklist')"
:message="$t('settings.privacy.allowBlocklistHelp')">
<b-switch v-model="data['privacy.allow_blocklist']"
name="privacy.allow_blocklist" />
<b-field :label="$t('settings.privacy.allowBlocklist')" :message="$t('settings.privacy.allowBlocklistHelp')">
<b-switch v-model="data['privacy.allow_blocklist']" name="privacy.allow_blocklist" />
</b-field>
<b-field :label="$t('settings.privacy.allowPrefs')"
:message="$t('settings.privacy.allowPrefsHelp')">
<b-switch v-model="data['privacy.allow_preferences']"
name="privacy.allow_blocklist" />
<b-field :label="$t('settings.privacy.allowPrefs')" :message="$t('settings.privacy.allowPrefsHelp')">
<b-switch v-model="data['privacy.allow_preferences']" name="privacy.allow_blocklist" />
</b-field>
<b-field :label="$t('settings.privacy.allowExport')"
:message="$t('settings.privacy.allowExportHelp')">
<b-switch v-model="data['privacy.allow_export']"
name="privacy.allow_export" />
<b-field :label="$t('settings.privacy.allowExport')" :message="$t('settings.privacy.allowExportHelp')">
<b-switch v-model="data['privacy.allow_export']" name="privacy.allow_export" />
</b-field>
<b-field :label="$t('settings.privacy.allowWipe')"
:message="$t('settings.privacy.allowWipeHelp')">
<b-switch v-model="data['privacy.allow_wipe']"
name="privacy.allow_wipe" />
<b-field :label="$t('settings.privacy.allowWipe')" :message="$t('settings.privacy.allowWipeHelp')">
<b-switch v-model="data['privacy.allow_wipe']" name="privacy.allow_wipe" />
</b-field>
<b-field :label="$t('settings.privacy.recordOptinIP')"
:message="$t('settings.privacy.recordOptinIPHelp')">
<b-switch v-model="data['privacy.record_optin_ip']"
name="privacy.record_optin_ip" />
<b-field :label="$t('settings.privacy.recordOptinIP')" :message="$t('settings.privacy.recordOptinIPHelp')">
<b-switch v-model="data['privacy.record_optin_ip']" name="privacy.record_optin_ip" />
</b-field>
<b-field :label="$t('settings.privacy.domainBlocklist')"
:message="$t('settings.privacy.domainBlocklistHelp')">
<b-input type="textarea"
v-model="data['privacy.domain_blocklist']"
name="privacy.domain_blocklist" />
<b-field :label="$t('settings.privacy.domainBlocklist')" :message="$t('settings.privacy.domainBlocklistHelp')">
<b-input type="textarea" v-model="data['privacy.domain_blocklist']" name="privacy.domain_blocklist" />
</b-field>
</div>
</template>
@ -57,7 +41,7 @@ import Vue from 'vue';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -2,21 +2,19 @@
<div class="items">
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.security.enableCaptcha')"
:message="$t('settings.security.enableCaptchaHelp')">
<b-switch v-model="data['security.enable_captcha']"
name="security.captcha" />
<b-field :label="$t('settings.security.enableCaptcha')" :message="$t('settings.security.enableCaptchaHelp')">
<b-switch v-model="data['security.enable_captcha']" name="security.captcha" />
</b-field>
</div>
<div class="column is-8">
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
:message="$t('settings.security.captchaKeyHelp')">
<b-input v-model="data['security.captcha_key']" name="captcha_key"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
<b-input v-model="data['security.captcha_key']" name="captcha_key" :disabled="!data['security.enable_captcha']"
:maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
<b-input v-model="data['security.captcha_secret']" name="captcha_secret" type="password"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
</b-field>
</div>
</div>
@ -29,7 +27,7 @@ import Vue from 'vue';
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

View file

@ -5,77 +5,74 @@
<div class="columns">
<div class="column is-2">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" data-cy="btn-enable-smtp" />
<b-switch v-model="item.enabled" name="enabled" :native-value="true" data-cy="btn-enable-smtp" />
</b-field>
<b-field v-if="form.smtp.length > 1">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))"
href="#" data-cy="btn-delete-smtp">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))" href="#" data-cy="btn-delete-smtp">
<b-icon icon="trash-can-outline" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
</div><!-- first column -->
<div class="column" :class="{'disabled': !item.enabled}">
<div class="column" :class="{ disabled: !item.enabled }">
<div class="columns">
<div class="column is-8">
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
:message="$t('settings.mailserver.hostHelp')">
<b-input v-model="item.host" name="host"
placeholder='smtp.yourmailserver.net' :maxlength="200" />
<b-input v-model="item.host" name="host" placeholder="smtp.yourmailserver.net" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
:message="$t('settings.mailserver.portHelp')">
<b-numberinput v-model="item.port" name="port" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
<b-numberinput v-model="item.port" name="port" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
</div><!-- host -->
<div class="columns">
<div class="column is-2">
<b-field :label="$t('settings.mailserver.authProtocol')"
label-position="on-border">
<b-field :label="$t('settings.mailserver.authProtocol')" label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol">
<option value="login">LOGIN</option>
<option value="cram">CRAM</option>
<option value="plain">PLAIN</option>
<option value="none">None</option>
<option value="login">
LOGIN
</option>
<option value="cram">
CRAM
</option>
<option value="plain">
PLAIN
</option>
<option value="none">
None
</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.username')"
label-position="on-border" expanded>
<b-field :label="$t('settings.mailserver.username')" label-position="on-border" expanded>
<b-input v-model="item.username" :custom-class="`smtp-username-${n}`"
:disabled="item.auth_protocol === 'none'"
name="username" placeholder="mysmtp" :maxlength="200" />
:disabled="item.auth_protocol === 'none'" name="username" placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field :label="$t('settings.mailserver.password')"
label-position="on-border" expanded
<b-field :label="$t('settings.mailserver.password')" label-position="on-border" expanded
:message="$t('settings.mailserver.passwordHelp')">
<b-input v-model="item.password"
:disabled="item.auth_protocol === 'none'"
name="password" type="password"
:custom-class="`password-${n}`"
:placeholder="$t('settings.mailserver.passwordHelp')"
:maxlength="200" />
<b-input v-model="item.password" :disabled="item.auth_protocol === 'none'" name="password"
type="password" :custom-class="`password-${n}`"
:placeholder="$t('settings.mailserver.passwordHelp')" :maxlength="200" />
</b-field>
</b-field>
</div>
</div><!-- auth -->
<div class="smtp-shortcuts is-size-7">
<a href="" @click.prevent="() => fillSettings(n, 'gmail')">Gmail</a>
<a href="" @click.prevent="() => fillSettings(n, 'ses')">Amazon SES</a>
<a href="" @click.prevent="() => fillSettings(n, 'mailgun')">Mailgun</a>
<a href="" @click.prevent="() => fillSettings(n, 'mailjet')">Mailjet</a>
<a href="" @click.prevent="() => fillSettings(n, 'sendgrid')">Sendgrid</a>
<a href="" @click.prevent="() => fillSettings(n, 'postmark')">Postmark</a>
<a href="#" @click.prevent="() => fillSettings(n, 'gmail')">Gmail</a>
<a href="#" @click.prevent="() => fillSettings(n, 'ses')">Amazon SES</a>
<a href="#" @click.prevent="() => fillSettings(n, 'mailgun')">Mailgun</a>
<a href="#" @click.prevent="() => fillSettings(n, 'mailjet')">Mailjet</a>
<a href="#" @click.prevent="() => fillSettings(n, 'sendgrid')">Sendgrid</a>
<a href="#" @click.prevent="() => fillSettings(n, 'postmark')">Postmark</a>
</div>
<hr />
@ -83,24 +80,29 @@
<div class="column is-6">
<b-field :label="$t('settings.smtp.heloHost')" label-position="on-border"
:message="$t('settings.smtp.heloHostHelp')">
<b-input v-model="item.hello_hostname"
name="hello_hostname" placeholder="" :maxlength="200" />
<b-input v-model="item.hello_hostname" name="hello_hostname" placeholder="" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field :label="$t('settings.mailserver.tls')" expanded
:message="$t('settings.mailserver.tlsHelp')" label-position="on-border">
<b-field :label="$t('settings.mailserver.tls')" expanded :message="$t('settings.mailserver.tlsHelp')"
label-position="on-border">
<b-select v-model="item.tls_type" name="items.tls_type">
<option value="none">{{ $t('globals.states.off') }}</option>
<option value="STARTTLS">STARTTLS</option>
<option value="TLS">SSL/TLS</option>
<option value="none">
{{ $t('globals.states.off') }}
</option>
<option value="STARTTLS">
STARTTLS
</option>
<option value="TLS">
SSL/TLS
</option>
</b-select>
</b-field>
<b-field :label="$t('settings.mailserver.skipTLS')" expanded
:message="$t('settings.mailserver.skipTLSHelp')">
<b-switch v-model="item.tls_skip_verify"
:disabled="item.tls_type === 'none'" name="item.tls_skip_verify" />
<b-switch v-model="item.tls_skip_verify" :disabled="item.tls_type === 'none'"
name="item.tls_skip_verify" />
</b-field>
</b-field>
</div>
@ -109,37 +111,31 @@
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.mailserver.maxConns')"
label-position="on-border"
<b-field :label="$t('settings.mailserver.maxConns')" label-position="on-border"
:message="$t('settings.mailserver.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light" controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.smtp.retries')" label-position="on-border"
:message="$t('settings.smtp.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
type="is-light"
controls-position="compact"
placeholder="2" min="1" max="1000" />
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries" type="is-light"
controls-position="compact" placeholder="2" min="1" max="1000" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.mailserver.idleTimeout')"
label-position="on-border"
<b-field :label="$t('settings.mailserver.idleTimeout')" label-position="on-border"
:message="$t('settings.mailserver.idleTimeoutHelp')">
<b-input v-model="item.idle_timeout" name="idle_timeout"
placeholder="15s" :pattern="regDuration" :maxlength="10" />
<b-input v-model="item.idle_timeout" name="idle_timeout" placeholder="15s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.mailserver.waitTimeout')"
label-position="on-border"
<b-field :label="$t('settings.mailserver.waitTimeout')" label-position="on-border"
:message="$t('settings.mailserver.waitTimeoutHelp')">
<b-input v-model="item.wait_timeout" name="wait_timeout"
placeholder="5s" :pattern="regDuration" :maxlength="10" />
<b-input v-model="item.wait_timeout" name="wait_timeout" placeholder="5s" :pattern="regDuration"
:maxlength="10" />
</b-field>
</div>
</div>
@ -150,11 +146,10 @@
<a href="#" @click.prevent="() => showSMTPHeaders(n)">
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
</p>
<b-field v-if="item.email_headers.length > 0 || item.showHeaders"
label-position="on-border"
<b-field v-if="item.email_headers.length > 0 || item.showHeaders" label-position="on-border"
:message="$t('settings.smtp.customHeadersHelp')">
<b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />
placeholder="[{&quot;X-Custom&quot;: &quot;value&quot;}, {&quot;X-Custom2&quot;: &quot;value&quot;}]" />
</b-field>
</div>
</div>
@ -170,15 +165,13 @@
</div>
<div class="column is-4">
<b-field :label="$t('settings.smtp.toEmail')" label-position="on-border">
<b-input type="email" required v-model="testEmail"
:ref="'testEmailTo'" placeholder="email@site.com"
<b-input type="email" required v-model="testEmail" :ref="'testEmailTo'" placeholder="email@site.com"
:custom-class="`test-email-${n}`" />
</b-field>
</div>
</template>
<div class="column has-text-right">
<b-button v-if="smtpTestItem === n" class="is-primary"
@click.prevent="() => doSMTPTest(item, n)">
<b-button v-if="smtpTestItem === n" class="is-primary" @click.prevent="() => doSMTPTest(item, n)">
{{ $t('settings.smtp.sendTest') }}
</b-button>
<a href="#" v-else class="is-primary" @click.prevent="showTestForm(n)">
@ -186,18 +179,15 @@
</a>
</div>
<div class="columns">
<div class="column">
</div>
<div class="column" />
</div>
</div>
<div v-if="errMsg && smtpTestItem === n">
<b-field class="mt-4" type="is-danger">
<b-input v-model="errMsg" type="textarea"
custom-class="has-text-danger is-size-6" readonly />
<b-input v-model="errMsg" type="textarea" custom-class="has-text-danger is-size-6" readonly />
</b-field>
</div>
</form><!-- smtp test -->
</div>
</div><!-- second container column -->
</div><!-- block -->
@ -238,7 +228,7 @@ const smtpTemplates = {
export default Vue.extend({
props: {
form: {
type: Object,
type: Object, default: () => { },
},
},

37
frontend/vite.config.js vendored Normal file
View file

@ -0,0 +1,37 @@
import vue from '@vitejs/plugin-vue2';
import { defineConfig, loadEnv } from 'vite';
const path = require('path');
// https://vitejs.dev/config/
export default defineConfig(({ _, mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [vue()],
base: '/admin',
mode,
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
bulma: require.resolve('bulma/bulma.sass'),
},
},
build: {
assetsDir: 'static',
},
server: {
port: env.LISTMONK_FRONTEND_PORT || 8080,
proxy: {
'^/$': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},
'^/(api|webhooks|subscription|public|health)': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},
'^/(admin\/custom\.(css|js))': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},
},
},
};
});

View file

@ -1,40 +0,0 @@
module.exports = {
publicPath: '/admin',
outputDir: 'dist',
// This is to make all static file requests generated by Vue to go to
// /frontend/*. However, this also ends up creating a `dist/frontend`
// directory and moves all the static files in it. The physical directory
// and the URI for assets are tightly coupled. This is handled in the Go app
// by using stuffbin aliases.
assetsDir: 'static',
// Move the index.html file from dist/index.html to dist/frontend/index.html
// indexPath: './frontend/index.html',
productionSourceMap: false,
filenameHashing: true,
css: {
loaderOptions: {
sass: {
implementation: require('sass'), // This line must in sass option
},
},
},
devServer: {
port: process.env.LISTMONK_FRONTEND_PORT || 8080,
proxy: {
'^/$': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
},
'^/(api|webhooks|subscription|public|health)': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
},
'^/(admin\/custom\.(css|js))': {
target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000'
}
}
}
};

6432
frontend/yarn.lock vendored

File diff suppressed because it is too large Load diff