Merge branch 'vue'

This commit is contained in:
Kailash Nadh 2020-07-05 15:16:28 +05:30
commit 7f9a811897
74 changed files with 88849 additions and 6278 deletions

View File

@ -4,7 +4,12 @@ VERSION := $(shell git describe)
BUILDSTR := ${VERSION} (${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
BIN := listmonk
STATIC := config.toml.sample schema.sql queries.sql static/public:/public static/email-templates frontend/build:/frontend
STATIC := config.toml.sample \
schema.sql queries.sql \
static/public:/public \
static/email-templates \
frontend/dist:/frontend \
frontend/dist/frontend:/frontend
# Dependencies.
.PHONY: deps
@ -19,7 +24,7 @@ build:
.PHONY: build-frontend
build-frontend:
export REACT_APP_VERSION="${VERSION}" && cd frontend && yarn build
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn build
.PHONY: run
run: build
@ -27,7 +32,7 @@ run: build
.PHONY: run-frontend
run-frontend:
export REACT_APP_VERSION="${VERSION}" && cd frontend && yarn start
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn serve
.PHONY: test
test:

View File

@ -12,15 +12,10 @@ import (
type configScript struct {
RootURL string `json:"rootURL"`
UploadURI string `json:"uploadURI"`
FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"`
}
type dashboardStats struct {
Stats types.JSONText `db:"stats"`
}
// handleGetConfigScript returns general configuration as a Javascript
// variable that can be included in an HTML page directly.
func handleGetConfigScript(c echo.Context) error {
@ -41,17 +36,32 @@ func handleGetConfigScript(c echo.Context) error {
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
}
// handleGetDashboardStats returns general states for the dashboard.
func handleGetDashboardStats(c echo.Context) error {
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
func handleGetDashboardCharts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out dashboardStats
out types.JSONText
)
if err := app.queries.GetDashboardStats.Get(&out); err != nil {
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out.Stats})
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetDashboardCounts returns stats counts to show on the dashboard.
func handleGetDashboardCounts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out types.JSONText
)
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out})
}

View File

@ -301,7 +301,7 @@ func handleUpdateCampaign(c echo.Context) error {
o = c
}
res, err := app.queries.UpdateCampaign.Exec(cm.ID,
_, err := app.queries.UpdateCampaign.Exec(cm.ID,
o.Name,
o.Subject,
o.FromEmail,
@ -318,10 +318,6 @@ func handleUpdateCampaign(c echo.Context) error {
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return handleGetCampaigns(c)
}
@ -597,6 +593,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
}
}
if len(c.ListIDs) == 0 {
return c, errors.New("no lists selected")
}
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
return c, fmt.Errorf("Error compiling campaign body: %v", err)

5
frontend/.babelrc vendored
View File

@ -1,5 +0,0 @@
{
"presets": ["env", "react"],
"plugins": [["transform-react-jsx", { "pragma": "h" }]]
}

3
frontend/.browserslistrc vendored Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

7
frontend/.editorconfig vendored Normal file
View File

@ -0,0 +1,7 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

17
frontend/.eslintrc.js vendored Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/essential',
'@vue/airbnb',
],
parserOptions: {
parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
};

33
frontend/.gitignore vendored
View File

@ -1,21 +1,22 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
frontend/README.md vendored Normal file
View File

@ -0,0 +1,30 @@
# listmonk frontend (Vue + Buefy)
It's best if the `listmonk/frontend` directory is opened in an IDE as a separate project where the frontend directory is the root of the project.
For developer setup instructions, refer to the main project's README.
## Globals
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`) and `$utils` (util functions from `util.js`), are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.
Some constants are defined in `constants.js`.
## APIs and states
The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`.
There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses.
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually.
## Icon pack
Buefy by default uses [Material Design Icons](https://materialdesignicons.com) (MDI) with icon classes prefixed by `mdi-`.
listmonk uses only a handful of icons from the massive MDI set packed as web font, using [Fontello](https://fontello.com). To add more icons to the set using fontello:
- Go to Fontello and drag and drop `frontend/fontello/config.json` (This is the full MDI set converted from TTF to SVG icons to work with Fontello).
- Use the UI to search for icons and add them to the selection (add icons from under the `Custom` section)
- Download the Fontello pack and from the ZIP:
- Copy and overwrite `config.json` to `frontend/fontello`
- Copy `fontello.woff2` to `frontend/src/assets/icons`.
- Open `css/fontello.css` and copy the individual icon definitions and overwrite the ones in `frontend/src/assets/icons/fontello.css`

5
frontend/babel.config.js vendored Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};

View File

@ -1,31 +0,0 @@
const { injectBabelPlugin } = require("react-app-rewired")
const rewireLess = require("react-app-rewire-less")
module.exports = function override(config, env) {
config = injectBabelPlugin(
[
"import",
{
libraryName: "antd",
libraryDirectory: "es",
style: true
}
], // change importing css to less
config
)
config = rewireLess.withLoaderOptions({
modifyVars: {
"@font-family":
'"IBM Plex Sans", "Helvetica Neueue", "Segoe UI", "sans-serif"',
"@font-size-base": "15px",
"@primary-color": "#7f2aff",
"@shadow-1-up": "0 -2px 3px @shadow-color",
"@shadow-1-down": "0 2px 3px @shadow-color",
"@shadow-1-left": "-2px 0 3px @shadow-color",
"@shadow-1-right": "2px 0 3px @shadow-color",
"@shadow-2": "0 2px 6px @shadow-color"
},
javascriptEnabled: true
})(config, env)
return config
}

74840
frontend/fontello/config.json Normal file

File diff suppressed because it is too large Load Diff

59
frontend/package.json vendored
View File

@ -2,36 +2,41 @@
"name": "listmonk",
"version": "0.1.0",
"private": true,
"dependencies": {
"antd": "^3.6.5",
"axios": "^0.18.0",
"bizcharts": "^3.2.5-beta.4",
"dayjs": "^1.7.5",
"react": "^16.4.1",
"react-app-rewire-less": "^2.1.3",
"react-app-rewired": "^1.6.2",
"react-dom": "^16.4.1",
"react-quill": "^1.3.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-scripts": "1.1.4"
},
"scripts": {
"start": "react-app-rewired start",
"build": "GENERATE_SOURCEMAP=false PUBLIC_URL=/frontend/ react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-scripts eject"
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build-report": "vue-cli-service build --report",
"lint": "vue-cli-service lint"
},
"eslintConfig": {
"extends": "react-app"
"dependencies": {
"axios": "^0.19.2",
"buefy": "^0.8.20",
"c3": "^0.7.18",
"core-js": "^3.6.5",
"dayjs": "^1.8.28",
"humps": "^2.0.1",
"node-sass": "^4.14.1",
"qs": "^6.9.4",
"quill": "^1.3.7",
"quill-delta": "^4.2.2",
"sass-loader": "^8.0.2",
"vue": "^2.6.11",
"vue-c3": "^1.2.11",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
},
"devDependencies": {
"babel-plugin-import": "^1.11.0",
"eslint-plugin-prettier": "^3.0.1",
"less-plugin-npm-import": "^2.1.0",
"prettier": "1.15.3"
},
"prettier": {
"semi": false
"@vue/cli-plugin-babel": "~4.4.0",
"@vue/cli-plugin-eslint": "~4.4.0",
"@vue/cli-plugin-router": "~4.4.0",
"@vue/cli-plugin-vuex": "~4.4.0",
"@vue/cli-service": "~4.4.0",
"@vue/eslint-config-airbnb": "^5.0.2",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
}
}

View File

@ -1 +0,0 @@
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>

Before

Width:  |  Height:  |  Size: 244 B

View File

@ -1,21 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<script src="/api/config.js" type="text/javascript"></script>
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<title>listmonk</title>
<script>VERSION = "%REACT_APP_VERSION%";</script>
<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 %>frontend/favicon.png" />
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= BASE_URL %>api/config.js"></script>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="root"></div>
<div id="app"></div>
</body>
</html>

View File

@ -1,15 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,193 +0,0 @@
import React from "react"
import Utils from "./utils"
import { BrowserRouter } from "react-router-dom"
import { Icon, notification } from "antd"
import axios from "axios"
import qs from "qs"
import logo from "./static/listmonk.svg"
import Layout from "./Layout"
import * as cs from "./constants"
/*
App acts as a an "automagic" wrapper for all sub components. It is also the central
store for data required by various child components. In addition, all HTTP requests
are fired through App.requests(), where successful responses are set in App's state
for child components to access via this.props.data[type]. The structure is as follows:
App.state.data = {
"lists": [],
"subscribers": []
// etc.
}
A number of assumptions are made here for the "automagic" behaviour.
1. All responses to resources return lists
2. All PUT, POST, DELETE requests automatically append /:id to the API URIs.
*/
class App extends React.PureComponent {
models = [
cs.ModelUsers,
cs.ModelSubscribers,
cs.ModelLists,
cs.ModelCampaigns,
cs.ModelTemplates
]
state = {
// Initialize empty states.
reqStates: this.models.reduce(
// eslint-disable-next-line
(map, obj) => ((map[obj] = cs.StatePending), map),
{}
),
// eslint-disable-next-line
data: this.models.reduce((map, obj) => ((map[obj] = []), map), {}),
modStates: {}
}
componentDidMount = () => {
axios.defaults.paramsSerializer = params => {
return qs.stringify(params, { arrayFormat: "repeat" })
}
}
// modelRequest is an opinionated wrapper for model specific HTTP requests,
// including setting model states.
modelRequest = async (model, route, method, params) => {
let url = replaceParams(route, params)
this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StatePending }
})
try {
let req = {
method: method,
url: url
}
if (method === cs.MethodGet || method === cs.MethodDelete) {
req.params = params ? params : {}
} else {
req.data = params ? params : {}
}
let res = await axios(req)
this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
})
// If it's a GET call, set the response as the data state.
if (method === cs.MethodGet) {
this.setState({
data: { ...this.state.data, [model]: res.data.data }
})
}
return res
} catch (e) {
// If it's a GET call, throw a global notification.
if (method === cs.MethodGet) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error fetching data",
description: Utils.HttpError(e).message
})
}
// Set states and show the error on the layout.
this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
})
throw Utils.HttpError(e)
}
}
// request is a wrapper for generic HTTP requests.
request = async (url, method, params, headers) => {
url = replaceParams(url, params)
this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StatePending }
})
try {
let req = {
method: method,
url: url,
headers: headers ? headers : {}
}
if (method === cs.MethodGet || method === cs.MethodDelete) {
req.params = params ? params : {}
} else {
req.data = params ? params : {}
}
let res = await axios(req)
this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
})
return res
} catch (e) {
this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
})
throw Utils.HttpError(e)
}
}
pageTitle = title => {
document.title = title
}
render() {
if (!window.CONFIG) {
return (
<div className="broken">
<p>
<img src={logo} alt="listmonk logo" />
</p>
<hr />
<h1>
<Icon type="warning" /> Something's not right
</h1>
<p>
The app configuration could not be loaded. Please ensure that the
app is running and then refresh this page.
</p>
</div>
)
}
return (
<BrowserRouter>
<Layout
modelRequest={this.modelRequest}
request={this.request}
reqStates={this.state.reqStates}
pageTitle={this.pageTitle}
config={window.CONFIG}
data={this.state.data}
/>
</BrowserRouter>
)
}
}
function replaceParams(route, params) {
// Replace :params in the URL with params in the array.
let uriParams = route.match(/:([a-z0-9\-_]+)/gi)
if (uriParams && uriParams.length > 0) {
uriParams.forEach(p => {
let pName = p.slice(1) // Lose the ":" prefix
if (params && params.hasOwnProperty(pName)) {
route = route.replace(p, params[pName])
}
})
}
return route
}
export default App

135
frontend/src/App.vue Normal file
View File

@ -0,0 +1,135 @@
<template>
<div id="app">
<section class="sidebar">
<b-sidebar
type="is-white"
position="static"
mobile="reduce"
:fullheight="true"
:open="true"
:can-cancel="false"
>
<div>
<div class="logo">
<a href="/"><img class="full" src="@/assets/logo.svg"/></a>
<img class="favicon" src="@/assets/favicon.png"/>
<p class="is-size-7 has-text-grey version">{{ version }}</p>
</div>
<b-menu :accordion="false">
<b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link"
:active="activeItem.dashboard"
icon="view-dashboard-variant-outline" label="Dashboard">
</b-menu-item><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists"
icon="format-list-bulleted-square" label="Lists">
<b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists"
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
<b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms"
icon="newspaper-variant-outline" label="Forms"></b-menu-item>
</b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.subscribers"
icon="account-multiple" label="Subscribers">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers"
icon="account-multiple" label="All subscribers"></b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import"
icon="file-upload-outline" label="Import"></b-menu-item>
</b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns"
icon="rocket-launch-outline" label="Campaigns">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns"
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign"
icon="plus" label="Create new"></b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media"
icon="image-outline" label="Media"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates"
icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-item><!-- campaigns -->
<!-- <b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item> -->
</b-menu-list>
</b-menu>
</div>
</b-sidebar>
</section>
<!-- sidebar-->
<!-- body //-->
<div class="main">
<router-view :key="$route.fullPath" />
</div>
<b-loading v-if="!isLoaded" active>
<div class="has-text-centered">
<h1 class="title">Oops</h1>
<p>
Can't connect to the listmonk backend.<br />
Make sure it is running and refresh this page.
</p>
</div>
</b-loading>
</div>
</template>
<script>
import Vue from 'vue';
export default Vue.extend({
name: 'App',
data() {
return {
activeItem: {},
activeGroup: {},
isLoaded: window.CONFIG,
};
},
watch: {
$route(to) {
// Set the current route name to true for active+expanded keys in the
// menu to pick up.
this.activeItem = { [to.name]: true };
if (to.meta.group) {
this.activeGroup = { [to.meta.group]: true };
}
},
},
mounted() {
// Lists is required across different views. On app load, fetch the lists
// and have them in the store.
this.$api.getLists();
},
computed: {
version() {
return process.env.VUE_APP_VERSION;
},
},
});
</script>
<style lang="scss">
@import "assets/style.scss";
@import "assets/icons/fontello.css";
</style>

View File

@ -1,879 +0,0 @@
import React from "react"
import {
Modal,
Tabs,
Row,
Col,
Form,
Switch,
Select,
Radio,
Tag,
Input,
Button,
Icon,
Spin,
DatePicker,
Popconfirm,
notification
} from "antd"
import * as cs from "./constants"
import Media from "./Media"
import ModalPreview from "./ModalPreview"
import moment from "moment"
import parseUrl from "querystring"
import ReactQuill from "react-quill"
import Delta from "quill-delta"
import "react-quill/dist/quill.snow.css"
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 10 }, md: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 14 }, md: { span: 10 } }
}
class Editor extends React.PureComponent {
state = {
editor: null,
quill: null,
rawInput: null,
selContentType: cs.CampaignContentTypeRichtext,
contentType: cs.CampaignContentTypeRichtext,
body: ""
}
quillModules = {
toolbar: {
container: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote", "code"],
[{ color: [] }, { background: [] }, { size: [] }],
[
{ list: "ordered" },
{ list: "bullet" },
{ indent: "-1" },
{ indent: "+1" }
],
[
{ align: "" },
{ align: "center" },
{ align: "right" },
{ align: "justify" }
],
["link", "image"],
["clean", "font"]
],
handlers: {
image: () => {
this.props.toggleMedia()
}
}
}
}
componentDidMount = () => {
// The editor component will only load once the individual campaign metadata
// has loaded, i.e., record.body is guaranteed to be available here.
if (this.props.record && this.props.record.id) {
this.setState({
body: this.props.record.body,
contentType: this.props.record.content_type,
selContentType: this.props.record.content_type
})
}
}
// Custom handler for inserting images from the media popup.
insertMedia = uri => {
const quill = this.state.quill.getEditor()
let range = quill.getSelection(true)
quill.updateContents(
new Delta()
.retain(range.index)
.delete(range.length)
.insert({ image: uri }),
null
)
}
handleSelContentType = (_, e) => {
this.setState({ selContentType: e.props.value })
}
handleSwitchContentType = () => {
this.setState({ contentType: this.state.selContentType })
if (!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
return
}
// Switching from richtext to html.
let body = ""
if (this.state.selContentType === cs.CampaignContentTypeHTML) {
body = this.state.quill.editor.container.firstChild.innerHTML
// eslint-disable-next-line
this.state.rawInput.value = body
} else if (this.state.selContentType === cs.CampaignContentTypeRichtext) {
body = this.state.rawInput.value
this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
}
this.props.setContent(this.state.selContentType, body)
}
render() {
return (
<div>
<header className="header">
{!this.props.formDisabled && (
<Row>
<Col span={20}>
<div className="content-type">
<p>Content format</p>
<Select
name="content_type"
onChange={this.handleSelContentType}
style={{ minWidth: 200 }}
value={this.state.selContentType}
>
<Select.Option value={ cs.CampaignContentTypeRichtext }>Rich Text</Select.Option>
<Select.Option value={ cs.CampaignContentTypeHTML }>Raw HTML</Select.Option>
</Select>
{this.state.contentType !== this.state.selContentType && (
<div className="actions">
<Popconfirm
title="The content may lose its formatting. Are you sure?"
onConfirm={this.handleSwitchContentType}
>
<Button>
<Icon type="save" /> Switch format
</Button>
</Popconfirm>
</div>
)}
</div>
</Col>
<Col span={4} />
</Row>
)}
</header>
<ReactQuill
readOnly={this.props.formDisabled}
style={{
display: this.state.contentType === cs.CampaignContentTypeRichtext ? "block" : "none"
}}
modules={this.quillModules}
defaultValue={this.props.record.body}
ref={o => {
if (!o) {
return
}
this.setState({ quill: o })
document.querySelector(".ql-editor").focus()
}}
onChange={() => {
if (!this.state.quill) {
return
}
this.props.setContent(
this.state.contentType,
this.state.quill.editor.root.innerHTML
)
}}
/>
<Input.TextArea
readOnly={this.props.formDisabled}
placeholder="Your message here"
style={{
display: this.state.contentType === "html" ? "block" : "none"
}}
id="html-body"
rows={10}
autosize={{ minRows: 2, maxRows: 10 }}
defaultValue={this.props.record.body}
ref={o => {
if (!o) {
return
}
this.setState({ rawInput: o.textAreaRef })
}}
onChange={e => {
this.props.setContent(this.state.contentType, e.target.value)
}}
/>
</div>
)
}
}
class TheFormDef extends React.PureComponent {
state = {
editorVisible: false,
sendLater: false,
loading: false
}
componentWillReceiveProps(nextProps) {
// On initial load, toggle the send_later switch if the record
// has a "send_at" date.
if (nextProps.record.send_at === this.props.record.send_at) {
return
}
this.setState({
sendLater: nextProps.isSingle && nextProps.record.send_at !== null
})
}
validateEmail = (rule, value, callback) => {
if (!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
return callback("Format should be: Your Name <email@address.com>")
}
callback()
}
handleSendLater = e => {
this.setState({ sendLater: e })
}
// Handle create / edit form submission.
handleSubmit = cb => {
if (this.state.loading) {
return
}
if (!cb) {
// Set a fake callback.
cb = () => {}
}
this.props.form.validateFields((err, values) => {
if (err) {
return
}
if (!values.tags) {
values.tags = []
}
values.type = cs.CampaignTypeRegular
values.body = this.props.body
values.content_type = this.props.contentType
if (values.send_at) {
values.send_later = true
} else {
values.send_later = false
}
// Create a new campaign.
this.setState({ loading: true })
if (!this.props.isSingle) {
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.CreateCampaign,
cs.MethodPost,
values
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign created",
description: `"${values["name"]}" created`
})
this.props.route.history.push({
pathname: cs.Routes.ViewCampaign.replace(
":id",
resp.data.data.id
),
hash: "content-tab"
})
cb(true)
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ loading: false })
cb(false)
})
} else {
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.UpdateCampaign,
cs.MethodPut,
{
...values,
id: this.props.record.id
}
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign updated",
description: `"${values["name"]}" updated`
})
this.setState({ loading: false })
this.props.setRecord(resp.data.data)
cb(true)
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ loading: false })
cb(false)
})
}
})
}
handleTestCampaign = e => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
if (!values.tags) {
values.tags = []
}
values.id = this.props.record.id
values.body = this.props.body
values.content_type = this.props.contentType
this.setState({ loading: true })
this.props
.request(cs.Routes.TestCampaign, cs.MethodPost, values)
.then(resp => {
this.setState({ loading: false })
notification["success"]({
placement: cs.MsgPosition,
message: "Test sent",
description: `Test messages sent`
})
})
.catch(e => {
this.setState({ loading: false })
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
})
}
render() {
const { record } = this.props
const { getFieldDecorator } = this.props.form
let subLists = []
if (this.props.isSingle && record.lists) {
subLists = record.lists
.map(v => {
// Exclude deleted lists.
return v.id !== 0 ? v.id : null
})
.filter(v => v !== null)
} else if (this.props.route.location.search) {
// One or more list_id in the query params.
const p = parseUrl.parse(this.props.route.location.search.substring(1))
if (p.hasOwnProperty("list_id")) {
if(Array.isArray(p.list_id)) {
p.list_id.forEach(i => {
// eslint-disable-next-line radix
const id = parseInt(i)
if (id) {
subLists.push(id)
}
});
} else {
// eslint-disable-next-line radix
const id = parseInt(p.list_id)
if (id) {
subLists.push(id)
}
}
}
}
if (this.record) {
this.props.pageTitle(record.name + " / Campaigns")
} else {
this.props.pageTitle("New campaign")
}
return (
<div>
<Spin spinning={this.state.loading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", {
extra:
"This is internal and will not be visible to subscribers",
initialValue: record.name,
rules: [{ required: true }]
})(
<Input
disabled={this.props.formDisabled}
autoFocus
maxLength={200}
/>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Subject">
{getFieldDecorator("subject", {
initialValue: record.subject,
rules: [{ required: true }]
})(<Input disabled={this.props.formDisabled} maxLength={500} />)}
</Form.Item>
<Form.Item {...formItemLayout} label="From address">
{getFieldDecorator("from_email", {
initialValue: record.from_email
? record.from_email
: this.props.config.fromEmail,
rules: [{ required: true }, { validator: this.validateEmail }]
})(
<Input
disabled={this.props.formDisabled}
placeholder="Company Name <email@company.com>"
maxLength={200}
/>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="Lists"
extra="Lists to subscribe to"
>
{getFieldDecorator("lists", {
initialValue:
subLists.length > 0
? subLists
: this.props.data[cs.ModelLists].hasOwnProperty(
"results"
) && this.props.data[cs.ModelLists].results.length === 1
? [this.props.data[cs.ModelLists].results[0].id]
: undefined,
rules: [{ required: true }]
})(
<Select disabled={this.props.formDisabled} mode="multiple">
{this.props.data[cs.ModelLists].hasOwnProperty("results") &&
[...this.props.data[cs.ModelLists].results].map((v) =>
(record.type !== cs.CampaignTypeOptin || v.optin === cs.ListOptinDouble) && (
<Select.Option value={v["id"]} key={v["id"]}>
{v["name"]}
</Select.Option>
))}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Template" extra="Template">
{getFieldDecorator("template_id", {
initialValue: record.template_id
? record.template_id
: this.props.data[cs.ModelTemplates].length > 0
? this.props.data[cs.ModelTemplates].filter(
t => t.is_default
)[0].id
: undefined,
rules: [{ required: true }]
})(
<Select disabled={this.props.formDisabled}>
{this.props.data[cs.ModelTemplates].map((v, i) => (
<Select.Option value={v["id"]} key={v["id"]}>
{v["name"]}
</Select.Option>
))}
</Select>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="Tags"
extra="Hit Enter after typing a word to add multiple tags"
>
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={this.props.formDisabled} mode="tags" />
)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="Messenger"
style={{
display:
this.props.config.messengers.length === 1 ? "none" : "block"
}}
>
{getFieldDecorator("messenger", {
initialValue: record.messenger ? record.messenger : "email"
})(
<Radio.Group className="messengers">
{[...this.props.config.messengers].map((v, i) => (
<Radio disabled={this.props.formDisabled} value={v} key={v}>
{v}
</Radio>
))}
</Radio.Group>
)}
</Form.Item>
<hr />
<Form.Item {...formItemLayout} label="Send later?">
<Row>
<Col lg={4}>
{getFieldDecorator("send_later")(
<Switch
disabled={this.props.formDisabled}
checked={this.state.sendLater}
onChange={this.handleSendLater}
/>
)}
</Col>
<Col lg={20}>
{this.state.sendLater &&
getFieldDecorator("send_at", {
initialValue:
record && typeof record.send_at === "string"
? moment(record.send_at)
: moment(new Date())
.add(1, "days")
.startOf("day")
})(
<DatePicker
disabled={this.props.formDisabled}
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item>
{this.props.isSingle && (
<div>
<hr />
<Form.Item
{...formItemLayout}
label="Send test messages"
extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers."
>
{getFieldDecorator("subscribers")(
<Select mode="tags" style={{ width: "100%" }} />
)}
</Form.Item>
<Form.Item {...formItemLayout} label="&nbsp;" colon={false}>
<Button onClick={this.handleTestCampaign}>
<Icon type="mail" /> Send test
</Button>
</Form.Item>
</div>
)}
</Form>
</Spin>
</div>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Campaign extends React.PureComponent {
state = {
campaignID: this.props.route.match.params
? parseInt(this.props.route.match.params.campaignID, 10)
: 0,
record: {},
formRef: null,
contentType: cs.CampaignContentTypeRichtext,
previewRecord: null,
body: "",
currentTab: "form",
editor: null,
loading: true,
mediaVisible: false,
formDisabled: false
}
componentDidMount = () => {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
per_page: "all"
})
// Fetch templates.
this.props.modelRequest(
cs.ModelTemplates,
cs.Routes.GetTemplates,
cs.MethodGet
)
// Fetch campaign.
if (this.state.campaignID) {
this.fetchRecord(this.state.campaignID)
} else {
this.setState({ loading: false })
}
// Content tab?
if (document.location.hash === "#content-tab") {
this.setCurrentTab("content")
}
}
setRecord = r => {
this.setState({ record: r })
}
fetchRecord = id => {
this.props
.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id })
.then(r => {
const record = r.data.data
this.setState({ loading: false })
this.setRecord(record)
// The form for non draft and scheduled campaigns should be locked.
if (
record.status !== cs.CampaignStatusDraft &&
record.status !== cs.CampaignStatusScheduled
) {
this.setState({ formDisabled: true })
}
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
setContent = (contentType, body) => {
this.setState({ contentType: contentType, body: body })
}
toggleMedia = () => {
this.setState({ mediaVisible: !this.state.mediaVisible })
}
setCurrentTab = tab => {
this.setState({ currentTab: tab })
}
handlePreview = record => {
this.setState({ previewRecord: record })
}
render() {
return (
<section className="content campaign">
<Row gutter={[2, 16]}>
<Col span={24} md={12}>
{!this.state.record.id && <h1>Create a campaign</h1>}
{this.state.record.id && (
<div>
<h1>
<Tag
color={cs.CampaignStatusColors[this.state.record.status]}
>
{this.state.record.status}
</Tag>
{this.state.record.type === cs.CampaignStatusOptin && (
<Tag className="campaign-type" color="geekblue">
{this.state.record.type}
</Tag>
)}
{this.state.record.name}
</h1>
<span className="text-tiny text-grey">
ID {this.state.record.id} &mdash; UUID{" "}
{this.state.record.uuid}
</span>
</div>
)}
</Col>
<Col span={24} md={12} className="right header-action-break">
{!this.state.formDisabled && !this.state.loading && (
<div>
<Button
type="primary"
icon="save"
onClick={() => {
this.state.formRef.handleSubmit()
}}
>
{!this.state.record.id ? "Continue" : "Save changes"}
</Button>{" "}
{this.state.record.status === cs.CampaignStatusDraft &&
this.state.record.send_at && (
<Popconfirm
title="The campaign will start automatically at the scheduled date and time. Schedule now?"
onConfirm={() => {
this.state.formRef.handleSubmit(() => {
this.props.route.history.push({
pathname: cs.Routes.ViewCampaigns,
state: {
campaign: this.state.record,
campaignStatus: cs.CampaignStatusScheduled
}
})
})
}}
>
<Button icon="clock-circle" type="primary">
Schedule campaign
</Button>
</Popconfirm>
)}
{this.state.record.status === cs.CampaignStatusDraft &&
!this.state.record.send_at && (
<Popconfirm
title="Campaign properties cannot be changed once it starts. Save changes and start now?"
onConfirm={() => {
this.state.formRef.handleSubmit(() => {
this.props.route.history.push({
pathname: cs.Routes.ViewCampaigns,
state: {
campaign: this.state.record,
campaignStatus: cs.CampaignStatusRunning
}
})
})
}}
>
<Button icon="rocket" type="primary">
Start campaign
</Button>
</Popconfirm>
)}
</div>
)}
</Col>
</Row>
<br />
<Tabs
type="card"
activeKey={this.state.currentTab}
onTabClick={t => {
this.setState({ currentTab: t })
}}
>
<Tabs.TabPane tab="Campaign" key="form">
<Spin spinning={this.state.loading}>
<TheForm
{...this.props}
wrappedComponentRef={r => {
if (!r) {
return
}
// Take the editor's reference and save it in the state
// so that it's insertMedia() function can be passed to <Media />
this.setState({ formRef: r })
}}
record={this.state.record}
setRecord={this.setRecord}
isSingle={this.state.record.id ? true : false}
body={
this.state.body ? this.state.body : this.state.record.body
}
contentType={this.state.contentType}
formDisabled={this.state.formDisabled}
fetchRecord={this.fetchRecord}
setCurrentTab={this.setCurrentTab}
/>
</Spin>
</Tabs.TabPane>
<Tabs.TabPane
tab="Content"
disabled={this.state.record.id ? false : true}
key="content"
>
{this.state.record.id && (
<div>
<Editor
{...this.props}
ref={r => {
if (!r) {
return
}
// Take the editor's reference and save it in the state
// so that it's insertMedia() function can be passed to <Media />
this.setState({ editor: r })
}}
isSingle={this.state.record.id ? true : false}
record={this.state.record}
visible={this.state.editorVisible}
toggleMedia={this.toggleMedia}
setContent={this.setContent}
formDisabled={this.state.formDisabled}
/>
<div className="content-actions">
<p>
<Button
icon="search"
onClick={() => this.handlePreview(this.state.record)}
>
Preview
</Button>
</p>
</div>
</div>
)}
{!this.state.record.id && <Spin className="empty-spinner" />}
</Tabs.TabPane>
</Tabs>
<Modal
visible={this.state.mediaVisible}
width="900px"
title="Media"
okText={"Ok"}
onCancel={this.toggleMedia}
onOk={this.toggleMedia}
>
<Media
{...{
...this.props,
insertMedia: this.state.editor
? this.state.editor.insertMedia
: null,
onCancel: this.toggleMedia,
onOk: this.toggleMedia
}}
/>
</Modal>
{this.state.previewRecord && (
<ModalPreview
title={this.state.previewRecord.name}
body={this.state.body}
previewURL={cs.Routes.PreviewCampaign.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => {
this.setState({ previewRecord: null })
}}
/>
)}
</section>
)
}
}
export default Campaign

View File

@ -1,747 +0,0 @@
import React from "react"
import { Link } from "react-router-dom"
import {
Row,
Col,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Progress,
Modal,
notification,
Input
} from "antd"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import ModalPreview from "./ModalPreview"
import * as cs from "./constants"
class Campaigns extends React.PureComponent {
defaultPerPage = 20
state = {
formType: null,
pollID: -1,
queryParams: {},
stats: {},
record: null,
previewRecord: null,
cloneName: "",
cloneModalVisible: false,
modalWaiting: false
}
// Pagination config.
paginationOptions = {
hideOnSinglePage: false,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"],
position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`
}
constructor(props) {
super(props)
this.columns = [
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "20%",
vAlign: "top",
filterIcon: filtered => (
<Icon
type="search"
style={{ color: filtered ? "#1890ff" : undefined }}
/>
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) => (
<div style={{ padding: 8 }}>
<Input
ref={node => {
this.searchInput = node
}}
placeholder={`Search`}
onChange={e =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 188, marginBottom: 8, display: "block" }}
/>
<Button
type="primary"
onClick={() => confirm()}
icon="search"
size="small"
style={{ width: 90, marginRight: 8 }}
>
Search
</Button>
<Button
onClick={() => {
clearFilters()
}}
size="small"
style={{ width: 90 }}
>
Reset
</Button>
</div>
),
render: (text, record) => {
const out = []
out.push(
<div className="name" key={`name-${record.id}`}>
<Link to={`/campaigns/${record.id}`}>{text}</Link>{" "}
{record.type === cs.CampaignStatusOptin && (
<Tooltip title="Opt-in campaign" placement="top">
<Tag className="campaign-type" color="geekblue">
{record.type}
</Tag>
</Tooltip>
)}
<br />
<span className="text-tiny">{record.subject}</span>
</div>
)
if (record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
}
}
return out
}
},
{
title: "Status",
dataIndex: "status",
className: "status",
width: "10%",
filters: [
{ text: "Draft", value: "draft" },
{ text: "Running", value: "running" },
{ text: "Scheduled", value: "scheduled" },
{ text: "Paused", value: "paused" },
{ text: "Cancelled", value: "cancelled" },
{ text: "Finished", value: "finished" }
],
render: (status, record) => {
let color = cs.CampaignStatusColors.hasOwnProperty(status)
? cs.CampaignStatusColors[status]
: ""
return (
<div>
<Tag color={color}>{status}</Tag>
{record.send_at && (
<span className="text-tiny date">
Scheduled &mdash;{" "}
{dayjs(record.send_at).format(cs.DateFormat)}
</span>
)}
</div>
)
}
},
{
title: "Lists",
dataIndex: "lists",
width: "25%",
align: "left",
className: "lists",
render: (lists, record) => {
const out = []
lists.forEach(l => {
out.push(
<Tag className="name" key={`name-${l.id}`}>
<Link to={`/subscribers/lists/${l.id}`}>{l.name}</Link>
</Tag>
)
})
return out
}
},
{
title: "Stats",
className: "stats",
width: "30%",
render: (text, record) => {
if (
record.status !== cs.CampaignStatusDraft &&
record.status !== cs.CampaignStatusScheduled
) {
return this.renderStats(record)
}
}
},
{
title: "",
dataIndex: "actions",
className: "actions",
width: "15%",
render: (text, record) => {
return (
<div className="actions">
{record.status === cs.CampaignStatusPaused && (
<Popconfirm
title="Are you sure?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
}
>
<Tooltip title="Resume campaign" placement="bottom">
<a role="button">
<Icon type="rocket" />
</a>
</Tooltip>
</Popconfirm>
)}
{record.status === cs.CampaignStatusRunning && (
<Popconfirm
title="Are you sure?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusPaused)
}
>
<Tooltip title="Pause campaign" placement="bottom">
<a role="button">
<Icon type="pause-circle-o" />
</a>
</Tooltip>
</Popconfirm>
)}
{/* Draft with send_at */}
{record.status === cs.CampaignStatusDraft && record.send_at && (
<Popconfirm
title="The campaign will start automatically at the scheduled date and time. Schedule now?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusScheduled)
}
>
<Tooltip title="Schedule campaign" placement="bottom">
<a role="button">
<Icon type="clock-circle" />
</a>
</Tooltip>
</Popconfirm>
)}
{record.status === cs.CampaignStatusDraft && !record.send_at && (
<Popconfirm
title="Campaign properties cannot be changed once it starts. Start now?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
}
>
<Tooltip title="Start campaign" placement="bottom">
<a role="button">
<Icon type="rocket" />
</a>
</Tooltip>
</Popconfirm>
)}
{(record.status === cs.CampaignStatusPaused ||
record.status === cs.CampaignStatusRunning) && (
<Popconfirm
title="Are you sure?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusCancelled)
}
>
<Tooltip title="Cancel campaign" placement="bottom">
<a role="button">
<Icon type="close-circle-o" />
</a>
</Tooltip>
</Popconfirm>
)}
<Tooltip title="Preview campaign" placement="bottom">
<a
role="button"
onClick={() => {
this.handlePreview(record)
}}
>
<Icon type="search" />
</a>
</Tooltip>
<Tooltip title="Clone campaign" placement="bottom">
<a
role="button"
onClick={() => {
let r = {
...record,
lists: record.lists.map(i => {
return i.id
})
}
this.handleToggleCloneForm(r)
}}
>
<Icon type="copy" />
</a>
</Tooltip>
{(record.status === cs.CampaignStatusDraft ||
record.status === cs.CampaignStatusScheduled) && (
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete campaign" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
)}
</div>
)
}
}
]
}
progressPercent(record) {
return Math.round(
(this.getStatsField("sent", record) /
this.getStatsField("to_send", record)) *
100,
2
)
}
isDone(record) {
return (
this.getStatsField("status", record) === cs.CampaignStatusFinished ||
this.getStatsField("status", record) === cs.CampaignStatusCancelled
)
}
// getStatsField returns a stats field value of a given record if it
// exists in the stats state, or the value from the record itself.
getStatsField = (field, record) => {
if (this.state.stats.hasOwnProperty(record.id)) {
return this.state.stats[record.id][field]
}
return record[field]
}
renderStats = record => {
let color = cs.CampaignStatusColors.hasOwnProperty(record.status)
? cs.CampaignStatusColors[record.status]
: ""
const startedAt = this.getStatsField("started_at", record)
const updatedAt = this.getStatsField("updated_at", record)
const sent = this.getStatsField("sent", record)
const toSend = this.getStatsField("to_send", record)
const isDone = this.isDone(record)
const r = this.getStatsField("rate", record)
const rate = r ? r : 0
return (
<div>
{!isDone && (
<Progress
strokeColor={color}
status="active"
type="line"
percent={this.progressPercent(record)}
/>
)}
<Row>
<Col className="label" span={10}>
Sent
</Col>
<Col span={12}>
{sent >= toSend && <span>{toSend}</span>}
{sent < toSend && (
<span>
{sent} / {toSend}
</span>
)}
&nbsp;
{record.status === cs.CampaignStatusRunning && (
<Icon type="loading" style={{ fontSize: 12 }} spin />
)}
</Col>
</Row>
{rate > 0 && (
<Row>
<Col className="label" span={10}>
Rate
</Col>
<Col span={12}>{Math.round(rate, 2)} / min</Col>
</Row>
)}
<Row>
<Col className="label" span={10}>
Views
</Col>
<Col span={12}>{record.views}</Col>
</Row>
<Row>
<Col className="label" span={10}>
Clicks
</Col>
<Col span={12}>{record.clicks}</Col>
</Row>
<br />
<Row>
<Col className="label" span={10}>
Created
</Col>
<Col span={12}>{dayjs(record.created_at).format(cs.DateFormat)}</Col>
</Row>
{startedAt && (
<Row>
<Col className="label" span={10}>
Started
</Col>
<Col span={12}>{dayjs(startedAt).format(cs.DateFormat)}</Col>
</Row>
)}
{isDone && (
<Row>
<Col className="label" span={10}>
Ended
</Col>
<Col span={12}>{dayjs(updatedAt).format(cs.DateFormat)}</Col>
</Row>
)}
{startedAt && updatedAt && (
<Row>
<Col className="label" span={10}>
Duration
</Col>
<Col className="duration" span={12}>
{dayjs(updatedAt).from(dayjs(startedAt), true)}
</Col>
</Row>
)}
</div>
)
}
componentDidMount() {
this.props.pageTitle("Campaigns")
dayjs.extend(relativeTime)
this.fetchRecords()
// Did we land here to start a campaign?
let loc = this.props.route.location
let state = loc.state
if (state && state.hasOwnProperty("campaign")) {
this.handleUpdateStatus(state.campaign, state.campaignStatus)
delete state.campaign
delete state.campaignStatus
this.props.route.history.replace({ ...loc, state })
}
}
componentWillUnmount() {
window.clearInterval(this.state.pollID)
}
fetchRecords = params => {
if (!params) {
params = {}
}
let qParams = {
page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page
}
// Avoid sending blank string where the enum check will fail.
if (!params.status) {
delete params.status
}
if (params) {
qParams = { ...qParams, ...params }
}
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.GetCampaigns,
cs.MethodGet,
qParams
)
.then(r => {
this.setState({
queryParams: {
...this.state.queryParams,
total: this.props.data[cs.ModelCampaigns].total,
per_page: this.props.data[cs.ModelCampaigns].per_page,
page: this.props.data[cs.ModelCampaigns].page,
query: this.props.data[cs.ModelCampaigns].query,
status: params.status
}
})
this.startStatsPoll()
})
}
startStatsPoll = () => {
window.clearInterval(this.state.pollID)
this.setState({ stats: {} })
// If there's at least one running campaign, start polling.
let hasRunning = false
this.props.data[cs.ModelCampaigns].results.forEach(c => {
if (c.status === cs.CampaignStatusRunning) {
hasRunning = true
return
}
})
if (!hasRunning) {
return
}
// Poll for campaign stats.
let pollID = window.setInterval(() => {
this.props
.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet)
.then(r => {
// No more running campaigns.
if (r.data.data.length === 0) {
window.clearInterval(this.state.pollID)
this.fetchRecords()
return
}
let stats = {}
r.data.data.forEach(s => {
stats[s.id] = s
})
this.setState({ stats: stats })
})
.catch(e => {
console.log(e.message)
})
}, 3000)
this.setState({ pollID: pollID })
}
handleUpdateStatus = (record, status) => {
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.UpdateCampaignStatus,
cs.MethodPut,
{ id: record.id, status: status }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: `Campaign ${status}`,
description: `"${record.name}" ${status}`
})
// Reload the table.
this.fetchRecords()
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
handleDeleteRecord = record => {
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.DeleteCampaign,
cs.MethodDelete,
{ id: record.id }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign deleted",
description: `"${record.name}" deleted`
})
// Reload the table.
this.fetchRecords()
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
handleToggleCloneForm = record => {
this.setState({
cloneModalVisible: !this.state.cloneModalVisible,
record: record,
cloneName: record.name
})
}
handleCloneCampaign = record => {
this.setState({ modalWaiting: true })
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.CreateCampaign,
cs.MethodPost,
record
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign created",
description: `${record.name} created`
})
this.setState({ record: null, modalWaiting: false })
this.props.route.history.push(
cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)
)
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false })
})
}
handlePreview = record => {
this.setState({ previewRecord: record })
}
render() {
const pagination = {
...this.paginationOptions,
...this.state.queryParams
}
return (
<section className="content campaigns">
<Row>
<Col xs={24} sm={14}>
<h1>Campaigns</h1>
</Col>
<Col xs={24} sm={10} className="right header-action-break">
<Link to="/campaigns/new">
<Button type="primary" icon="plus" role="link">
New campaign
</Button>
</Link>
</Col>
</Row>
<br />
<Table
className="campaigns"
columns={this.columns}
rowKey={record => record.uuid}
dataSource={(() => {
if (
!this.props.data[cs.ModelCampaigns] ||
!this.props.data[cs.ModelCampaigns].hasOwnProperty("results")
) {
return []
}
return this.props.data[cs.ModelCampaigns].results
})()}
loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone}
pagination={pagination}
onChange={(pagination, filters, sorter, records) => {
this.fetchRecords({
per_page: pagination.pageSize,
page: pagination.current,
status:
filters.status && filters.status.length > 0
? filters.status
: "",
query:
filters.name && filters.name.length > 0 ? filters.name[0] : ""
})
}}
/>
{this.state.previewRecord && (
<ModalPreview
title={this.state.previewRecord.name}
previewURL={cs.Routes.PreviewCampaign.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => {
this.setState({ previewRecord: null })
}}
/>
)}
{this.state.cloneModalVisible && this.state.record && (
<Modal
visible={this.state.record !== null}
width="500px"
className="clone-campaign-modal"
title={"Clone " + this.state.record.name}
okText="Clone"
confirmLoading={this.state.modalWaiting}
onCancel={this.handleToggleCloneForm}
onOk={() => {
this.handleCloneCampaign({
...this.state.record,
name: this.state.cloneName
})
}}
>
<Input
autoFocus
defaultValue={this.state.record.name}
style={{ width: "100%" }}
onChange={e => {
this.setState({ cloneName: e.target.value })
}}
/>
</Modal>
)}
</section>
)
}
}
export default Campaigns

View File

@ -1,26 +0,0 @@
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
height: 150px;
padding: 20px;
}
.App-title {
font-size: 1.5em;
}
.App-intro {
font-size: large;
}
@keyframes App-logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@ -1,190 +0,0 @@
import { Col, Row, notification, Card, Spin } from "antd"
import React from "react"
import { Chart, Geom, Tooltip as BizTooltip } from "bizcharts"
import * as cs from "./constants"
class Dashboard extends React.PureComponent {
state = {
stats: null,
loading: true
}
campaignTypes = [
"running",
"finished",
"paused",
"draft",
"scheduled",
"cancelled"
]
componentDidMount = () => {
this.props.pageTitle("Dashboard")
this.props
.request(cs.Routes.GetDashboarcStats, cs.MethodGet)
.then(resp => {
this.setState({ stats: resp.data.data, loading: false })
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ loading: false })
})
}
orZero(v) {
return v ? v : 0
}
render() {
return (
<section className="dashboard">
<h1>Welcome</h1>
<hr />
<Spin spinning={this.state.loading}>
{this.state.stats && (
<div className="stats">
<Row>
<Col xs={24} sm={24} xl={16}>
<Row gutter={24}>
<Col xs={24} sm={12} md={8}>
<Card title="Active subscribers" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.subscribers.enabled)}
</h1>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card title="Blacklisted subscribers" bordered={false}>
<h1 className="count">
{this.orZero(
this.state.stats.subscribers.blacklisted
)}
</h1>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card title="Orphaned subscribers" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.orphan_subscribers)}
</h1>
</Card>
</Col>
</Row>
</Col>
<Col xs={24} sm={24} xl={{ span: 6, offset: 2 }}>
<Row gutter={24}>
<Col xs={24} sm={12}>
<Card title="Public lists" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.lists.public)}
</h1>
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="Private lists" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.lists.private)}
</h1>
</Card>
</Col>
</Row>
</Col>
</Row>
<hr />
<Row>
<Col xs={24} sm={24} xl={16}>
<Row gutter={24}>
<Col xs={24} sm={12}>
<Card
title="Campaign views (last 3 months)"
bordered={false}
>
<h1 className="count">
{this.state.stats.campaign_views.reduce(
(total, v) => total + v.count,
0
)}{" "}
views
</h1>
<Chart
height={220}
padding={[0, 0, 0, 0]}
data={this.state.stats.campaign_views}
forceFit
>
<BizTooltip crosshairs={{ type: "y" }} />
<Geom
type="area"
position="date*count"
size={0}
color="#7f2aff"
/>
<Geom type="point" position="date*count" size={0} />
</Chart>
</Card>
</Col>
<Col xs={24} sm={12}>
<Card
title="Link clicks (last 3 months)"
bordered={false}
>
<h1 className="count">
{this.state.stats.link_clicks.reduce(
(total, v) => total + v.count,
0
)}{" "}
clicks
</h1>
<Chart
height={220}
padding={[0, 0, 0, 0]}
data={this.state.stats.link_clicks}
forceFit
>
<BizTooltip crosshairs={{ type: "y" }} />
<Geom
type="area"
position="date*count"
size={0}
color="#7f2aff"
/>
<Geom type="point" position="date*count" size={0} />
</Chart>
</Card>
</Col>
</Row>
</Col>
<Col xs={24} sm={12} xl={{ span: 6, offset: 2 }}>
<Card
title="Campaigns"
bordered={false}
className="campaign-counts"
>
{this.campaignTypes.map(key => (
<Row key={`stats-campaigns-${key}`}>
<Col span={18}>
<h1 className="name">{key}</h1>
</Col>
<Col span={6}>
<h1 className="count">
{this.state.stats.campaigns.hasOwnProperty(key)
? this.state.stats.campaigns[key]
: 0}
</h1>
</Col>
</Row>
))}
</Card>
</Col>
</Row>
</div>
)}
</Spin>
</section>
)
}
}
export default Dashboard

View File

@ -1,139 +0,0 @@
import React from "react"
import {
Row,
Col,
Checkbox,
} from "antd"
import * as cs from "./constants"
class Forms extends React.PureComponent {
state = {
lists: [],
selected: [],
selectedUUIDs: [],
indeterminate: false,
checkAll: false
}
componentDidMount() {
this.props.pageTitle("Subscription forms")
this.props
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
per_page: "all"
})
.then(() => {
this.setState({ lists: this.props.data[cs.ModelLists].results })
})
}
handleSelectAll = e => {
const uuids = this.state.lists.map(l => l.uuid)
this.setState({
selectedUUIDs: e.target.checked ? uuids : [],
indeterminate: false,
checkAll: e.target.checked
})
this.handleSelection(e.target.checked ? uuids : [])
}
handleSelection(sel) {
let out = []
sel.forEach(s => {
const item = this.state.lists.find(l => {
return l.uuid === s
})
if (item) {
out.push(item)
}
})
this.setState({
selected: out,
selectedUUIDs: sel,
indeterminate: sel.length > 0 && sel.length < this.state.lists.length,
checkAll: sel.length === this.state.lists.length
})
}
render() {
return (
<section className="content list-form">
<h1>Subscription forms</h1>
<hr />
<Row gutter={[16, 40]}>
<Col span={24} md={8}>
<h2>Lists</h2>
<Checkbox
indeterminate={this.state.indeterminate}
onChange={this.handleSelectAll}
checked={this.state.checkAll}
>
Select all
</Checkbox>
<hr />
<Checkbox.Group
className="lists"
options={this.state.lists.map(l => {
return { label: l.name, value: l.uuid }
})}
onChange={sel => this.handleSelection(sel)}
value={this.state.selectedUUIDs}
/>
</Col>
<Col span={24} md={16}>
<h2>Form HTML</h2>
<p>
Use the following HTML to show a subscription form on an external
webpage.
</p>
<p>
The form should have the{" "}
<code>
<strong>email</strong>
</code>{" "}
field and one or more{" "}
<code>
<strong>l</strong>
</code>{" "}
(list UUID) fields. The{" "}
<code>
<strong>name</strong>
</code>{" "}
field is optional.
</p>
<pre className="html">
{`<form method="post" action="${
window.CONFIG.rootURL
}/subscription/form" class="listmonk-form">
<div>
<h3>Subscribe</h3>
<p><input type="text" name="email" placeholder="E-mail" /></p>
<p><input type="text" name="name" placeholder="Name (optional)" /></p>`}
{(() => {
let out = []
this.state.selected.forEach(l => {
out.push(`
<p>
<input type="checkbox" name="l" value="${
l.uuid
}" id="${l.uuid.substr(0, 5)}" />
<label for="${l.uuid.substr(0, 5)}">${l.name}</label>
</p>`)
})
return out
})()}
{`
<p><input type="submit" value="Subscribe" /></p>
</div>
</form>
`}
</pre>
</Col>
</Row>
</section>
)
}
}
export default Forms

View File

@ -1,469 +0,0 @@
import React from "react"
import {
Row,
Col,
Form,
Select,
Input,
Upload,
Button,
Radio,
Icon,
Spin,
Progress,
Popconfirm,
Tag,
notification
} from "antd"
import * as cs from "./constants"
const StatusNone = "none"
const StatusImporting = "importing"
const StatusStopping = "stopping"
const StatusFinished = "finished"
const StatusFailed = "failed"
class TheFormDef extends React.PureComponent {
state = {
confirmDirty: false,
fileList: [],
formLoading: false,
mode: "subscribe"
}
componentDidMount() {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
per_page: "all"
})
}
// Handle create / edit form submission.
handleSubmit = e => {
e.preventDefault()
var err = null,
values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if (err) {
return
}
if (this.state.fileList.length < 1) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: "Select a valid file to upload"
})
return
}
this.setState({ formLoading: true })
let params = new FormData()
params.set("params", JSON.stringify(values))
params.append("file", this.state.fileList[0])
this.props
.request(cs.Routes.UploadRouteImport, cs.MethodPost, params)
.then(() => {
notification["info"]({
placement: cs.MsgPosition,
message: "File uploaded",
description: "Please wait while the import is running"
})
this.props.fetchimportState()
this.setState({ formLoading: false })
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.props.fetchimportState()
this.setState({ formLoading: false })
})
}
handleConfirmBlur = e => {
const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
}
onFileChange = f => {
let fileList = [f]
this.setState({ fileList })
return false
}
render() {
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { sm: { span: 24 }, md: { span: 5 } },
wrapperCol: { sm: { span: 24 }, md: { span: 10 } }
}
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
return (
<Spin spinning={this.state.formLoading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Mode">
{getFieldDecorator("mode", {
rules: [{ required: true }],
initialValue: "subscribe"
})(
<Radio.Group
className="mode"
onChange={e => {
this.setState({ mode: e.target.value })
}}
>
<Radio disabled={this.props.formDisabled} value="subscribe">
Subscribe
</Radio>
<Radio disabled={this.props.formDisabled} value="blacklist">
Blacklist
</Radio>
</Radio.Group>
)}
</Form.Item>
{this.state.mode === "subscribe" && (
<React.Fragment>
<Form.Item
{...formItemLayout}
label="Lists"
extra="Lists to subscribe to"
>
{getFieldDecorator("lists", { rules: [{ required: true }] })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) => (
<Select.Option value={v["id"]} key={v["id"]}>
{v["name"]}
</Select.Option>
))}
</Select>
)}
</Form.Item>
</React.Fragment>
)}
{this.state.mode === "blacklist" && (
<Form.Item {...formItemTailLayout}>
<p className="ant-form-extra">
All existing subscribers found in the import will be marked as
'blacklisted' and will be unsubscribed from their existing
subscriptions. New subscribers will be imported and marked as
'blacklisted'.
</p>
</Form.Item>
)}
<Form.Item
{...formItemLayout}
label="CSV delimiter"
extra="Default delimiter is comma"
>
{getFieldDecorator("delim", {
initialValue: ","
})(<Input maxLength={1} style={{ maxWidth: 40 }} />)}
</Form.Item>
<Form.Item {...formItemLayout} label="CSV or ZIP file">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
getValueFromEvent: this.normFile,
rules: [{ required: true }]
})(
<Upload.Dragger
name="files"
multiple={false}
fileList={this.state.fileList}
beforeUpload={this.onFileChange}
accept=".zip,.csv"
>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">
Click or drag a CSV or ZIP file here
</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
<Form.Item {...formItemTailLayout}>
<p className="ant-form-extra">
For existing subscribers, the names and attributes will be
overwritten with the values in the CSV.
</p>
<Button type="primary" htmlType="submit">
<Icon type="upload" /> Upload
</Button>
</Form.Item>
</Form>
</Spin>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Importing extends React.PureComponent {
state = {
pollID: -1,
logs: ""
}
stopImport = () => {
// Get the import status.
this.props
.request(cs.Routes.UploadRouteImport, cs.MethodDelete)
.then(r => {
this.props.fetchimportState()
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
componentDidMount() {
// Poll for stats until it's finished or failed.
let pollID = window.setInterval(() => {
this.props.fetchimportState()
this.fetchLogs()
if (
this.props.importState.status === StatusFinished ||
this.props.importState.status === StatusFailed
) {
window.clearInterval(this.state.pollID)
}
}, 1000)
this.setState({ pollID: pollID })
}
componentWillUnmount() {
window.clearInterval(this.state.pollID)
}
fetchLogs() {
this.props
.request(cs.Routes.GetRouteImportLogs, cs.MethodGet)
.then(r => {
this.setState({ logs: r.data.data })
let t = document.querySelector("#log-textarea")
t.scrollTop = t.scrollHeight
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
render() {
let progressPercent = 0
if (this.props.importState.status === StatusFinished) {
progressPercent = 100
} else {
progressPercent = Math.floor(
(this.props.importState.imported / this.props.importState.total) * 100
)
}
return (
<section className="content import">
<h1>Importing &mdash; {this.props.importState.name}</h1>
{this.props.importState.status === StatusImporting && (
<p>
Import is in progress. It is safe to navigate away from this page.
</p>
)}
{this.props.importState.status !== StatusImporting && (
<p>Import has finished.</p>
)}
<Row className="import-container">
<Col span={10} offset={3}>
<div className="stats center">
<div>
<Progress type="line" percent={progressPercent} />
</div>
<div>
<h3>{this.props.importState.imported} records</h3>
<br />
{this.props.importState.status === StatusImporting && (
<Popconfirm
title="Are you sure?"
onConfirm={() => this.stopImport()}
>
<p>
<Icon type="loading" />
</p>
<Button type="primary">Stop import</Button>
</Popconfirm>
)}
{this.props.importState.status === StatusStopping && (
<div>
<p>
<Icon type="loading" />
</p>
<h4>Stopping</h4>
</div>
)}
{this.props.importState.status !== StatusImporting &&
this.props.importState.status !== StatusStopping && (
<div>
{this.props.importState.status !== StatusFinished && (
<div>
<Tag color="red">{this.props.importState.status}</Tag>
<br />
</div>
)}
<br />
<Button type="primary" onClick={() => this.stopImport()}>
Done
</Button>
</div>
)}
</div>
</div>
<div className="logs">
<h3>Import log</h3>
<Spin spinning={this.state.logs === ""}>
<Input.TextArea
placeholder="Import logs"
id="log-textarea"
rows={10}
value={this.state.logs}
autosize={{ minRows: 2, maxRows: 10 }}
/>
</Spin>
</div>
</Col>
</Row>
</section>
)
}
}
class Import extends React.PureComponent {
state = {
importState: { status: "" }
}
fetchimportState = () => {
// Get the import status.
this.props
.request(cs.Routes.GetRouteImportStats, cs.MethodGet)
.then(r => {
this.setState({ importState: r.data.data })
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
componentDidMount() {
this.props.pageTitle("Import subscribers")
this.fetchimportState()
}
render() {
if (this.state.importState.status === "") {
// Fetching the status.
return (
<section className="content center">
<Spin />
</section>
)
} else if (this.state.importState.status !== StatusNone) {
// There's an import state
return (
<Importing
{...this.props}
importState={this.state.importState}
fetchimportState={this.fetchimportState}
/>
)
}
return (
<section className="content import">
<Row>
<Col span={22}>
<h1>Import subscribers</h1>
</Col>
<Col span={2} />
</Row>
<TheForm
{...this.props}
fetchimportState={this.fetchimportState}
lists={
this.props.data[cs.ModelLists].hasOwnProperty("results")
? this.props.data[cs.ModelLists].results
: []
}
/>
<hr />
<div className="help">
<h2>Instructions</h2>
<p>
Upload a CSV file or a ZIP file with a single CSV file in it to bulk
import subscribers. The CSV file should have the following headers
with the exact column names. <code>attributes</code> (optional)
should be a valid JSON string with double escaped quotes.
</p>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code>
</blockquote>
<h3>Example raw CSV</h3>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code>
<code className="csv-row">
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code>
<code className="csv-row">
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span>
</code>
</blockquote>
</div>
</section>
)
}
}
export default Import

View File

@ -1,275 +0,0 @@
import React from "react"
import { Switch, Route } from "react-router-dom"
import { Link } from "react-router-dom"
import { Layout, Menu, Icon } from "antd"
import logo from "./static/listmonk.svg"
// Views.
import Dashboard from "./Dashboard"
import Lists from "./Lists"
import Forms from "./Forms"
import Subscribers from "./Subscribers"
import Subscriber from "./Subscriber"
import Templates from "./Templates"
import Import from "./Import"
import Campaigns from "./Campaigns"
import Campaign from "./Campaign"
import Media from "./Media"
const { Content, Footer, Sider } = Layout
const SubMenu = Menu.SubMenu
const year = new Date().getUTCFullYear()
class Base extends React.Component {
state = {
basePath: "/" + window.location.pathname.split("/")[1],
error: null,
collapsed: false
}
onCollapse = collapsed => {
this.setState({ collapsed })
}
componentDidMount() {
// For small screen devices collapse the menu by default.
if (window.screen.width < 768) {
this.setState({ collapsed: true })
}
}
render() {
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse}
theme="light"
>
<div className="logo">
<Link to="/">
<img src={logo} alt="listmonk logo" />
</Link>
</div>
<Menu
defaultSelectedKeys={["/"]}
selectedKeys={[window.location.pathname]}
defaultOpenKeys={[this.state.basePath]}
mode="inline"
>
<Menu.Item key="/">
<Link to="/">
<Icon type="dashboard" />
<span>Dashboard</span>
</Link>
</Menu.Item>
<SubMenu
key="/lists"
title={
<span>
<Icon type="bars" />
<span>Lists</span>
</span>
}
>
<Menu.Item key="/lists">
<Link to="/lists">
<Icon type="bars" />
<span>All lists</span>
</Link>
</Menu.Item>
<Menu.Item key="/lists/forms">
<Link to="/lists/forms">
<Icon type="form" />
<span>Forms</span>
</Link>
</Menu.Item>
</SubMenu>
<SubMenu
key="/subscribers"
title={
<span>
<Icon type="team" />
<span>Subscribers</span>
</span>
}
>
<Menu.Item key="/subscribers">
<Link to="/subscribers">
<Icon type="team" /> All subscribers
</Link>
</Menu.Item>
<Menu.Item key="/subscribers/import">
<Link to="/subscribers/import">
<Icon type="upload" /> Import
</Link>
</Menu.Item>
</SubMenu>
<SubMenu
key="/campaigns"
title={
<span>
<Icon type="rocket" />
<span>Campaigns</span>
</span>
}
>
<Menu.Item key="/campaigns">
<Link to="/campaigns">
<Icon type="rocket" /> All campaigns
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/new">
<Link to="/campaigns/new">
<Icon type="plus" /> Create new
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/media">
<Link to="/campaigns/media">
<Icon type="picture" /> Media
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/templates">
<Link to="/campaigns/templates">
<Icon type="code-o" /> Templates
</Link>
</Menu.Item>
</SubMenu>
</Menu>
</Sider>
<Layout>
<Content style={{ margin: "0 16px" }}>
<div className="content-body">
<div id="alert-container" />
<Switch>
<Route
exact
key="/"
path="/"
render={props => (
<Dashboard {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/lists"
path="/lists"
render={props => (
<Lists {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/lists/forms"
path="/lists/forms"
render={props => (
<Forms {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers"
path="/subscribers"
render={props => (
<Subscribers {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/lists/:listID"
path="/subscribers/lists/:listID"
render={props => (
<Subscribers {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/import"
path="/subscribers/import"
render={props => (
<Import {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/:subID"
path="/subscribers/:subID"
render={props => (
<Subscriber {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns"
path="/campaigns"
render={props => (
<Campaigns {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/new"
path="/campaigns/new"
render={props => (
<Campaign {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/media"
path="/campaigns/media"
render={props => (
<Media {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/templates"
path="/campaigns/templates"
render={props => (
<Templates {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/:campaignID"
path="/campaigns/:campaignID"
render={props => (
<Campaign {...{ ...this.props, route: props }} />
)}
/>
</Switch>
</div>
</Content>
<Footer>
<span className="text-small">
<a
href="https://listmonk.app"
rel="noreferrer noopener"
target="_blank"
>
listmonk
</a>{" "}
&copy; 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
{process.env.REACT_APP_VERSION} &mdash;{" "}
<a
href="https://listmonk.app/docs"
target="_blank"
rel="noopener noreferrer"
>
Docs
</a>
</span>
</Footer>
</Layout>
</Layout>
)
}
}
export default Base

View File

@ -1,496 +0,0 @@
import React from "react"
import { Link } from "react-router-dom"
import {
Row,
Col,
Modal,
Form,
Input,
Select,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Spin,
notification
} from "antd"
import Utils from "./utils"
import * as cs from "./constants"
const tagColors = {
private: "orange",
public: "green"
}
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
modalWaiting: false
}
// Handle create / edit form submission.
handleSubmit = e => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) {
// Create a new list.
this.props
.modelRequest(
cs.ModelLists,
cs.Routes.CreateList,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List created",
description: `"${values["name"]}" created`
})
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
} else {
// Edit a list.
this.props
.modelRequest(cs.ModelLists, cs.Routes.UpdateList, cs.MethodPut, {
...values,
id: this.props.record.id
})
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List modified",
description: `"${values["name"]}" modified`
})
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false })
})
}
})
}
modalTitle(formType, record) {
if (formType === cs.FormCreate) {
return "Create a list"
}
return (
<div>
<Tag
color={
tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : ""
}
>
{record.type}
</Tag>{" "}
{record.name}
<br />
<span className="text-tiny text-grey">
ID {record.id} / UUID {record.uuid}
</span>
</div>
)
}
render() {
const { formType, record, onClose } = this.props
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
}
if (formType === null) {
return null
}
return (
<Modal
visible={true}
title={this.modalTitle(formType, record)}
okText={this.state.form === cs.FormCreate ? "Create" : "Save"}
confirmLoading={this.state.modalWaiting}
onCancel={onClose}
onOk={this.handleSubmit}
>
<div id="modal-alert-container" />
<Spin
spinning={this.props.reqStates[cs.ModelLists] === cs.StatePending}
>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength={200} />)}
</Form.Item>
<Form.Item
{...formItemLayout}
name="type"
label="Type"
extra="Public lists are open to the world to subscribe and their
names may appear on public pages such as the subscription management page."
>
{getFieldDecorator("type", {
initialValue: record.type ? record.type : "private",
rules: [{ required: true }]
})(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="private">Private</Select.Option>
<Select.Option value="public">Public</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
name="optin"
label="Opt-in"
extra="Double opt-in sends an e-mail to the subscriber asking for confirmation.
On Double opt-in lists, campaigns are only sent to confirmed subscribers."
>
{getFieldDecorator("optin", {
initialValue: record.optin ? record.optin : "single",
rules: [{ required: true }]
})(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="single">Single</Select.Option>
<Select.Option value="double">Double</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="Tags"
extra="Hit Enter after typing a word to add multiple tags"
>
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select mode="tags" />
)}
</Form.Item>
</Form>
</Spin>
</Modal>
)
}
}
const CreateForm = Form.create()(CreateFormDef)
class Lists extends React.PureComponent {
defaultPerPage = 20
state = {
formType: null,
record: {},
queryParams: {}
}
// Pagination config.
paginationOptions = {
hideOnSinglePage: false,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"],
position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
onChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
},
onShowSizeChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
}
}
constructor(props) {
super(props)
this.columns = [
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "40%",
render: (text, record) => {
const out = []
out.push(
<div className="name" key={`name-${record.id}`}>
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
</div>
)
if (record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
}
}
return out
}
},
{
title: "Type",
dataIndex: "type",
width: "15%",
render: (type, record) => {
let color = type === "private" ? "orange" : "green"
return (
<div>
<p>
<Tag color={color}>{type}</Tag>
<Tag>{record.optin}</Tag>
</p>
{record.optin === cs.ListOptinDouble && (
<p className="text-small">
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
<Link onClick={ e => { e.preventDefault(); this.makeOptinCampaign(record)} } to={`/campaigns/new?type=optin&list_id=${record.id}`}>
<Icon type="rocket" /> Send opt-in campaign
</Link>
</Tooltip>
</p>
)}
</div>
)
}
},
{
title: "Subscribers",
dataIndex: "subscriber_count",
width: "10%",
align: "center",
render: (text, record) => {
return (
<div className="name" key={`name-${record.id}`}>
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
</div>
)
}
},
{
title: "Created",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "Updated",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "",
dataIndex: "actions",
width: "10%",
render: (text, record) => {
return (
<div className="actions">
<Tooltip title="Send a campaign">
<Link to={`/campaigns/new?list_id=${record.id}`}>
<Icon type="rocket" />
</Link>
</Tooltip>
<Tooltip title="Edit list">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.deleteRecord(record)}
>
<Tooltip title="Delete list" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
</div>
)
}
}
]
}
componentDidMount() {
this.props.pageTitle("Lists")
this.fetchRecords()
}
fetchRecords = params => {
let qParams = {
page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page
}
if (params) {
qParams = { ...qParams, ...params }
}
this.props
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, qParams)
.then(() => {
this.setState({
queryParams: {
...this.state.queryParams,
total: this.props.data[cs.ModelLists].total,
perPage: this.props.data[cs.ModelLists].per_page,
page: this.props.data[cs.ModelLists].page,
query: this.props.data[cs.ModelLists].query
}
})
})
}
deleteRecord = record => {
this.props
.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, {
id: record.id
})
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List deleted",
description: `"${record.name}" deleted`
})
// Reload the table.
this.fetchRecords()
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
makeOptinCampaign = record => {
this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.CreateCampaign,
cs.MethodPost,
{
type: cs.CampaignTypeOptin,
name: "Optin: "+ record.name,
subject: "Confirm your subscriptions",
messenger: "email",
content_type: cs.CampaignContentTypeRichtext,
lists: [record.id]
}
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Opt-in campaign created",
description: "Opt-in campaign created"
})
// Redirect to the newly created campaign.
this.props.route.history.push({
pathname: cs.Routes.ViewCampaign.replace(
":id",
resp.data.data.id
)
})
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
handleHideForm = () => {
this.setState({ formType: null })
}
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, record: {} })
}
handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record })
}
render() {
return (
<section className="content">
<Row>
<Col xs={12} sm={18}>
<h1>Lists ({this.props.data[cs.ModelLists].total}) </h1>
</Col>
<Col xs={12} sm={6} className="right">
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Create list
</Button>
</Col>
</Row>
<br />
<Table
className="lists"
columns={this.columns}
rowKey={record => record.uuid}
dataSource={(() => {
if (
!this.props.data[cs.ModelLists] ||
!this.props.data[cs.ModelLists].hasOwnProperty("results")
) {
return []
}
return this.props.data[cs.ModelLists].results
})()}
loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone}
pagination={{ ...this.paginationOptions, ...this.state.queryParams }}
/>
<CreateForm
{...this.props}
formType={this.state.formType}
record={this.state.record}
onClose={this.handleHideForm}
fetchRecords={this.fetchRecords}
/>
</section>
)
}
}
export default Lists

View File

@ -1,176 +0,0 @@
import React from "react"
import {
Row,
Col,
Form,
Upload,
Icon,
Spin,
Popconfirm,
Tooltip,
notification
} from "antd"
import * as cs from "./constants"
class TheFormDef extends React.PureComponent {
state = {
confirmDirty: false
}
componentDidMount() {
this.props.pageTitle("Media")
this.fetchRecords()
}
fetchRecords = () => {
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
}
handleDeleteRecord = record => {
this.props
.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, {
id: record.id
})
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Image deleted",
description: `"${record.filename}" deleted`
})
// Reload the table.
this.fetchRecords()
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
handleInsertMedia = record => {
// The insertMedia callback may be passed down by the invoker (Campaign)
if (!this.props.insertMedia) {
return false
}
this.props.insertMedia(record.uri)
return false
}
onFileChange = f => {
if (
f.file.error &&
f.file.response &&
f.file.response.hasOwnProperty("message")
) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error uploading file",
description: f.file.response.message
})
} else if (f.file.status === "done") {
this.fetchRecords()
}
return false
}
render() {
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
return (
<Spin spinning={false}>
<Form>
<Form.Item {...formItemLayout} label="Upload images">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
getValueFromEvent: this.normFile,
rules: [{ required: true }]
})(
<Upload.Dragger
name="file"
action="/api/media"
multiple={true}
listType="picture"
onChange={this.onFileChange}
accept=".gif, .jpg, .jpeg, .png"
>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">Click or drag file here</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
</Form>
<section className="gallery">
{this.props.media &&
this.props.media.map((record, i) => (
<div key={i} className="image">
<a
onClick={() => {
this.handleInsertMedia(record)
if (this.props.onCancel) {
this.props.onCancel()
}
}}
>
<img alt={record.filename} src={record.thumb_uri} />
</a>
<div className="actions">
<Tooltip title="View" placement="bottom">
<a role="button" href={record.uri} target="_blank">
<Icon type="login" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
</div>
<div className="name" title={record.filename}>
{record.filename}
</div>
</div>
))}
</section>
</Spin>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Media extends React.PureComponent {
render() {
return (
<section className="content media">
<Row>
<Col span={22}>
<h1>Images</h1>
</Col>
<Col span={2} />
</Row>
<TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
</section>
)
}
}
export default Media

View File

@ -1,75 +0,0 @@
import React from "react"
import { Modal } from "antd"
import * as cs from "./constants"
import { Spin } from "antd"
class ModalPreview extends React.PureComponent {
makeForm(body) {
let form = document.createElement("form")
form.method = cs.MethodPost
form.action = this.props.previewURL
form.target = "preview-iframe"
let input = document.createElement("input")
input.type = "hidden"
input.name = "body"
input.value = body
form.appendChild(input)
document.body.appendChild(form)
form.submit()
}
render() {
return (
<Modal
visible={true}
title={this.props.title}
className="preview-modal"
width="90%"
height={900}
onCancel={this.props.onCancel}
onOk={this.props.onCancel}
>
<div className="preview-iframe-container">
<Spin className="preview-iframe-spinner" />
<iframe
key="preview-iframe"
onLoad={() => {
// If state is used to manage the spinner, it causes
// the iframe to re-render and reload everything.
// Hack the spinner away from the DOM directly instead.
let spin = document.querySelector(".preview-iframe-spinner")
if (spin) {
spin.parentNode.removeChild(spin)
}
// this.setState({ loading: false })
}}
title={this.props.title ? this.props.title : "Preview"}
name="preview-iframe"
id="preview-iframe"
className="preview-iframe"
ref={o => {
if (!o) {
return
}
// When the DOM reference for the iframe is ready,
// see if there's a body to post with the form hack.
if (this.props.body !== undefined && this.props.body !== null) {
this.makeForm(this.props.body)
} else {
if (this.props.previewURL) {
o.src = this.props.previewURL
}
}
}}
src="about:blank"
/>
</div>
</Modal>
)
}
}
export default ModalPreview

View File

@ -1,458 +0,0 @@
import React from "react"
import { Link } from "react-router-dom"
import {
Row,
Col,
Form,
Input,
Select,
Button,
Tag,
Tooltip,
Icon,
Spin,
Popconfirm,
notification
} from "antd"
import * as cs from "./constants"
const tagColors = {
enabled: "green",
blacklisted: "red"
}
const formItemLayoutModal = {
labelCol: { xs: { span: 24 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
}
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
loading: false
}
// Handle create / edit form submission.
handleSubmit = (e, cb) => {
e.preventDefault()
if (!cb) {
// Set a fake callback.
cb = () => {}
}
var err = null,
values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if (err) {
return
}
let a = values["attribs"]
values["attribs"] = {}
if (a && a.length > 0) {
try {
values["attribs"] = JSON.parse(a)
if (values["attribs"] instanceof Array) {
notification["error"]({
message: "Invalid JSON type",
description: "Attributes should be a map {} and not an array []"
})
return
}
} catch (e) {
notification["error"]({
message: "Invalid JSON in attributes",
description: e.toString()
})
return
}
}
this.setState({ loading: true })
if (this.props.formType === cs.FormCreate) {
// Add a subscriber.
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.CreateSubscriber,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
message: "Subscriber added",
description: `${values["email"]} added`
})
if (!this.props.isModal) {
this.props.fetchRecord(this.props.record.id)
}
cb(true)
this.setState({ loading: false })
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
cb(false)
this.setState({ loading: false })
})
} else {
// Edit a subscriber.
delete values["keys"]
delete values["vals"]
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.UpdateSubscriber,
cs.MethodPut,
{ ...values, id: this.props.record.id }
)
.then(resp => {
notification["success"]({
message: "Subscriber modified",
description: `${values["email"]} modified`
})
if (!this.props.isModal) {
this.props.fetchRecord(this.props.record.id)
}
cb(true)
this.setState({ loading: false })
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
cb(false)
this.setState({ loading: false })
})
}
}
handleDeleteRecord = record => {
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscriber,
cs.MethodDelete,
{ id: record.id }
)
.then(() => {
notification["success"]({
message: "Subscriber deleted",
description: `${record.email} deleted`
})
this.props.route.history.push({
pathname: cs.Routes.ViewSubscribers
})
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleSendOptinMail = record => {
this.props
.request(cs.Routes.SendSubscriberOptinMail, cs.MethodPost, {
id: record.id
})
.then(r => {
notification["success"]({
placement: cs.MsgPosition,
message: "Sent",
description: `Opt-in e-mail sentto ${record.email}`
})
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
render() {
const { formType, record } = this.props
const { getFieldDecorator } = this.props.form
if (formType === null) {
return null
}
let subListIDs = []
let subStatuses = {}
if (this.props.record && this.props.record.lists) {
subListIDs = this.props.record.lists.map(v => {
return v["id"]
})
subStatuses = this.props.record.lists.reduce(
(o, item) => ({ ...o, [item.id]: item.subscription_status }),
{}
)
} else if (this.props.list) {
subListIDs = [this.props.list.id]
}
const layout = this.props.isModal ? formItemLayoutModal : formItemLayout
return (
<Spin spinning={this.state.loading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...layout} label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength={200} />)}
</Form.Item>
<Form.Item {...layout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength={200} />)}
</Form.Item>
<Form.Item
{...layout}
name="status"
label="Status"
extra="Blacklisted users will not receive any e-mails ever"
>
{getFieldDecorator("status", {
initialValue: record.status ? record.status : "enabled",
rules: [{ required: true, message: "Type is required" }]
})(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item
{...layout}
label="Lists"
extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed."
>
{getFieldDecorator("lists", { initialValue: subListIDs })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) => (
<Select.Option
value={v.id}
key={v.id}
disabled={
subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed
}
>
<span>
{v.name}
{subStatuses[v.id] && (
<sup
className={"subscription-status " + subStatuses[v.id]}
>
{" "}
{subStatuses[v.id]}
</sup>
)}
</span>
</Select.Option>
))}
</Select>
)}
{record.lists &&
record.lists.some(l => {
return (
l.subscription_status === cs.SubscriptionStatusUnConfirmed
)
}) && (
<Tooltip title="Send an opt-in e-mail to the subscriber to confirm subscriptions">
<Link
onClick={e => {
e.preventDefault()
this.handleSendOptinMail(record)
}}
to={`/`}
>
<Icon type="rocket" /> Send opt-in e-mail
</Link>
</Tooltip>
)}
</Form.Item>
<Form.Item {...layout} label="Attributes" colon={false}>
<div>
{getFieldDecorator("attribs", {
initialValue: record.attribs
? JSON.stringify(record.attribs, null, 4)
: ""
})(
<Input.TextArea
placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }}
/>
)}
</div>
<p className="ant-form-extra">
Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}.{" "}
<a
href="https://listmonk.app/docs/concepts"
rel="noopener noreferrer"
target="_blank"
>
More info
</a>
.
</p>
</Form.Item>
{!this.props.isModal && (
<Form.Item {...formItemTailLayout}>
<Button
type="primary"
htmlType="submit"
icon={this.props.formType === cs.FormCreate ? "plus" : "save"}
>
{this.props.formType === cs.FormCreate ? "Add" : "Save"}
</Button>{" "}
{this.props.formType === cs.FormEdit && (
<Popconfirm
title="Are you sure?"
onConfirm={() => {
this.handleDeleteRecord(record)
}}
>
<Button icon="delete">Delete</Button>
</Popconfirm>
)}
</Form.Item>
)}
</Form>
</Spin>
)
}
}
const CreateForm = Form.create()(CreateFormDef)
class Subscriber extends React.PureComponent {
state = {
loading: true,
formRef: null,
record: {},
subID: this.props.route.match.params
? parseInt(this.props.route.match.params.subID, 10)
: 0
}
componentDidMount() {
// When this component is invoked within a modal from the subscribers list page,
// the necessary context is supplied and there's no need to fetch anything.
if (!this.props.isModal) {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
// Fetch subscriber.
this.fetchRecord(this.state.subID)
} else {
this.setState({ record: this.props.record, loading: false })
}
}
fetchRecord = id => {
this.props
.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id })
.then(r => {
this.setState({ record: r.data.data, loading: false })
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
setFormRef = r => {
this.setState({ formRef: r })
}
submitForm = (e, cb) => {
if (this.state.formRef) {
this.state.formRef.handleSubmit(e, cb)
}
}
render() {
return (
<section className="content">
<header className="header">
<Row>
<Col span={20}>
{!this.state.record.id && <h1>Add subscriber</h1>}
{this.state.record.id && (
<div>
<h1>
<Tag
className="subscriber-status"
color={
tagColors.hasOwnProperty(this.state.record.status)
? tagColors[this.state.record.status]
: ""
}
>
{this.state.record.status}
</Tag>{" "}
<span className="subscriber-name">
{this.state.record.name} ({this.state.record.email})
</span>
</h1>
<span className="text-small text-grey">
ID {this.state.record.id} / UUID {this.state.record.uuid}
</span>
</div>
)}
</Col>
<Col span={4} className="right subscriber-export">
<Tooltip title="Export subscriber data" placement="top">
<a
role="button"
href={"/api/subscribers/" + this.state.record.id + "/export"}
>
Export <Icon type="export" />
</a>
</Tooltip>
</Col>
</Row>
</header>
<div>
<Spin spinning={this.state.loading}>
<CreateForm
{...this.props}
formType={this.props.formType ? this.props.formType : cs.FormEdit}
record={this.state.record}
fetchRecord={this.fetchRecord}
lists={this.props.data[cs.ModelLists].results}
wrappedComponentRef={r => {
if (!r) {
return
}
// Save the form's reference so that when this component
// is used as a modal, the invoker of the model can submit
// it via submitForm()
this.setState({ formRef: r })
}}
/>
</Spin>
</div>
</section>
)
}
}
export default Subscriber

View File

@ -1,850 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
import {
Row,
Col,
Modal,
Form,
Input,
Select,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
notification,
Radio
} from "antd";
import Utils from "./utils";
import Subscriber from "./Subscriber";
import * as cs from "./constants";
const tagColors = {
enabled: "green",
blacklisted: "red"
};
class ListsFormDef extends React.PureComponent {
state = {
modalWaiting: false
};
// Handle create / edit form submission.
handleSubmit = e => {
e.preventDefault();
var err = null,
values = {};
this.props.form.validateFields((e, v) => {
err = e;
values = v;
});
if (err) {
return;
}
if (this.props.allRowsSelected) {
values["list_ids"] = this.props.listIDs;
values["query"] = this.props.query;
} else {
values["ids"] = this.props.selectedRows.map(r => r.id);
}
this.setState({ modalWaiting: true });
this.props
.request(
!this.props.allRowsSelected
? cs.Routes.AddSubscribersToLists
: cs.Routes.AddSubscribersToListsByQuery,
cs.MethodPut,
values
)
.then(() => {
notification["success"]({
message: "Lists changed",
description: `Lists changed for selected subscribers`
});
this.props.clearSelectedRows();
this.props.fetchRecords();
this.setState({ modalWaiting: false });
this.props.onClose();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
this.setState({ modalWaiting: false });
});
};
render() {
const { getFieldDecorator } = this.props.form;
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
};
return (
<Modal
visible={true}
width="750px"
className="subscriber-lists-modal"
title="Manage lists"
okText="Ok"
confirmLoading={this.state.modalWaiting}
onCancel={this.props.onClose}
onOk={this.handleSubmit}
>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Action">
{getFieldDecorator("action", {
initialValue: "add",
rules: [{ required: true }]
})(
<Radio.Group>
<Radio value="add">Add</Radio>
<Radio value="remove">Remove</Radio>
<Radio value="unsubscribe">Mark as unsubscribed</Radio>
</Radio.Group>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Lists">
{getFieldDecorator("target_list_ids", {
rules: [{ required: true }]
})(
<Select mode="multiple">
{[...this.props.lists].map((v, i) => (
<Select.Option value={v.id} key={v.id}>
{v.name}
</Select.Option>
))}
</Select>
)}
</Form.Item>
</Form>
</Modal>
);
}
}
const ListsForm = Form.create()(ListsFormDef);
class Subscribers extends React.PureComponent {
defaultPerPage = 20;
state = {
formType: null,
listsFormVisible: false,
modalForm: null,
record: {},
queryParams: {
page: 1,
total: 0,
perPage: this.defaultPerPage,
listID: this.props.route.match.params.listID
? parseInt(this.props.route.match.params.listID, 10)
: 0,
list: null,
query: null,
targetLists: []
},
listModalVisible: false,
allRowsSelected: false,
selectedRows: []
};
// Pagination config.
paginationOptions = {
hideOnSinglePage: true,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"],
position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
onChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage });
},
onShowSizeChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage });
}
};
constructor(props) {
super(props);
// Table layout.
this.columns = [
{
title: "E-mail",
dataIndex: "email",
sorter: true,
width: "25%",
render: (text, record) => {
const out = [];
out.push(
<div key={`sub-email-${record.id}`} className="sub-name">
<Link
to={`/subscribers/${record.id}`}
onClick={e => {
// Open the individual subscriber page on ctrl+click
// and the modal otherwise.
if (!e.ctrlKey) {
this.handleShowEditForm(record);
e.preventDefault();
}
}}
>
{text}
</Link>
</div>
);
if (record.lists.length > 0) {
for (let i = 0; i < record.lists.length; i++) {
out.push(
<Tag
className="list"
key={`sub-${record.id}-list-${record.lists[i].id}`}
>
<Link to={`/subscribers/lists/${record.lists[i].id}`}>
{record.lists[i].name}
</Link>
<sup
className={
"subscription-status " +
record.lists[i].subscription_status
}
>
{" "}
{record.lists[i].subscription_status}
</sup>
</Tag>
);
}
}
return out;
}
},
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "15%",
render: (text, record) => {
return (
<Link
to={`/subscribers/${record.id}`}
onClick={e => {
// Open the individual subscriber page on ctrl+click
// and the modal otherwise.
if (!e.ctrlKey) {
this.handleShowEditForm(record);
e.preventDefault();
}
}}
>
{text}
</Link>
);
}
},
{
title: "Status",
dataIndex: "status",
width: "5%",
render: (status, _) => {
return (
<Tag
color={tagColors.hasOwnProperty(status) ? tagColors[status] : ""}
>
{status}
</Tag>
);
}
},
{
title: "Lists",
dataIndex: "lists",
width: "10%",
align: "center",
render: (lists, _) => {
return (
<span>
{lists.reduce(
(def, item) =>
def +
(item.subscription_status !==
cs.SubscriptionStatusUnsubscribed
? 1
: 0),
0
)}
</span>
);
}
},
{
title: "Created",
width: "10%",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date);
}
},
{
title: "Updated",
width: "10%",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date);
}
},
{
title: "",
dataIndex: "actions",
width: "10%",
render: (text, record) => {
return (
<div className="actions">
{/* <Tooltip title="Send an e-mail"><a role="button"><Icon type="rocket" /></a></Tooltip> */}
<Tooltip title="Edit subscriber">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete subscriber" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
</div>
);
}
}
];
}
componentDidMount() {
// Load lists on boot.
this.props
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
.then(() => {
// If this is an individual list's view, pick up that list.
if (this.state.queryParams.listID) {
this.props.data[cs.ModelLists].results.forEach(l => {
if (l.id === this.state.queryParams.listID) {
this.setState({
queryParams: { ...this.state.queryParams, list: l }
});
return false;
}
});
}
});
this.fetchRecords();
}
fetchRecords = params => {
let qParams = {
page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page,
list_id: this.state.queryParams.listID,
query: this.state.queryParams.query
};
// The records are for a specific list.
if (this.state.queryParams.listID) {
qParams.list_id = this.state.queryParams.listID;
}
if (params) {
qParams = { ...qParams, ...params };
}
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.GetSubscribers,
cs.MethodGet,
qParams
)
.then(() => {
this.setState({
queryParams: {
...this.state.queryParams,
total: this.props.data[cs.ModelSubscribers].total,
perPage: this.props.data[cs.ModelSubscribers].per_page,
page: this.props.data[cs.ModelSubscribers].page,
query: this.props.data[cs.ModelSubscribers].query
}
});
});
};
handleDeleteRecord = record => {
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscriber,
cs.MethodDelete,
{ id: record.id }
)
.then(() => {
notification["success"]({
message: "Subscriber deleted",
description: `${record.email} deleted`
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleDeleteRecords = records => {
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscribers,
cs.MethodDelete,
{ id: records.map(r => r.id) }
)
.then(() => {
notification["success"]({
message: "Subscriber(s) deleted",
description: "Selected subscribers deleted"
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleBlacklistSubscribers = records => {
this.props
.request(cs.Routes.BlacklistSubscribers, cs.MethodPut, {
ids: records.map(r => r.id)
})
.then(() => {
notification["success"]({
message: "Subscriber(s) blacklisted",
description: "Selected subscribers blacklisted"
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
// Arbitrary query based calls.
handleDeleteRecordsByQuery = (listIDs, query) => {
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscribersByQuery,
cs.MethodPost,
{ list_ids: listIDs, query: query }
)
.then(() => {
notification["success"]({
message: "Subscriber(s) deleted",
description: "Selected subscribers have been deleted"
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleBlacklistSubscribersByQuery = (listIDs, query) => {
this.props
.request(cs.Routes.BlacklistSubscribersByQuery, cs.MethodPut, {
list_ids: listIDs,
query: query
})
.then(() => {
notification["success"]({
message: "Subscriber(s) blacklisted",
description: "Selected subscribers have been blacklisted"
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleQuerySubscribersIntoLists = (query, sourceList, targetLists) => {
let params = {
query: query,
source_list: sourceList,
target_lists: targetLists
};
this.props
.request(cs.Routes.QuerySubscribersIntoLists, cs.MethodPost, params)
.then(res => {
notification["success"]({
message: "Subscriber(s) added",
description: `${res.data.data.count} added`
});
this.handleToggleListModal();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleHideForm = () => {
this.setState({ formType: null });
};
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, attribs: [], record: {} });
};
handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record });
};
handleToggleListsForm = () => {
this.setState({ listsFormVisible: !this.state.listsFormVisible });
};
handleSearch = q => {
q = q.trim().toLowerCase();
if (q === "") {
this.fetchRecords({ query: null });
return;
}
q = q.replace(/'/g, "''");
const query = `(name ~* '${q}' OR email ~* '${q}')`;
this.fetchRecords({ query: query });
};
handleSelectRow = (_, records) => {
this.setState({ allRowsSelected: false, selectedRows: records });
};
handleSelectAllRows = () => {
this.setState({
allRowsSelected: true,
selectedRows: this.props.data[cs.ModelSubscribers].results
});
};
clearSelectedRows = (_, records) => {
this.setState({ allRowsSelected: false, selectedRows: [] });
};
handleToggleQueryForm = () => {
this.setState({ queryFormVisible: !this.state.queryFormVisible });
};
handleToggleListModal = () => {
this.setState({ listModalVisible: !this.state.listModalVisible });
};
render() {
const pagination = {
...this.paginationOptions,
...this.state.queryParams
};
if (this.state.queryParams.list) {
this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers");
} else {
this.props.pageTitle("Subscribers");
}
return (
<section className="content subscribers">
<header className="header">
<Row>
<Col xs={24} sm={14}>
<h1>
Subscribers
{this.props.data[cs.ModelSubscribers].total > 0 && (
<span> ({this.props.data[cs.ModelSubscribers].total})</span>
)}
{this.state.queryParams.list && (
<span> &raquo; {this.state.queryParams.list.name}</span>
)}
</h1>
</Col>
<Col xs={24} sm={10} className="right header-action-break">
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Add subscriber
</Button>
</Col>
</Row>
</header>
<div className="subscriber-query">
<Row>
<Col sm={24} md={10}>
<Row>
<Row>
<label>Search subscribers</label>
<Input.Search
name="name"
placeholder="Name or e-mail"
enterButton
onSearch={this.handleSearch}
/>{" "}
</Row>
<Row style={{ marginTop: "10px" }}>
<a role="button" onClick={this.handleToggleQueryForm}>
<Icon type="setting" /> Advanced
</a>
</Row>
</Row>
{this.state.queryFormVisible && (
<div className="advanced-query">
<p>
<label>Advanced query</label>
<Input.TextArea
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'"
id="subscriber-query"
rows={10}
onChange={e => {
this.setState({
queryParams: {
...this.state.queryParams,
query: e.target.value
}
});
}}
value={this.state.queryParams.query}
autosize={{ minRows: 2, maxRows: 10 }}
/>
<span className="text-tiny text-small">
Partial SQL expression to query subscriber attributes.{" "}
<a
href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon type="link" />.
</a>
</span>
</p>
<p>
<Button
disabled={this.state.queryParams.query === ""}
type="primary"
icon="search"
onClick={() => {
this.fetchRecords();
}}
>
Query
</Button>{" "}
<Button
disabled={this.state.queryParams.query === ""}
icon="refresh"
onClick={() => {
this.fetchRecords({ query: null });
}}
>
Reset
</Button>
</p>
</div>
)}
</Col>
<Col sm={24} md={{ span: 12, offset: 2 }} className="slc-subs-section">
{this.state.selectedRows.length > 0 && (
<nav className="table-options">
<p>
<strong>
{this.state.allRowsSelected
? this.state.queryParams.total
: this.state.selectedRows.length}
</strong>{" "}
subscriber(s) selected
{!this.state.allRowsSelected &&
this.state.queryParams.total >
this.state.queryParams.perPage && (
<span>
{" "}
&mdash;{" "}
<a role="button" onClick={this.handleSelectAllRows}>
Select all {this.state.queryParams.total}?
</a>
</span>
)}
</p>
<p class="slc-subs-actions">
<a role="button" onClick={this.handleToggleListsForm}>
<Icon type="bars" /> Manage lists
</a>
<a role="button">
<Icon type="rocket" /> Send campaign
</a>
<Popconfirm
title="Are you sure?"
onConfirm={() => {
if (this.state.allRowsSelected) {
this.handleDeleteRecordsByQuery(
this.state.queryParams.listID
? [this.state.queryParams.listID]
: [],
this.state.queryParams.query
);
this.clearSelectedRows();
} else {
this.handleDeleteRecords(this.state.selectedRows);
this.clearSelectedRows();
}
}}
>
<a role="button">
<Icon type="delete" /> Delete
</a>
</Popconfirm>
<Popconfirm
title="Are you sure?"
onConfirm={() => {
if (this.state.allRowsSelected) {
this.handleBlacklistSubscribersByQuery(
this.state.queryParams.listID
? [this.state.queryParams.listID]
: [],
this.state.queryParams.query
);
this.clearSelectedRows();
} else {
this.handleBlacklistSubscribers(
this.state.selectedRows
);
this.clearSelectedRows();
}
}}
>
<a role="button">
<Icon type="close" /> Blacklist
</a>
</Popconfirm>
</p>
</nav>
)}
</Col>
</Row>
</div>
<Table
columns={this.columns}
rowKey={record => `sub-${record.id}`}
dataSource={(() => {
if (
!this.props.data[cs.ModelSubscribers] ||
!this.props.data[cs.ModelSubscribers].hasOwnProperty("results")
) {
return [];
}
return this.props.data[cs.ModelSubscribers].results;
})()}
loading={this.props.reqStates[cs.ModelSubscribers] !== cs.StateDone}
pagination={pagination}
rowSelection={{
columnWidth: "5%",
onChange: this.handleSelectRow,
selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`)
}}
/>
{this.state.formType !== null && (
<Modal
visible={true}
width="750px"
className="subscriber-modal"
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
confirmLoading={this.state.modalWaiting}
onOk={e => {
if (!this.state.modalForm) {
return;
}
// This submits the form embedded in the Subscriber component.
this.state.modalForm.submitForm(e, ok => {
if (ok) {
this.handleHideForm();
this.fetchRecords();
}
});
}}
onCancel={this.handleHideForm}
okButtonProps={{
disabled:
this.props.reqStates[cs.ModelSubscribers] === cs.StatePending
}}
>
<Subscriber
{...this.props}
isModal={true}
formType={this.state.formType}
record={this.state.record}
ref={r => {
if (!r) {
return;
}
this.setState({ modalForm: r });
}}
/>
</Modal>
)}
{this.state.listsFormVisible && (
<ListsForm
{...this.props}
lists={this.props.data[cs.ModelLists].results}
allRowsSelected={this.state.allRowsSelected}
selectedRows={this.state.selectedRows}
selectedLists={
this.state.queryParams.listID
? [this.state.queryParams.listID]
: []
}
clearSelectedRows={this.clearSelectedRows}
query={this.state.queryParams.query}
fetchRecords={this.fetchRecords}
onClose={this.handleToggleListsForm}
/>
)}
</section>
);
}
}
export default Subscribers;

View File

@ -1,443 +0,0 @@
import React from "react";
import {
Row,
Col,
Modal,
Form,
Input,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Spin,
notification
} from "antd";
import ModalPreview from "./ModalPreview";
import Utils from "./utils";
import * as cs from "./constants";
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
modalWaiting: false,
previewName: "",
previewBody: ""
};
// Handle create / edit form submission.
handleSubmit = e => {
e.preventDefault();
this.props.form.validateFields((err, values) => {
if (err) {
return;
}
this.setState({ modalWaiting: true });
if (this.props.formType === cs.FormCreate) {
// Create a new list.
this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.CreateTemplate,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template added",
description: `"${values["name"]}" added`
});
this.props.fetchRecords();
this.props.onClose();
this.setState({ modalWaiting: false });
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
});
this.setState({ modalWaiting: false });
});
} else {
// Edit a list.
this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.UpdateTemplate,
cs.MethodPut,
{ ...values, id: this.props.record.id }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template updated",
description: `"${values["name"]}" modified`
});
this.props.fetchRecords();
this.props.onClose();
this.setState({ modalWaiting: false });
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
});
this.setState({ modalWaiting: false });
});
}
});
};
handleConfirmBlur = e => {
const value = e.target.value;
this.setState({ confirmDirty: this.state.confirmDirty || !!value });
};
handlePreview = (name, body) => {
this.setState({ previewName: name, previewBody: body });
};
render() {
const { formType, record, onClose } = this.props;
const { getFieldDecorator } = this.props.form;
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
};
if (formType === null) {
return null;
}
return (
<div>
<Modal
visible={true}
title={formType === cs.FormCreate ? "Add template" : record.name}
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
width="90%"
height={900}
confirmLoading={this.state.modalWaiting}
onCancel={onClose}
onOk={this.handleSubmit}
>
<Spin
spinning={
this.props.reqStates[cs.ModelTemplates] === cs.StatePending
}
>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength={200} />)}
</Form.Item>
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
{getFieldDecorator("body", {
initialValue: record.body ? record.body : "",
rules: [{ required: true }]
})(<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />)}
</Form.Item>
{this.props.form.getFieldValue("body") !== "" && (
<Form.Item {...formItemLayout} colon={false} label="&nbsp;">
<Button
icon="search"
onClick={() =>
this.handlePreview(
this.props.form.getFieldValue("name"),
this.props.form.getFieldValue("body")
)
}
>
Preview
</Button>
</Form.Item>
)}
</Form>
</Spin>
<Row>
<Col span="4" />
<Col span="18" className="text-grey text-small">
The placeholder{" "}
<code>
{"{"}
{"{"} template "content" . {"}"}
{"}"}
</code>{" "}
should appear in the template.{" "}
<a
href="https://listmonk.app/docs/templating"
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon type="link" />.
</a>
.
</Col>
</Row>
</Modal>
{this.state.previewBody && (
<ModalPreview
title={
this.state.previewName
? this.state.previewName
: "Template preview"
}
previewURL={cs.Routes.PreviewNewTemplate}
body={this.state.previewBody}
onCancel={() => {
this.setState({ previewBody: null, previewName: null });
}}
/>
)}
</div>
);
}
}
const CreateForm = Form.create()(CreateFormDef);
class Templates extends React.PureComponent {
state = {
formType: null,
record: {},
previewRecord: null
};
constructor(props) {
super(props);
this.columns = [
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "50%",
render: (text, record) => {
return (
<div className="name">
<a role="button" onClick={() => this.handleShowEditForm(record)}>
{text}
</a>
{record.is_default && (
<div>
<Tag>Default</Tag>
</div>
)}
</div>
);
}
},
{
title: "Created",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date);
}
},
{
title: "Updated",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date);
}
},
{
title: "",
dataIndex: "actions",
width: "20%",
className: "actions",
render: (text, record) => {
return (
<div className="actions">
<Tooltip
title="Preview template"
onClick={() => this.handlePreview(record)}
>
<a role="button">
<Icon type="search" />
</a>
</Tooltip>
{!record.is_default && (
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleSetDefault(record)}
>
<Tooltip title="Set as default" placement="bottom">
<a role="button">
<Icon type="check" />
</a>
</Tooltip>
</Popconfirm>
)}
<Tooltip title="Edit template">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
{record.id !== 1 && (
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete template" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
)}
</div>
);
}
}
];
}
componentDidMount() {
this.props.pageTitle("Templates");
this.fetchRecords();
}
fetchRecords = () => {
this.props.modelRequest(
cs.ModelTemplates,
cs.Routes.GetTemplates,
cs.MethodGet
);
};
handleDeleteRecord = record => {
this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.DeleteTemplate,
cs.MethodDelete,
{ id: record.id }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template deleted",
description: `"${record.name}" deleted`
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message });
});
};
handleSetDefault = record => {
this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.SetDefaultTemplate,
cs.MethodPut,
{ id: record.id }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template updated",
description: `"${record.name}" set as default`
});
// Reload the table.
this.fetchRecords();
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
});
});
};
handlePreview = record => {
this.setState({ previewRecord: record });
};
hideForm = () => {
this.setState({ formType: null });
};
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, record: {} });
};
handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record });
};
render() {
return (
<section className="content templates">
<Row>
<Col xs={24} sm={14}>
<h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1>
</Col>
<Col xs={24} sm={10} className="right header-action-break">
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Add template
</Button>
</Col>
</Row>
<br />
<Table
columns={this.columns}
rowKey={record => record.id}
dataSource={this.props.data[cs.ModelTemplates]}
loading={this.props.reqStates[cs.ModelTemplates] !== cs.StateDone}
pagination={false}
/>
<CreateForm
{...this.props}
formType={this.state.formType}
record={this.state.record}
onClose={this.hideForm}
fetchRecords={this.fetchRecords}
/>
{this.state.previewRecord && (
<ModalPreview
title={this.state.previewRecord.name}
previewURL={cs.Routes.PreviewTemplate.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => {
this.setState({ previewRecord: null });
}}
/>
)}
</section>
);
}
}
export default Templates;

190
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,190 @@
import { ToastProgrammatic as Toast } from 'buefy';
import axios from 'axios';
import humps from 'humps';
import qs from 'qs';
import store from '../store';
import { models } from '../constants';
const http = axios.create({
baseURL: process.env.BASE_URL,
withCredentials: false,
responseType: 'json',
transformResponse: [
// Apply the defaut transformations as well.
...axios.defaults.transformResponse,
(resp) => {
if (!resp) {
return resp;
}
// There's an error message.
if ('message' in resp && resp.message !== '') {
return resp;
}
const data = humps.camelizeKeys(resp.data);
return data;
},
],
// Override the default serializer to switch params from becoming []id=a&[]id=b ...
// in GET and DELETE requests to id=a&id=b.
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
});
// Intercept requests to set the 'loading' state of a model.
http.interceptors.request.use((config) => {
if ('loading' in config) {
store.commit('setLoading', { model: config.loading, status: true });
}
return config;
}, (error) => Promise.reject(error));
// Intercept responses to set them to store.
http.interceptors.response.use((resp) => {
// Clear the loading state for a model.
if ('loading' in resp.config) {
store.commit('setLoading', { model: resp.config.loading, status: false });
}
// Store the API response for a model.
if ('store' in resp.config) {
store.commit('setModelResponse', { model: resp.config.store, data: resp.data });
}
return resp;
}, (err) => {
// Clear the loading state for a model.
if ('loading' in err.config) {
store.commit('setLoading', { model: err.config.loading, status: false });
}
let msg = '';
if (err.response.data && err.response.data.message) {
msg = err.response.data.message;
} else {
msg = err.toString();
}
Toast.open({
message: msg,
type: 'is-danger',
queue: false,
});
return Promise.reject(err);
});
// API calls accept the following config keys.
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
// Dashboard
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
{ loading: models.dashboard });
export const getDashboardCharts = () => http.get('/api/dashboard/charts',
{ loading: models.dashboard });
// Lists.
export const getLists = () => http.get('/api/lists',
{ loading: models.lists, store: 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 deleteList = (id) => http.delete(`/api/lists/${id}`,
{ loading: models.lists });
// Subscribers.
export const getSubscribers = async (params) => http.get('/api/subscribers',
{ params, loading: models.subscribers, store: 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 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 addSubscribersToListsByQuery = (data) => http.put('/api/subscribers/query/lists',
data, { loading: models.subscribers });
export const blacklistSubscribers = (data) => http.put('/api/subscribers/blacklist', data,
{ loading: models.subscribers });
export const blacklistSubscribersByQuery = (data) => http.put('/api/subscribers/query/blacklist', 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 = () => http.get('/api/import/subscribers/logs');
export const stopImport = () => http.delete('/api/import/subscribers');
// Campaigns.
export const getCampaigns = async (params) => http.get('/api/campaigns',
{ params, loading: models.campaigns, store: models.campaigns });
export const getCampaign = async (id) => http.get(`/api/campaigns/${id}`,
{ loading: models.campaigns });
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 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 changeCampaignStatus = async (id, status) => http.put(`/api/campaigns/${id}/status`,
{ status }, { loading: models.campaigns });
export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
{ loading: models.campaigns });
// Media.
export const getMedia = async () => http.get('/api/media',
{ loading: models.media, store: 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 });
// 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 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 deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates });

View File

@ -0,0 +1,43 @@
@import "~bulma/sass/base/_all";
@import "~bulma/sass/elements/_all";
@import "~bulma/sass/components/card";
@import "~bulma/sass/components/dropdown";
@import "~bulma/sass/components/level";
@import "~bulma/sass/components/menu";
@import "~bulma/sass/components/message";
@import "~bulma/sass/components/modal";
@import "~bulma/sass/components/pagination";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/form/_all";
@import "~bulma/sass/grid/columns";
@import "~bulma/sass/grid/tiles";
@import "~bulma/sass/layout/section";
@import "~bulma/sass/layout/footer";
@import "~buefy/src/scss/utils/_all";
@import "~buefy/src/scss/components/_autocomplete";
@import "~buefy/src/scss/components/_carousel";
@import "~buefy/src/scss/components/_checkbox";
@import "~buefy/src/scss/components/_datepicker";
@import "~buefy/src/scss/components/_dialog";
@import "~buefy/src/scss/components/_dropdown";
@import "~buefy/src/scss/components/_form";
@import "~buefy/src/scss/components/_icon";
@import "~buefy/src/scss/components/_loading";
@import "~buefy/src/scss/components/_menu";
@import "~buefy/src/scss/components/_message";
@import "~buefy/src/scss/components/_modal";
@import "~buefy/src/scss/components/_pagination";
@import "~buefy/src/scss/components/_notices";
@import "~buefy/src/scss/components/_progress";
@import "~buefy/src/scss/components/_radio";
@import "~buefy/src/scss/components/_select";
@import "~buefy/src/scss/components/_sidebar";
@import "~buefy/src/scss/components/_switch";
@import "~buefy/src/scss/components/_table";
@import "~buefy/src/scss/components/_tabs";
@import "~buefy/src/scss/components/_tag";
@import "~buefy/src/scss/components/_taginput";
@import "~buefy/src/scss/components/_timepicker";
@import "~buefy/src/scss/components/_tooltip";
@import "~buefy/src/scss/components/_upload";

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

73
frontend/src/assets/icons/fontello.css vendored Normal file
View File

@ -0,0 +1,73 @@
@font-face {
font-family: 'fontello';
src: url('fontello.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
[class^="mdi-"]:before, [class*=" mdi-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
.mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */
.mdi-account-multiple:before { content: '\e803'; } /* '' */
.mdi-file-upload-outline:before { content: '\e804'; } /* '' */
.mdi-rocket-launch-outline:before { content: '\e805'; } /* '' */
.mdi-plus:before { content: '\e806'; } /* '' */
.mdi-image-outline:before { content: '\e807'; } /* '' */
.mdi-file-image-outline:before { content: '\e808'; } /* '' */
.mdi-cog-outline:before { content: '\e809'; } /* '' */
.mdi-tag-outline:before { content: '\e80a'; } /* '' */
.mdi-calendar-clock:before { content: '\e80b'; } /* '' */
.mdi-email-outline:before { content: '\e80c'; } /* '' */
.mdi-text:before { content: '\e80d'; } /* '' */
.mdi-alarm:before { content: '\e80e'; } /* '' */
.mdi-pause-circle-outline:before { content: '\e80f'; } /* '' */
.mdi-file-find-outline:before { content: '\e810'; } /* '' */
.mdi-clock-start:before { content: '\e811'; } /* '' */
.mdi-file-multiple-outline:before { content: '\e812'; } /* '' */
.mdi-trash-can-outline:before { content: '\e813'; } /* '' */
.mdi-pencil-outline:before { content: '\e814'; } /* '' */
.mdi-arrow-top-right:before { content: '\e815'; } /* '' */
.mdi-link-variant:before { content: '\e816'; } /* '' */
.mdi-cloud-download-outline:before { content: '\e817'; } /* '' */
.mdi-account-search-outline:before { content: '\e818'; } /* '' */
.mdi-check-circle-outline:before { content: '\e819'; } /* '' */
.mdi-account-check-outline:before { content: '\e81a'; } /* '' */
.mdi-account-off-outline:before { content: '\e81b'; } /* '' */
.mdi-chevron-right:before { content: '\e81c'; } /* '' */
.mdi-chevron-left:before { content: '\e81d'; } /* '' */
.mdi-content-save-outline:before { content: '\e81e'; } /* '' */

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,533 @@
/* Import Bulma to set variables */
@import "~bulma/sass/utilities/_all";
$body-family: "IBM Plex Sans", "Helvetica Neue", sans-serif;
$body-size: 15px;
$primary: #7f2aff;
$green: #4caf50;
$turquoise: $green;
$red: #ff5722;
$link: $primary;
$input-placeholder-color: $black-ter;
$colors: map-merge($colors, (
"turquoise": ($green, $green-invert),
"green": ($green, $green-invert),
"success": ($green, $green-invert),
"danger": ($red, $green-invert),
));
$sidebar-box-shadow: none;
$sidebar-width: 240px;
$menu-item-active-background-color: $white-bis;
$menu-item-active-color: $primary;
/* Buefy */
$modal-background-background-color: rgba(0, 0, 0, .30);
$speed-slow: 25ms !default;
$speed-slower: 50ms !default;
/* Import full Bulma and Buefy to override styles. */
// @import "~bulma";
@import "./buefy";
html, body {
height: 100%;
}
code {
color: $grey;
}
ul.no {
list-style-type: none;
padding: 0;
margin: 0;
}
.relative {
position: relative;
}
section {
&.wrap {
max-width: 1100px;
}
&.wrap-small {
max-width: 900px;
}
}
.spinner.is-tiny {
display: inline-block;
height: 10px;
width: 10px;
position: relative;
.loading-overlay {
.loading-background {
background: none;
}
.loading-icon::after {
width: 10px;
height: 10px;
top: 0;
left: 0;
position: static;
}
}
}
/* Two column sidebar+body layout */
#app {
display: flex;
flex-direction: row;
min-height: 100%;
> .sidebar {
flex-shrink: 1;
box-shadow: 0 0 5px #eee;
border-right: 1px solid #eee;
.b-sidebar {
position: sticky;
top: 0px;
}
}
> .main {
margin: 30px 30px 30px 45px;
flex-grow: 1;
}
}
.b-sidebar {
.logo {
padding: 15px;
}
.sidebar-content {
border-right: 1px solid #eee;
}
.menu-list {
.router-link-exact-active {
border-right: 5px solid $primary;
outline: 0 none;
}
li ul {
margin-right: 0;
}
> li {
margin-bottom: 15px;
}
}
.logo {
margin-bottom: 30px;
}
.favicon {
display: none;
}
}
/* Table colors and padding */
.main table {
thead th {
background: $white-bis;
border-bottom: 1px solid $grey-lighter;
}
thead th, tbody td {
padding: 15px 10px;
border-color: #eaeaea;
}
.actions a {
margin: 0 10px;
display: inline-block;
}
}
/* Modal */
.modal {
z-index: 100;
}
.modal-card-head {
display: block;
}
.modal .modal-card-foot {
justify-content: flex-end;
}
.modal .modal-close.is-large {
display: none;
}
/* Fix for button primary colour. */
.button.is-primary {
background: $primary;
&:hover {
background: darken($primary, 15%);
}
&:disabled {
background: $grey-light;
}
}
.autocomplete .dropdown-content {
background-color: $white-bis;
}
.help {
color: $grey;
}
/* Tags */
.tag {
min-width: 75px;
&:not(body) {
$color: $grey-lighter;
border: 1px solid $color;
box-shadow: 1px 1px 0 $color;
color: $grey;
}
&.private, &.scheduled, &.paused {
$color: #ed7b00;
color: $color;
background: #fff7e6;
border: 1px solid lighten($color, 37%);
box-shadow: 1px 1px 0 lighten($color, 37%);
}
&.public, &.running {
$color: #1890ff;
color: $color;
background: #e6f7ff;
border: 1px solid lighten($color, 37%);
box-shadow: 1px 1px 0 lighten($color, 25%);
}
&.finished, &.enabled {
$color: #50ab24;
color: $color;
background: #f6ffed;
border: 1px solid lighten($color, 45%);
box-shadow: 1px 1px 0 lighten($color, 45%);
}
&.blacklisted {
$color: #f5222d;
color: $color;
background: #fff1f0;
border: 1px solid lighten($color, 45%);
box-shadow: 1px 1px 0 lighten($color, 45%);
}
sup {
font-weight: $weight-semibold;
letter-spacing: 0.03em;
}
&.unsubscribed sup {
color: #fa8c16;
}
&.confirmed sup {
color: #52c41a;
}
&:not(body) .icon:first-child:last-child {
margin-right: 1px;
}
}
/* Dashboard */
section.dashboard {
.title {
margin-bottom: 0.5rem;
}
.level-item {
background-color: $white-bis;
padding: 30px;
margin: 10px;
&:first-child, &:last-child {
margin: 0;
}
}
label {
font-weight: bold;
display: inline-block;
min-width: 50px;
text-align: right;
}
.charts {
min-height: 200px;
}
}
/* Lists page */
section.lists {
td .tag {
min-width: 65px;
}
}
/* Subscribers page */
.subscribers-controls {
padding-bottom: 15px;
}
.subscribers-bulk {
.actions a {
display: inline-block;
margin-right: 30px;
}
}
/* Import page */
section.import {
.delimiter input {
max-width: 100px;
}
.status {
padding: 60px;
}
.logs {
max-height: 240px;
}
}
/* Campaigns page */
section.campaigns {
table tbody {
tr.running {
background: lighten(#1890ff, 43%);
td {
border-bottom: 1px solid lighten(#1890ff, 30%);
}
.spinner .loading-overlay .loading-icon::after {
border-bottom-color: lighten(#1890ff, 30%);
border-left-color: lighten(#1890ff, 30%);
}
}
td {
&.status .spinner {
margin-left: 10px;
}
.tags {
margin-top: 5px;
}
p {
margin: 0 !important;
}
&.lists ul {
font-size: $size-7;
list-style-type: circle;
a {
color: $grey-dark;
&:hover {
color: $primary;
}
}
}
.fields {
font-size: $size-7;
label {
font-weight: bold;
text-align: right;
min-width: 50px;
margin-right: 10px;
display: inline-block;
}
}
&.draft {
color: $grey-lighter;
}
.progress-wrapper {
.progress.is-small {
height: 0.4em;
}
display: inline-block;
}
}
}
}
/* Campaign / template preview popup */
.preview {
padding: 0;
/* Contain the spinner background in the content area. */
position: relative;
#iframe {
border: 0;
width: 100%;
height: 100%;
min-height: 500px;
padding: 0;
margin: 0 0 -5px 0;
}
}
/* Campaign */
section.campaign {
header .buttons {
justify-content: flex-end;
}
}
/* Media gallery */
.media-files {
.thumbs {
display: flex;
flex-wrap: wrap;
flex-direction: column;
flex-flow: row wrap;
.thumb {
margin: 10px;
max-height: 90px;
overflow: hidden;
position: relative;
.caption {
background-color: rgba($white, .70);
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2px 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
background-color: rgba($white, .70);
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 2px 5px;
display: none;
a {
margin-left: 10px;
}
}
&:hover .actions {
display: block;
}
}
.box {
padding: 10px;
}
}
}
/* Template form */
.template-modal {
.template-modal-content {
height: 95vh;
max-height: none;
}
.textarea {
max-height: none;
height: 55vh;
}
}
.c3 {
.c3-chart-lines .c3-line {
stroke-width: 2px;
}
.c3-axis-x .tick line,
.c3-axis-y .tick line {
display: none;
}
text {
fill: $grey;
font-family: $body-family;
font-size: 11px;
}
.c3-axis path, .c3-axis line {
stroke: #eee;
}
.c3-tooltip {
border: 0;
background-color: #fff;
empty-cells: show;
box-shadow: none;
opacity: 0.9;
tr {
border: 0;
}
th {
background: $white;
}
}
}
@media screen and (max-width: 1450px) and (min-width: 769px) {
section.campaigns {
/* Fold the stats labels until the card view */
table tbody td {
.fields {
label {
margin: 0;
display: block;
text-align: left;
}
p {
margin-bottom: 10px !important;
}
}
}
}
}
@media screen and (max-width: 1023px) {
/* Hide sidebar menu captions on mobile */
.b-sidebar .sidebar-content.is-mini-mobile {
.menu-list li {
margin-bottom: 30px;
span:nth-child(2) {
display: none;
}
.icon.is-small {
scale: 1.4;
}
}
.logo {
text-align: center;
.full {
display: none;
}
.favicon {
display: block;
}
.version {
display: none;
}
}
}
#app > .content {
margin: 15px;
}
}
@media screen and (max-width: 840px) {
section.dashboard label {
min-width: auto;
}
}

View File

@ -0,0 +1,95 @@
<template>
<div>
<b-modal scroll="keep" @close="close"
:aria-modal="true" :active="isVisible">
<div>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4>{{ title }}</h4>
</header>
</div>
<section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading>
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
<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>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="close">Close</b-button>
</footer>
</div>
</b-modal>
</div>
</template>
<script>
import { uris } from '../constants';
export default {
name: 'CampaignPreview',
props: {
// Template or campaign ID.
id: Number,
title: String,
// campaign | template.
type: String,
body: String,
},
data() {
return {
isVisible: true,
isLoading: true,
};
},
methods: {
close() {
this.$emit('close');
this.isVisible = false;
},
// On iframe load, kill the spinner.
onLoaded(l) {
if (l.srcElement.contentWindow.location.href === 'about:blank') {
return;
}
this.isLoading = false;
},
},
computed: {
previewURL() {
let uri = 'about:blank';
if (this.type === 'campaign') {
uri = uris.previewCampaign;
} else if (this.type === 'template') {
if (this.id) {
uri = uris.previewTemplate;
} else {
uri = uris.previewRawTemplate;
}
}
return uri.replace(':id', this.id);
},
},
mounted() {
setTimeout(() => {
if (this.$refs.form) {
this.$refs.form.submit();
}
}, 100);
},
};
</script>

View File

@ -0,0 +1,187 @@
<template>
<!-- Two-way Data-Binding -->
<section class="editor">
<div class="columns">
<div class="column is-6">
<b-field label="Format">
<div>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="richtext">Rich text</b-radio>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="html">Raw HTML</b-radio>
</div>
</b-field>
</div>
<div class="column is-6 has-text-right">
<b-button @click="togglePreview" type="is-primary"
icon-left="file-find-outline">Preview</b-button>
</div>
</div>
<!-- wsywig //-->
<quill-editor
v-if="form.format === 'richtext'"
v-model="form.body"
ref="quill"
:options="options"
:disabled="disabled"
placeholder="Content here"
@change="onEditorChange($event)"
@ready="onEditorReady($event)"
/>
<!-- raw html editor //-->
<b-input v-if="form.format === 'html'"
@input="onEditorChange"
v-model="form.body" type="textarea" />
<!-- campaign preview //-->
<campaign-preview v-if="isPreviewing"
@close="togglePreview"
type='campaign'
:id='id'
:title='title'
:body="form.body"></campaign-preview>
<!-- image picker -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
<div class="modal-card content" style="width: auto">
<section expanded class="modal-card-body">
<media isModal @selected="onMediaSelect" />
</section>
</div>
</b-modal>
</section>
</template>
<script>
import 'quill/dist/quill.snow.css';
import 'quill/dist/quill.core.css';
import { quillEditor } from 'vue-quill-editor';
import CampaignPreview from './CampaignPreview.vue';
import Media from '../views/Media.vue';
export default {
components: {
Media,
CampaignPreview,
quillEditor,
},
props: {
id: Number,
title: String,
body: String,
contentType: String,
disabled: Boolean,
},
data() {
return {
isPreviewing: false,
isMediaVisible: false,
form: {
body: '',
format: this.contentType,
// Model bound to the checkboxes. This changes on click of the radio,
// but is reverted by the change handler if the user cancels the
// conversion warning. This is used to set the value of form.format
// that the editor uses to render content.
radioFormat: this.contentType,
},
// Quill editor options.
options: {
placeholder: 'Content here',
modules: {
toolbar: {
container: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike', 'blockquote', 'code'],
[{ color: [] }, { background: [] }, { size: [] }],
[
{ list: 'ordered' },
{ list: 'bullet' },
{ indent: '-1' },
{ indent: '+1' },
],
[
{ align: '' },
{ align: 'center' },
{ align: 'right' },
{ align: 'justify' },
],
['link', 'image'],
['clean', 'font'],
],
handlers: {
image: this.toggleMedia,
},
},
},
},
};
},
methods: {
onChangeFormat(format) {
this.$utils.confirm(
'The content may lose some formatting. Are you sure?',
() => {
this.form.format = format;
this.onEditorChange();
},
() => {
// On cancel, undo the radio selection.
this.form.radioFormat = format === 'richtext' ? 'html' : 'richtext';
},
);
},
onEditorReady() {
// Hack to focus the editor on page load.
this.$nextTick(() => {
window.setTimeout(() => this.$refs.quill.quill.focus(), 100);
});
},
onEditorChange() {
// The parent's v-model gets { contentType, body }.
this.$emit('input', { contentType: this.form.format, body: this.form.body });
},
togglePreview() {
this.isPreviewing = !this.isPreviewing;
},
toggleMedia() {
this.isMediaVisible = !this.isMediaVisible;
},
onMediaSelect(m) {
this.$refs.quill.quill.insertEmbed(10, 'image', m.uri);
},
},
watch: {
// Capture contentType and body passed from the parent as props.
contentType(f) {
this.form.format = f;
this.form.radioFormat = f;
// Trigger the change event so that the body and content type
// are propagated to the parent on first load.
this.onEditorChange();
},
body(b) {
this.form.body = b;
},
},
};
</script>

View File

@ -0,0 +1,22 @@
<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 ? 'Nothing here yet' : label }}</p>
</div>
</section>
</template>
<script>
export default {
name: 'EmptyPlaceholder',
props: {
icon: String,
label: String,
},
};
</script>

View File

@ -0,0 +1,111 @@
<template>
<div class="field">
<b-field :label="label + (selectedItems ? ` (${selectedItems.length})` : '')">
<div :class="classes">
<b-taglist>
<b-tag v-for="l in selectedItems"
:key="l.id"
:class="l.subscriptionStatus"
:closable="!disabled && l.subscriptionStatus !== 'unsubscribed'"
:data-id="l.id"
@close="removeList(l.id)">
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
</b-tag>
</b-taglist>
</div>
</b-field>
<b-field :message="message">
<b-autocomplete
:placeholder="placeholder"
clearable
dropdown-position="top"
:disabled="disabled || filteredLists.length === 0"
:keep-first="true"
:clear-on-select="true"
:open-on-focus="true"
:data="filteredLists"
@select="selectList"
field="name">
</b-autocomplete>
</b-field>
</div>
</template>
<script>
import Vue from 'vue';
export default {
name: 'ListSelector',
props: {
label: String,
placeholder: String,
message: String,
required: Boolean,
disabled: Boolean,
classes: {
type: Array,
default: () => [],
},
selected: {
type: Array,
default: () => [],
},
all: {
type: Array,
default: () => [],
},
},
data() {
return {
selectedItems: [],
};
},
methods: {
selectList(l) {
if (!l) {
return;
}
this.selectedItems.push(l);
// Propagate the items to the parent's v-model binding.
Vue.nextTick(() => {
this.$emit('input', this.selectedItems);
});
},
removeList(id) {
this.selectedItems = this.selectedItems.filter((l) => l.id !== id);
// Propagate the items to the parent's v-model binding.
Vue.nextTick(() => {
this.$emit('input', this.selectedItems);
});
},
},
computed: {
// Returns the list of lists to which the subscriber isn't subscribed.
filteredLists() {
// Get a map of IDs of the user subsciptions. eg: {1: true, 2: true};
const subIDs = this.selectedItems.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
// Filter lists from the global lists whose IDs are not in the user's
// subscribed ist.
return this.$props.all.filter((l) => !(l.id in subIDs));
},
},
watch: {
// This is required to update the array of lists to propagate from parent
// components and "react" on the selector.
selected() {
// Deep-copy.
this.selectedItems = JSON.parse(JSON.stringify(this.selected));
},
},
};
</script>

View File

@ -1,126 +1,27 @@
export const DateFormat = "ddd D MMM YYYY, hh:mm A"
export const models = Object.freeze({
dashboard: 'dashboard',
lists: 'lists',
subscribers: 'subscribers',
campaigns: 'campaigns',
templates: 'templates',
media: 'media',
});
// Data types.
export const ModelUsers = "users"
export const ModelSubscribers = "subscribers"
export const ModelSubscribersByList = "subscribersByList"
export const ModelLists = "lists"
export const ModelMedia = "media"
export const ModelCampaigns = "campaigns"
export const ModelTemplates = "templates"
// Ad-hoc URIs that are used outside of vuex requests.
export const uris = Object.freeze({
previewCampaign: '/api/campaigns/:id/preview',
previewTemplate: '/api/templates/:id/preview',
previewRawTemplate: '/api/templates/preview',
});
// HTTP methods.
export const MethodGet = "get"
export const MethodPost = "post"
export const MethodPut = "put"
export const MethodDelete = "delete"
// Keys used in Vuex store.
export const storeKeys = Object.freeze({
models: 'models',
isLoading: 'isLoading',
});
// Data loading states.
export const StatePending = "pending"
export const StateDone = "done"
export const timestamp = 'ddd D MMM YYYY, hh:mm A';
// Form types.
export const FormCreate = "create"
export const FormEdit = "edit"
// Message types.
export const MsgSuccess = "success"
export const MsgWarning = "warning"
export const MsgError = "error"
export const MsgPosition = "bottomRight"
// Model specific.
export const CampaignStatusColors = {
draft: "",
scheduled: "purple",
running: "blue",
paused: "orange",
finished: "green",
cancelled: "red"
}
export const CampaignStatusDraft = "draft"
export const CampaignStatusScheduled = "scheduled"
export const CampaignStatusRunning = "running"
export const CampaignStatusPaused = "paused"
export const CampaignStatusFinished = "finished"
export const CampaignStatusCancelled = "cancelled"
export const CampaignStatusRegular = "regular"
export const CampaignStatusOptin = "optin"
export const CampaignTypeRegular = "regular"
export const CampaignTypeOptin = "optin"
export const CampaignContentTypeRichtext = "richtext"
export const CampaignContentTypeHTML = "html"
export const CampaignContentTypePlain = "plain"
export const SubscriptionStatusConfirmed = "confirmed"
export const SubscriptionStatusUnConfirmed = "unconfirmed"
export const SubscriptionStatusUnsubscribed = "unsubscribed"
export const ListOptinSingle = "single"
export const ListOptinDouble = "double"
// API routes.
export const Routes = {
GetDashboarcStats: "/api/dashboard/stats",
GetUsers: "/api/users",
// Lists.
GetLists: "/api/lists",
CreateList: "/api/lists",
UpdateList: "/api/lists/:id",
DeleteList: "/api/lists/:id",
// Subscribers.
ViewSubscribers: "/subscribers",
GetSubscribers: "/api/subscribers",
GetSubscriber: "/api/subscribers/:id",
GetSubscribersByList: "/api/subscribers/lists/:listID",
PreviewCampaign: "/api/campaigns/:id/preview",
CreateSubscriber: "/api/subscribers",
UpdateSubscriber: "/api/subscribers/:id",
DeleteSubscriber: "/api/subscribers/:id",
DeleteSubscribers: "/api/subscribers",
SendSubscriberOptinMail: "/api/subscribers/:id/optin",
BlacklistSubscriber: "/api/subscribers/:id/blacklist",
BlacklistSubscribers: "/api/subscribers/blacklist",
AddSubscriberToLists: "/api/subscribers/lists/:id",
AddSubscribersToLists: "/api/subscribers/lists",
DeleteSubscribersByQuery: "/api/subscribers/query/delete",
BlacklistSubscribersByQuery: "/api/subscribers/query/blacklist",
AddSubscribersToListsByQuery: "/api/subscribers/query/lists",
// Campaigns.
ViewCampaigns: "/campaigns",
ViewCampaign: "/campaigns/:id",
GetCampaignMessengers: "/api/campaigns/messengers",
GetCampaigns: "/api/campaigns",
GetCampaign: "/api/campaigns/:id",
GetRunningCampaignStats: "/api/campaigns/running/stats",
CreateCampaign: "/api/campaigns",
TestCampaign: "/api/campaigns/:id/test",
UpdateCampaign: "/api/campaigns/:id",
UpdateCampaignStatus: "/api/campaigns/:id/status",
DeleteCampaign: "/api/campaigns/:id",
// Media.
GetMedia: "/api/media",
AddMedia: "/api/media",
DeleteMedia: "/api/media/:id",
// Templates.
GetTemplates: "/api/templates",
PreviewTemplate: "/api/templates/:id/preview",
PreviewNewTemplate: "/api/templates/preview",
CreateTemplate: "/api/templates",
UpdateTemplate: "/api/templates/:id",
SetDefaultTemplate: "/api/templates/:id/default",
DeleteTemplate: "/api/templates/:id",
// Import.
UploadRouteImport: "/api/import/subscribers",
GetRouteImportStats: "/api/import/subscribers",
GetRouteImportLogs: "/api/import/subscribers/logs"
}
export const colors = Object.freeze({
primary: '#7f2aff',
});

View File

@ -1,391 +0,0 @@
/* Disable all the ridiculous, unnecessary animations except for the spinner */
*:not(.ant-spin-dot-spin) {
animation-duration: 0s;
transition: none !important;
}
body {
font-weight: 400;
}
header.header {
margin-bottom: 30px;
}
hr {
border-width: 1px 0 0 0;
border-style: solid;
border-color: #eee;
margin: 30px 0;
}
/* Helpers */
.center {
text-align: center;
}
.right {
text-align: right;
}
.text-tiny {
font-size: 0.65em;
}
.text-small {
font-size: 0.85em;
}
.text-grey {
color: #999;
}
.hidden {
display: none;
}
.empty-spinner {
padding: 30px !important;
}
ul.no {
list-style-type: none;
}
ul.no li {
margin-bottom: 10px;
}
/* Layout */
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
.content-body {
min-height: 90vh;
}
section.content {
padding: 24px;
background: #fff;
}
.logo {
padding: 30px;
}
.logo a {
overflow: hidden;
display: inline-block;
}
.logo img {
width: auto;
height: 22px;
}
.ant-layout-sider.ant-layout-sider-collapsed .logo a {
width: 20px;
}
.ant-card-head-title {
font-size: .85em !important;
color: #999 !important;
}
.broken {
margin: 100px;
}
.hidden {
display: none;
}
/* Form */
.list-form .html {
background: #fafafa;
padding: 30px;
max-width: 100%;
overflow-y: auto;
max-height: 600px;
}
.list-form .lists label {
display: block;
}
/* Table actions */
td .actions a {
display: inline-block;
padding: 10px;
}
td.actions {
text-align: right;
}
td .ant-tag {
margin-top: 5px;
}
/* External options */
.table-options {
}
.table-options a {
margin-right: 30px;
}
/* Dashboard */
.dashboard {
margin: 24px;
}
.dashboard .campaign-counts .name {
text-transform: capitalize;
}
/* Templates */
.wysiwyg {
padding: 30px;
}
/* Subscribers */
.subscribers table .name {
margin-bottom: 10px;
}
.subscriber-query {
margin: 0 0 15px 0;
padding: 30px;
box-shadow: 0 1px 6px #ddd;
min-height: 140px;
}
.subscriber-query .advanced-query {
margin-top: 15px;
}
.subscriber-query textarea {
font-family: monospace;
}
.subscriber-query .actions {
margin-top: 15px;
}
.subscription-status {
color: #999;
}
.subscription-status.confirmed {
color: #52c41a;
}
.subscription-status.unsubscribed {
color: #ff7875;
}
/* Import */
.import .import-container {
margin-top: 100px;
}
.import .logs,
.import .help {
max-width: 950px;
margin-top: 30px;
}
.import .stats .ant-progress {
margin-bottom: 30px;
}
.import .csv-example {
background: #efefef;
padding: 5px 10px;
display: inline-block;
}
.import .csv-example code {
display: block;
}
.import .csv-example .csv-headers span {
font-weight: bold;
}
/* Campaigns */
.campaigns table tbody td {
vertical-align: top;
border-bottom-width: 3px;
border-bottom-color: #efefef;
}
.campaigns td.status .date {
display: block;
margin-top: 5px;
}
.campaigns td.lists .name {
margin-right: 15px;
}
.campaigns td hr {
margin: 10px 0;
}
.campaigns td.stats .ant-row {
border-bottom: 1px solid #eee;
padding: 5px 0;
}
.campaigns td.stats .ant-row:last-child {
border: 0;
}
.campaigns td.stats .label {
font-weight: 600;
color: #aaa;
}
.campaigns .duration {
text-transform: capitalize;
}
.campaign .messengers {
text-transform: capitalize;
}
.campaign .content-type .actions {
display: inline-block;
margin-left: 15px;
}
.campaign .content-actions {
margin-top: 30px;
}
/* gallery */
.gallery {
display:flex;
flex-direction: row;
flex-flow: wrap;
}
.gallery .image {
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
padding: 10px;
border: 1px solid #eee;
overflow: hidden;
margin: 10px;
text-align: center;
position: relative;
}
.gallery .name {
background: rgba(255, 255, 255, 0.8);
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 3px 5px;
width: 100%;
font-size: .75em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.gallery .actions {
position: absolute;
top: 10px;
right: 15px;
display: none;
text-align: center;
}
.gallery .actions a {
background: rgba(255, 255, 255, 0.9);
padding: 0 3px 3px 3px;
border-radius: 0 0 3px 3px;
display: inline-block;
margin-left: 5px;
}
.gallery .image:hover .actions {
display: block;
}
.gallery .image img {
max-width: 90px;
max-height: 90px;
display: block;
}
/* gallery icon in the wsiwyg */
.ql-gallery {
background: url('/gallery.svg');
}
/* templates */
.templates .template-body {
margin-top: 30px;
}
.preview-iframe-container {
min-height: 500px;
}
.preview-iframe-spinner {
position: absolute !important;
width: 40px;
height: 40px;
left: calc(50% - 40px);
top: calc(30%);
/* top: 15px; */
}
.preview-iframe {
border: 0;
width: 100%;
height: 100%;
min-height: 500px;
}
.preview-modal .ant-modal-footer button:first-child {
display: none;
}
@media screen and (max-width: 1200px) {
.dashboard .ant-card {
margin-bottom: 20px;
}
}
@media screen and (max-width: 1023px) {
.ant-table-content {
overflow-x: auto;
}
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td {
white-space: nowrap;
}
}
@media screen and (max-width: 768px) {
.ant-modal {
top: 0 !important;
}
hr {
margin: 20px 0;
}
.subscriber-query {
padding: 20px
}
.header-action-break {
text-align: left;
}
.subscribers.content .slc-subs-section .table-options {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f4f4f4;
}
.subscribers.content .slc-subs-actions a {
display: block;
margin-bottom: 5px;
}
.ant-modal.subscriber-modal .subscriber-export {
margin-top: 10px;
}
.ant-modal.subscriber-modal .subscriber-name {
display: block;
}
.dashboard {
margin: 24px 12px;
}
}

View File

@ -1,7 +0,0 @@
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App.js"
ReactDOM.render(<App />, document.getElementById("root"))

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

21
frontend/src/main.js Normal file
View File

@ -0,0 +1,21 @@
import Vue from 'vue';
import Buefy from 'buefy';
import App from './App.vue';
import router from './router';
import store from './store';
import * as api from './api';
import utils from './utils';
Vue.use(Buefy, {});
Vue.config.productionTip = false;
// Custom global elements.
Vue.prototype.$api = api;
Vue.prototype.$utils = utils;
new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app');

View File

@ -1,117 +0,0 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

@ -0,0 +1,95 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
// The meta.group param is used in App.vue to expand menu group by name.
const routes = [
{
path: '/',
name: 'dashboard',
meta: { title: 'Dashboard' },
component: () => import(/* webpackChunkName: "main" */ '../views/Dashboard.vue'),
},
{
path: '/lists',
name: 'lists',
meta: { title: 'Lists', group: 'lists' },
component: () => import(/* webpackChunkName: "main" */ '../views/Lists.vue'),
},
{
path: '/lists/forms',
name: 'forms',
meta: { title: 'Forms', group: 'lists' },
component: () => import(/* webpackChunkName: "main" */ '../views/Forms.vue'),
},
{
path: '/subscribers',
name: 'subscribers',
meta: { title: 'Subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
},
{
path: '/subscribers/import',
name: 'import',
meta: { title: 'Import subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Import.vue'),
},
{
path: '/subscribers/lists/:listID',
name: 'subscribers_list',
meta: { title: 'Subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
},
{
path: '/subscribers/:id',
name: 'subscriber',
meta: { title: 'Subscribers', group: 'subscribers' },
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
},
{
path: '/campaigns',
name: 'campaigns',
meta: { title: 'Campaigns', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Campaigns.vue'),
},
{
path: '/campaigns/media',
name: 'media',
meta: { title: 'Media', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Media.vue'),
},
{
path: '/campaigns/templates',
name: 'templates',
meta: { title: 'Templates', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
},
{
path: '/campaigns/:id',
name: 'campaign',
meta: { title: 'Campaign', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Campaign.vue'),
},
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
scrollBehavior(to) {
if (to.hash) {
return { selector: to.hash };
}
return { x: 0, y: 0 };
},
});
router.afterEach((to) => {
Vue.nextTick(() => {
document.title = to.meta.title;
});
});
export default router;

View File

@ -1 +0,0 @@
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>

Before

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,48 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { models } from '../constants';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
// Data from API responses for different models, eg: lists, campaigns.
// The API responses are stored in this map as-is. This is invoked by
// API requests in `http`. This initialises lists: {}, campaigns: {}
// etc. on state.
...Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: [] }), {}),
// Map of loading status (true, false) indicators for different model keys
// like lists, campaigns etc. loading: {lists: true, campaigns: true ...}.
// The Axios API global request interceptor marks a model as loading=true
// and the response interceptor marks it as false. The model keys are being
// pre-initialised here to fix "reactivity" issues on first loads.
loading: Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: false }), {}),
},
mutations: {
// Set data from API responses. `model` is 'lists', 'campaigns' etc.
setModelResponse(state, { model, data }) {
state[model] = data;
},
// Set the loading status for a model globally. When a request starts,
// status is set to true which is used by the UI to show loaders and block
// forms. When a response is received, the status is set to false. This is
// invoked by API requests in `http`.
setLoading(state, { model, status }) {
state.loading[model] = status;
},
},
getters: {
[models.lists]: (state) => state[models.lists],
[models.subscribers]: (state) => state[models.subscribers],
[models.campaigns]: (state) => state[models.campaigns],
[models.media]: (state) => state[models.media],
[models.templates]: (state) => state[models.templates],
},
modules: {
},
});

View File

@ -1,82 +1,96 @@
import React from "react"
import ReactDOM from "react-dom"
import {
ToastProgrammatic as Toast,
DialogProgrammatic as Dialog,
} from 'buefy';
import { Alert } from "antd"
const reEmail = /(.+?)@(.+?)/ig;
class Utils {
static months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
]
static days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
export default class utils {
static months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
'Sep', 'Oct', 'Nov', 'Dec'];
// Converts the ISO date format to a simpler form.
static DateString = (stamp, showTime) => {
static days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Parses an ISO timestamp to a simpler form.
static niceDate = (stamp, showTime) => {
if (!stamp) {
return ""
return '';
}
let d = new Date(stamp)
let out =
Utils.days[d.getDay()] +
", " +
d.getDate() +
" " +
Utils.months[d.getMonth()] +
" " +
d.getFullYear()
const d = new Date(stamp);
let out = `${utils.days[d.getDay()]}, ${d.getDate()}`;
out += ` ${utils.months[d.getMonth()]} ${d.getFullYear()}`;
if (showTime) {
out += " " + d.getHours() + ":" + d.getMinutes()
out += ` ${d.getHours()}:${d.getMinutes()}`;
}
return out
return out;
};
// Simple, naive, e-mail address check.
static validateEmail = (e) => e.match(reEmail);
static niceNumber = (n) => {
if (n === null || n === undefined) {
return 0;
}
// HttpError takes an axios error and returns an error dict after some sanity checks.
static HttpError = err => {
if (!err.response) {
return err
let pfx = '';
let div = 1;
if (n >= 1.0e+9) {
pfx = 'b';
div = 1.0e+9;
} else if (n >= 1.0e+6) {
pfx = 'm';
div = 1.0e+6;
} else if (n >= 1.0e+4) {
pfx = 'k';
div = 1.0e+3;
} else {
return n;
}
if (!err.response.data || !err.response.data.message) {
return {
message: err.message + " - " + err.response.request.responseURL,
data: {}
}
// Whole number without decimals.
const out = (n / div);
if (Math.floor(out) === n) {
return out + pfx;
}
return {
message: err.response.data.message,
data: err.response.data.data
}
return out.toFixed(2) + pfx;
}
// Shows a flash message.
static Alert = (msg, msgType) => {
document.getElementById("alert-container").classList.add("visible")
ReactDOM.render(
<Alert message={msg} type={msgType} showIcon />,
document.getElementById("alert-container")
)
}
static ModalAlert = (msg, msgType) => {
document.getElementById("modal-alert-container").classList.add("visible")
ReactDOM.render(
<Alert message={msg} type={msgType} showIcon />,
document.getElementById("modal-alert-container")
)
}
}
// UI shortcuts.
static confirm = (msg, onConfirm, onCancel) => {
Dialog.confirm({
scroll: 'keep',
message: !msg ? 'Are you sure?' : msg,
onConfirm,
onCancel,
});
};
export default Utils
static prompt = (msg, inputAttrs, onConfirm, onCancel) => {
Dialog.prompt({
scroll: 'keep',
message: msg,
confirmText: 'OK',
inputAttrs: {
type: 'string',
maxlength: 200,
...inputAttrs,
},
trapFocus: true,
onConfirm,
onCancel,
});
};
static toast = (msg, typ) => {
Toast.open({
message: msg,
type: !typ ? 'is-success' : typ,
queue: false,
});
};
}

View File

@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@ -0,0 +1,369 @@
<template>
<section class="campaign">
<header class="columns">
<div class="column is-8">
<p v-if="isEditing" class="tags">
<b-tag v-if="isEditing" :class="data.status">{{ data.status }}</b-tag>
<b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag>
<span v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
</span>
</p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
<h4 v-else class="title is-4">New campaign</h4>
</div>
<div class="column">
<div class="buttons" v-if="isEditing && canEdit">
<b-button @click="onSubmit" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline">Save changes</b-button>
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline">
Start campaign
</b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start">
Schedule campaign
</b-button>
</div>
</div>
</header>
<b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab">
<b-tab-item label="Campaign" icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="onSubmit">
<b-field label="Name">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Subject">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
placeholder="Subject" required></b-input>
</b-field>
<b-field label="From address">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
placeholder="Your Name <noreply@yoursite.com>" required></b-input>
</b-field>
<list-selector
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
:disabled="!canEdit"
label="Lists"
placeholder="Lists to send to"
></list-selector>
<b-field label="Template">
<b-select placeholder="Template" v-model="form.templateId"
:disabled="!canEdit" required>
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
</b-select>
</b-field>
<b-field label="Tags">
<b-taginput v-model="form.tags" :disabled="!canEdit"
ellipsis icon="tag-outline" placeholder="Tags"></b-taginput>
</b-field>
<hr />
<b-field label="Send later?">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
</b-field>
<b-field v-if="form.sendLater" label="Send at">
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
placeholder="Date and time"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
horizontal-time-picker>
</b-datetimepicker>
</b-field>
<hr />
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary"
:loading="loading.campaigns">Continue</b-button>
</b-field>
</form>
</div>
<div class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">Send test message</h3>
<b-field message="Hit Enter after typing an address to add multiple recipients.
The addresses must belong to existing subscribers.">
<b-taginput v-model="form.testEmails"
:before-adding="$utils.validateEmail" :disabled="this.isNew"
ellipsis icon="email-outline" placeholder="E-mails"></b-taginput>
</b-field>
<b-field>
<b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew"
type="is-primary" icon-left="email-outline">Send</b-button>
</b-field>
</div>
</div>
</div>
</section>
</b-tab-item><!-- campaign -->
<b-tab-item label="Content" icon="text" :disabled="isNew">
<section class="wrap">
<editor
v-model="form.content"
:id="data.id"
:title="data.name"
:contentType="data.contentType"
:body="data.body"
:disabled="!canEdit"
/>
</section>
</b-tab-item><!-- content -->
</b-tabs>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
import ListSelector from '../components/ListSelector.vue';
import Editor from '../components/Editor.vue';
export default Vue.extend({
components: {
ListSelector,
Editor,
},
data() {
return {
isNew: false,
isEditing: false,
activeTab: 0,
data: {},
// Binds form input values.
form: {
name: '',
subject: '',
fromEmail: window.CONFIG.fromEmail,
templateId: 0,
lists: [],
tags: [],
sendAt: null,
content: { contentType: 'richtext', body: '' },
// Parsed Date() version of send_at from the API.
sendAtDate: null,
sendLater: false,
testEmails: [],
},
};
},
methods: {
formatDateTime(s) {
return dayjs(s).format('YYYY-MM-DD HH:mm');
},
getCampaign(id) {
return this.$api.getCampaign(id).then((r) => {
this.data = r.data;
this.form = { ...this.form, ...r.data };
if (r.data.sendAt !== null) {
this.form.sendLater = true;
this.form.sendAtDate = dayjs(r.data.sendAt).toDate();
}
});
},
sendTest() {
const data = {
id: this.data.id,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
content_type: 'richtext',
messenger: 'email',
type: 'regular',
tags: this.form.tags,
template_id: this.form.templateId,
body: this.form.body,
subscribers: this.form.testEmails,
};
this.$api.testCampaign(data).then(() => {
this.$utils.toast('Test message sent');
});
return false;
},
onSubmit() {
if (this.isNew) {
this.createCampaign();
} else {
this.updateCampaign();
}
},
createCampaign() {
const data = {
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
content_type: 'richtext',
messenger: 'email',
type: 'regular',
tags: this.form.tags,
template_id: this.form.templateId,
// body: this.form.body,
};
this.$api.createCampaign(data).then((r) => {
this.$router.push({ name: 'campaign', hash: '#content', params: { id: r.data.id } });
// this.data = r.data;
// this.isEditing = true;
// this.isNew = false;
// this.activeTab = 1;
});
return false;
},
async updateCampaign(typ) {
const data = {
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
from_email: this.form.fromEmail,
messenger: 'email',
type: 'regular',
tags: this.form.tags,
send_later: this.form.sendLater,
send_at: this.form.sendLater ? this.form.sendAtDate : null,
template_id: this.form.templateId,
content_type: this.form.content.contentType,
body: this.form.content.body,
};
let typMsg = 'updated';
if (typ === 'start') {
typMsg = 'started';
}
// This promise is used by startCampaign to first save before starting.
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((resp) => {
this.data = resp.data;
this.$buefy.toast.open({
message: `'${resp.data.name}' ${typMsg}`,
type: 'is-success',
queue: false,
});
resolve();
});
});
},
// Starts or schedule a campaign.
startCampaign() {
let status = '';
if (this.canStart) {
status = 'running';
} else if (this.canSchedule) {
status = 'scheduled';
} else {
return;
}
this.$utils.confirm(null,
() => {
// First save the campaign.
this.updateCampaign().then(() => {
// Then start/schedule it.
this.$api.changeCampaignStatus(this.data.id, status).then(() => {
this.$router.push({ name: 'campaigns' });
});
});
});
},
},
computed: {
...mapState(['lists', 'templates', 'loading']),
canEdit() {
return this.isNew
|| this.data.status === 'draft' || this.data.status === 'scheduled';
},
canSchedule() {
return this.data.status === 'draft' && this.data.sendAt;
},
canStart() {
return this.data.status === 'draft' && !this.data.sendAt;
},
},
mounted() {
const { id } = this.$route.params;
// New campaign.
if (id === 'new') {
this.isNew = true;
} else {
const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) {
this.$buefy.toast.open({
message: 'Invalid campaign',
type: 'is-danger',
queue: false,
});
return;
}
this.isEditing = true;
}
// Get templates list.
this.$api.getTemplates().then((r) => {
if (r.data.length > 0) {
if (!this.form.templateId) {
this.form.templateId = r.data.find((i) => i.isDefault === true).id;
}
}
});
// Fetch campaign.
if (this.isEditing) {
this.getCampaign(id).then(() => {
if (this.$route.hash === '#content') {
this.activeTab = 1;
}
});
}
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View File

@ -0,0 +1,365 @@
<template>
<section class="campaigns">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Campaigns
<span v-if="campaigns.total > 0">({{ campaigns.total }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link"
type="is-primary" icon-left="plus">New</b-button>
</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>
<template slot-scope="props">
<b-table-column class="status" field="status" label="Status"
width="10%" :id="props.row.id">
<div>
<p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
<span class="spinner is-tiny" v-if="isRunning(props.row.id)">
<b-loading :is-full-page="false" active />
</span>
</router-link>
</p>
<p v-if="isSheduled(props.row)">
<b-tooltip label="Scheduled" type="is-dark">
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
{{ $utils.niceDate(props.row.sendAt, true) }}
</span>
</b-tooltip>
</p>
</div>
</b-table-column>
<b-table-column field="name" label="Name" sortable width="25%">
<div>
<p>
<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>
<b-taglist>
<b-tag v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
</b-taglist>
</div>
</b-table-column>
<b-table-column class="lists" field="lists" label="Lists" width="15%">
<ul class="no">
<li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
{{ l.name }}
</router-link>
</li>
</ul>
</b-table-column>
<b-table-column field="updatedAt" label="Timestamps" width="19%" sortable>
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label>Created</label>
{{ $utils.niceDate(props.row.createdAt, true) }}
</p>
<p v-if="stats.startedAt">
<label>Started</label>
{{ $utils.niceDate(stats.startedAt, true) }}
</p>
<p v-if="isDone(props.row)">
<label>Ended</label>
{{ $utils.niceDate(stats.updatedAt, true) }}
</p>
<p v-if="stats.startedAt && stats.updatedAt"
class="is-capitalized" title="Duration">
<label><b-icon icon="alarm" size="is-small" /></label>
{{ duration(stats.startedAt, stats.updatedAt) }}
</p>
</div>
</b-table-column>
<b-table-column :class="props.row.status" label="Stats" width="18%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label>Views</label>
{{ props.row.views }}
</p>
<p>
<label>Clicks</label>
{{ props.row.clicks }}
</p>
<p>
<label>Sent</label>
{{ stats.sent }} / {{ stats.toSend }}
</p>
<p title="Speed" v-if="stats.rate">
<label><b-icon icon="speedometer" size="is-small"></b-icon></label>
<span class="send-rate">
{{ stats.rate }} / min
</span>
</p>
<p v-if="isRunning(props.row.id)">
<label>Progress
<span class="spinner is-tiny">
<b-loading :is-full-page="false" active />
</span>
</label>
<b-progress :value="stats.sent / stats.toSend * 100" size="is-small" />
</p>
</div>
</b-table-column>
<b-table-column class="actions" width="13%" align="right">
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Start" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canPause(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'paused'))">
<b-tooltip label="Pause" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canResume(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Send" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canSchedule(props.row)"
@click.prevent="$utils.confirm(`This campaign will start automatically at the
scheduled date and time. Schedule now?`,
() => changeCampaignStatus(props.row, 'scheduled'))">
<b-tooltip label="Schedule" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="previewCampaign(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone campaign`,
{ placeholder: 'Campaign name', value: `Copy of ${props.row.name}`},
(name) => cloneCampaign(name, props.row))">
<b-tooltip label="Clone" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))">
<b-tooltip label="Cancel" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canDelete(props.row)"
@click.prevent="$utils.confirm(`Delete '${props.row.name}'?`,
() => deleteCampaign(props.row))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.campaigns">
<empty-placeholder />
</template>
</b-table>
<campaign-preview v-if="previewItem"
type='campaign'
:id="previewItem.id"
:title="previewItem.name"
@close="closePreview"></campaign-preview>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import CampaignPreview from '../components/CampaignPreview.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
dayjs.extend(relativeTime);
export default Vue.extend({
components: {
CampaignPreview,
EmptyPlaceholder,
},
data() {
return {
previewItem: null,
queryParams: {
page: 1,
},
pollID: null,
campaignStatsData: {},
};
},
methods: {
// Campaign statuses.
canStart(c) {
return c.status === 'draft' && !c.sendAt;
},
canSchedule(c) {
return c.status === 'draft' && c.sendAt;
},
canPause(c) {
return c.status === 'running';
},
canCancel(c) {
return c.status === 'running' || c.status === 'paused';
},
canResume(c) {
return c.status === 'paused';
},
canDelete(c) {
return c.status === 'draft' || c.status === 'scheduled';
},
isSheduled(c) {
return c.status === 'scheduled' || c.sendAt !== null;
},
isDone(c) {
return c.status === 'finished' || c.status === 'cancelled';
},
isRunning(id) {
if (id in this.campaignStatsData) {
return true;
}
return false;
},
highlightedRow(data) {
if (data.status === 'running') {
return ['running'];
}
return '';
},
duration(start, end) {
return dayjs(end).from(dayjs(start), true);
},
onPageChange(p) {
this.queryParams.page = p;
this.getCampaigns();
},
// Campaign actions.
previewCampaign(c) {
this.previewItem = c;
},
closePreview() {
this.previewItem = null;
},
getCampaigns() {
this.$api.getCampaigns({
page: this.queryParams.page,
});
},
// Stats returns the campaign object with stats (sent, toSend etc.)
// if there's live stats availabe for running campaigns. Otherwise,
// it returns the incoming campaign object that has the static stats
// values.
getCampaignStats(c) {
if (c.id in this.campaignStatsData) {
return this.campaignStatsData[c.id];
}
return c;
},
pollStats() {
// Clear any running status polls.
clearInterval(this.pollID);
// Poll for the status as long as the import is running.
this.pollID = setInterval(() => {
this.$api.getCampaignStats().then((r) => {
// Stop polling. No running campaigns.
if (r.data.length === 0) {
clearInterval(this.pollID);
// There were running campaigns and stats earlier. Clear them
// and refetch the campaigns list with up-to-date fields.
if (Object.keys(this.campaignStatsData).length > 0) {
this.getCampaigns();
this.campaignStatsData = {};
}
} else {
// Turn the list of campaigns [{id: 1, ...}, {id: 2, ...}] into
// a map indexed by the id: {1: {}, 2: {}}.
this.campaignStatsData = r.data.reduce((obj, cur) => ({ ...obj, [cur.id]: cur }), {});
}
}, () => {
clearInterval(this.pollID);
});
}, 1000);
},
changeCampaignStatus(c, status) {
this.$api.changeCampaignStatus(c.id, status).then(() => {
this.$utils.toast(`'${c.name}' is ${status}`);
this.getCampaigns();
this.pollStats();
});
},
cloneCampaign(name, c) {
const data = {
name,
subject: c.subject,
lists: c.lists.map((l) => l.id),
type: c.type,
from_email: c.fromEmail,
content_type: c.contentType,
messenger: c.messenger,
tags: c.tags,
template_id: c.templateId,
body: c.body,
};
this.$api.createCampaign(data).then((r) => {
this.$router.push({ name: 'campaign', params: { id: r.data.id } });
});
},
deleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns();
this.$utils.toast(`'${c.name}' deleted`);
});
},
},
computed: {
...mapState(['campaigns', 'loading']),
},
mounted() {
this.getCampaigns();
this.pollStats();
},
destroyed() {
clearInterval(this.pollID);
},
});
</script>

View File

@ -0,0 +1,218 @@
<template>
<section class="dashboard content">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-5">{{ dayjs().format("ddd, DD MMM") }}</h1>
</div>
</header>
<section class="counts wrap-small">
<div class="tile is-ancestor">
<div class="tile is-vertical is-12">
<div class="tile">
<div class="tile is-parent is-vertical relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
<p class="is-size-6 has-text-grey">Lists</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
single opt-in
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
double opt-in</li>
</ul>
</div>
</div>
</article><!-- lists -->
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
<p class="is-size-6 has-text-grey">Campaigns</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
<label>{{ num }}</label> {{ status }}
</li>
</ul>
</div>
</div>
</article><!-- campaigns -->
</div><!-- block -->
<div class="tile is-parent relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
<p class="is-size-6 has-text-grey">Subscribers</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.subscribers.blacklisted) }}</label>
blacklisted
</li>
<li>
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
orphans
</li>
</ul>
</div><!-- subscriber breakdown -->
</div><!-- subscriber columns -->
<hr />
<div class="columns">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
<p class="is-size-6 has-text-grey">Messages sent</p>
</div>
</div>
</article><!-- subscribers -->
</div>
</div>
<div class="tile is-parent relative">
<b-loading v-if="isChartsLoading" active :is-full-page="false" />
<article class="tile is-child notification charts">
<div class="columns">
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">Campaign views</h3>
<vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">Link clicks</h3>
<vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
</div>
</article>
</div>
</div>
</div><!-- tile block -->
</section>
</section>
</template>
<style lang="css">
@import "~c3/c3.css";
</style>
<script>
import Vue from 'vue';
import VueC3 from 'vue-c3';
import dayjs from 'dayjs';
import { colors } from '../constants';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
VueC3,
},
data() {
return {
// Unique Vue() instances for each chart.
chartViewsInst: null,
chartClicksInst: null,
isChartsLoading: true,
isCountsLoading: true,
counts: {
lists: {},
subscribers: {},
campaigns: {},
messages: 0,
},
};
},
methods: {
makeChart(label, data) {
const conf = {
data: {
columns: [
[label, ...data.map((d) => d.count).reverse()],
],
type: 'spline',
color() {
return colors.primary;
},
},
axis: {
x: {
type: 'category',
categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
tick: {
rotate: -45,
multiline: false,
culling: { max: 10 },
},
},
},
legend: {
show: false,
},
};
return conf;
},
},
computed: {
dayjs() {
return dayjs;
},
},
mounted() {
// Pull the counts.
this.$api.getDashboardCounts().then((r) => {
this.counts = r.data;
this.isCountsLoading = false;
});
// Pull the charts.
this.$api.getDashboardCharts().then((r) => {
this.isChartsLoading = false;
// vue-c3 lib requires unique instances of Vue() to communicate.
if (r.data.campaignViews.length > 0) {
this.chartViewsInst = this;
this.$nextTick(() => {
this.chartViewsInst.$emit('init',
this.makeChart('Campaign views', r.data.campaignViews));
});
}
if (r.data.linkClicks.length > 0) {
this.chartClicksInst = new Vue();
this.$nextTick(() => {
this.chartClicksInst.$emit('init',
this.makeChart('Link clicks', r.data.linkClicks));
});
}
});
},
});
</script>

View File

@ -0,0 +1,88 @@
<template>
<section class="forms content relative">
<h1 class="title is-4">Forms</h1>
<hr />
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
<div class="columns" v-else-if="publicLists.length > 0">
<div class="column is-4">
<h4>Public lists</h4>
<p>Select lists to add to the form.</p>
<b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no">
<li v-for="l in publicLists" :key="l.id">
<b-checkbox v-model="checked"
:native-value="l.uuid">{{ l.name }}</b-checkbox>
</li>
</ul>
</div>
<div class="column">
<h4>Form HTML</h4>
<p>
Use the following HTML to show a subscription form on an external webpage.
</p>
<p>
The form should have the <code>email</code> field and one or more <code>l</code>
(list UUID) fields. The <code>name</code> field is optional.
</p>
<pre><!-- eslint-disable max-len -->&lt;form method=&quot;post&quot; action=&quot;http://localhost:9000/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
&lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;E-mail&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;Name (optional)&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; value=&quot;{{ uuid }}&quot; /&gt;
&lt;label for=&quot;{{ id }}&quot;&gt;{{ l.name }}&lt;/label&gt;
&lt;/p&gt;</span></template>
&lt;p&gt;&lt;input type=&quot;submit&quot; value=&quot;Subscribe&quot; /&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/form&gt;</pre>
</div>
</div><!-- columns -->
<p v-else>There are no public lists to create forms.</p>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
name: 'ListForm',
data() {
return {
checked: [],
};
},
methods: {
getPublicLists(lists) {
console.log(lists.filter((l) => l.type === 'public'));
return lists.filter((l) => l.type === 'public');
},
},
computed: {
...mapState(['lists', 'loading']),
publicLists() {
if (!this.lists.results) {
return [];
}
return this.lists.results.filter((l) => l.type === 'public');
},
selected() {
const sel = [];
this.checked.forEach((uuid) => {
sel[uuid] = true;
});
return sel;
},
},
});
</script>

View File

@ -0,0 +1,298 @@
<template>
<section class="import">
<h1 class="title is-4">Import subscribers</h1>
<b-loading :active="isLoading"></b-loading>
<section v-if="isFree()" class="wrap-small">
<form @submit.prevent="onSubmit" class="box">
<div>
<b-field label="Mode">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe">Subscribe</b-radio>
<b-radio v-model="form.mode" name="mode"
native-value="blacklist">Blacklist</b-radio>
</div>
</b-field>
<list-selector
label="Lists"
placeholder="Lists to subscribe to"
message="Lists to subscribe to."
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<hr />
<b-field label="CSV delimiter" message="Default delimiter is comma."
class="delimiter">
<b-input v-model="form.delim" name="delim"
placeholder="," maxlength="1" required />
</b-field>
<b-field label="CSV or ZIP file"
message="For existing subscribers, the names and attributes
will be overwritten with the values in the CSV.">
<b-upload v-model="form.file" drag-drop expanded required>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
</p>
<p>Click or drag a CSV or ZIP file here</p>
</div>
</b-upload>
</b-field>
<div class="tags" v-if="form.file">
<b-tag size="is-medium" closable @close="clearFile">
{{ form.file.name }}
</b-tag>
</div>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
:disabled="form.lists.length === 0"
:loading="isProcessing">Upload</b-button>
</div>
</div>
</form>
<hr />
<div class="import-help">
<h5 class="title is-size-6">Instructions</h5>
<p>
Upload a CSV file or a ZIP file with a single CSV file in it to bulk
import subscribers. The CSV file should have the following headers
with the exact column names. <code>attributes</code> (optional)
should be a valid JSON string with double escaped quotes.
</p>
<br />
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>attributes</span>
</code>
</blockquote>
<hr />
<h5 class="title is-size-6">Example raw CSV</h5>
<blockquote className="csv-example">
<code className="csv-headers">
<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 />
<code className="csv-row">
<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>
<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>{{ status.imported }} / {{ status.total }} records</p>
<br />
<p>
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline"
type="is-primary">{{ isDone() ? 'Done' : 'Stop import' }}</b-button>
</p>
<br />
<p>
<b-input v-model="logs" id="import-log" class="logs"
type="textarea" readonly placeholder="Import log" />
</p>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
export default Vue.extend({
components: {
ListSelector,
},
props: {
data: {},
isEditing: null,
},
data() {
return {
form: {
mode: 'subscribe',
delim: ',',
lists: [],
file: null,
},
// Initial page load still has to wait for the status API to return
// to either show the form or the status box.
isLoading: true,
isProcessing: false,
status: { status: '' },
logs: '',
pollID: null,
};
},
methods: {
clearFile() {
this.form.file = null;
},
// Returns true if we're free to do an upload.
isFree() {
if (this.status.status === 'none') {
return true;
}
return false;
},
// Returns true if an import is running.
isRunning() {
if (this.status.status === 'importing'
|| this.status.status === 'stopping') {
return true;
}
return false;
},
isSuccessful() {
return this.status.status === 'finished';
},
isFailed() {
return (
this.status.status === 'stopped'
|| this.status.status === 'failed'
);
},
// Returns true if an import has finished (failed or sucessful).
isDone() {
if (this.status.status === 'finished'
|| this.status.status === 'stopped'
|| this.status.status === 'failed'
) {
return true;
}
return false;
},
pollStatus() {
// Clear any running status polls.
clearInterval(this.pollID);
// Poll for the status as long as the import is running.
this.pollID = setInterval(() => {
this.$api.getImportStatus().then((r) => {
this.isProcessing = false;
this.isLoading = false;
this.status = r.data;
this.getLogs();
if (!this.isRunning()) {
clearInterval(this.pollID);
}
}, () => {
this.isProcessing = false;
this.isLoading = false;
this.status = { status: 'none' };
clearInterval(this.pollID);
});
return true;
}, 250);
},
getLogs() {
this.$api.getImportLogs().then((r) => {
this.logs = r.data;
Vue.nextTick(() => {
// vue.$refs doesn't work as the logs textarea is rendered dynamiaclly.
const ref = document.getElementById('import-log');
if (ref) {
ref.scrollTop = ref.scrollHeight;
}
});
});
},
// Cancel a running import or clears a finished import.
stopImport() {
this.isProcessing = true;
this.$api.stopImport().then(() => {
this.pollStatus();
});
},
onSubmit() {
this.isProcessing = true;
// Prepare the upload payload.
const params = new FormData();
params.set('params', JSON.stringify({
mode: this.form.mode,
delim: this.form.delim,
lists: this.form.lists.map((l) => l.id),
}));
params.set('file', this.form.file);
// Make the API request.
this.$api.importSubscribers(params).then(() => {
// On file upload, show a confirmation.
this.$buefy.toast.open({
message: 'Import started',
type: 'is-success',
queue: false,
});
// Start polling status.
this.pollStatus();
}, () => {
this.isProcessing = false;
});
},
},
computed: {
...mapState(['lists']),
// Import progress bar value.
progress() {
if (!this.status) {
return 0;
}
return Math.ceil((this.status.imported / this.status.total) * 100);
},
},
mounted() {
this.pollStatus();
},
});
</script>

View File

@ -0,0 +1,118 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">{{ data.type }}</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New list</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
</p>
</header>
<section expanded class="modal-card-body">
<b-field label="Name">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Type"
message="Public lists are open to the world to subscribe
and their names may appear on public pages such as the subscription
management page.">
<b-select v-model="form.type" placeholder="Type" required>
<option value="private">Private</option>
<option value="public">Public</option>
</b-select>
</b-field>
<b-field label="Opt-in"
message="Double opt-in sends an e-mail to the subscriber asking for
confirmation. On Double opt-in lists, campaigns are only sent to
confirmed subscribers.">
<b-select v-model="form.optin" placeholder="Opt-in type" required>
<option value="single">Single</option>
<option value="double">Double</option>
</b-select>
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.lists">Save</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
name: 'ListForm',
props: {
data: {},
isEditing: null,
},
data() {
return {
// Binds form input values.
form: {
name: '',
type: '',
optin: '',
},
};
},
methods: {
onSubmit() {
if (this.isEditing) {
this.updateList();
return;
}
this.createList();
},
createList() {
this.$api.createList(this.form).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' created`,
type: 'is-success',
queue: false,
});
});
},
updateList() {
this.$api.updateList({ id: this.data.id, ...this.form }).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' updated`,
type: 'is-success',
queue: false,
});
});
},
},
computed: {
...mapState(['loading']),
},
mounted() {
this.form = { ...this.$props.data };
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View File

@ -0,0 +1,153 @@
<template>
<section class="lists">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Lists <span v-if="lists.total > 0">({{ lists.total }})</span></h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
</div>
</header>
<b-table
:data="lists.results"
:loading="loading.lists"
hoverable
default-sort="createdAt">
<template slot-scope="props">
<b-table-column field="name" label="Name" sortable>
<router-link :to="{name: 'subscribers_list', params: { listID: props.row.id }}">
{{ props.row.name }}
</router-link>
</b-table-column>
<b-table-column field="type" label="Type" sortable>
<b-tag :class="props.row.type">{{ props.row.type }}</b-tag>
{{ ' ' }}
<b-tag>
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
{{ ' ' }}
{{ props.row.optin }}
</b-tag>{{ ' ' }}
<router-link :to="{name: 'campaign', params: {id: 'new'},
query: {type: 'optin', 'list_id': props.row.id}}"
v-if="props.row.optin === 'double'" class="is-size-7 send-optin">
<b-tooltip label="Send opt-in campaign" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
Send opt-in campaign
</b-tooltip>
</router-link>
</b-table-column>
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ props.row.subscriberCount }}
</router-link>
</b-table-column>
<b-table-column field="createdAt" label="Created" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<router-link :to="`/campaign/new?list_id=${props.row.id}`">
<b-tooltip label="Send campaign" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="deleteList(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.lists">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="450">
<list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListForm from './ListForm.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
components: {
ListForm,
EmptyPlaceholder,
},
data() {
return {
// Current list item being edited.
curItem: null,
isEditing: false,
isFormVisible: false,
};
},
methods: {
// Show the edit list form.
showEditForm(list) {
this.curItem = list;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new list form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.$api.getLists();
},
deleteList(list) {
this.$utils.confirm(
'Are you sure? This does not delete subscribers.',
() => {
this.$api.deleteList(list.id).then(() => {
this.$api.getLists();
this.$buefy.toast.open({
message: `'${list.name}' deleted`,
type: 'is-success',
queue: false,
});
});
},
);
},
},
computed: {
...mapState(['lists', 'loading']),
},
mounted() {
this.$api.getLists();
},
});
</script>

View File

@ -0,0 +1,179 @@
<template>
<section class="media-files">
<h1 class="title is-4">Media
<span v-if="media.length > 0">({{ media.length }})</span>
</h1>
<b-loading :active="isProcessing || loading.media"></b-loading>
<section class="wrap-small">
<form @submit.prevent="onSubmit" class="box">
<div>
<b-field label="Upload image">
<b-upload
v-model="form.files"
drag-drop
multiple
accept=".png,.jpg,.jpeg,.gif"
expanded required>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
</p>
<p>Click or drag one or more images here</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)">
{{ 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">Upload</b-button>
</div>
</div>
</form>
</section>
<section class="section gallery">
<div v-for="group in items" :key="group.title">
<h3 class="title is-5">{{ group.title }}</h3>
<div class="thumbs">
<div v-for="m in group.items" :key="m.id" class="box thumb">
<a @click="(e) => onMediaSelect(m, e)" :href="m.uri" target="_blank">
<img :src="m.thumbUri" :title="m.filename" />
</a>
<span class="caption is-size-7" :title="m.filename">{{ m.filename }}</span>
<div class="actions has-text-right">
<a :href="m.uri" target="_blank">
<b-icon icon="arrow-top-right" size="is-small" />
</a>
<a href="#" @click.prevent="deleteMedia(m.id)">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
</div>
</div>
<hr />
</div>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
export default Vue.extend({
name: 'Media',
props: {
isModal: Boolean,
},
data() {
return {
form: {
files: [],
},
toUpload: 0,
uploaded: 0,
};
},
methods: {
removeUploadFile(i) {
this.form.files.splice(i, 1);
},
onMediaSelect(m, e) {
// If the component is open in the modal mode, close the modal and
// fire the selection event.
// Otherwise, do nothing and let the image open like a normal link.
if (this.isModal) {
e.preventDefault();
this.$emit('selected', m);
this.$parent.close();
}
},
onSubmit() {
this.toUpload = this.form.files.length;
// Upload N files with N requests.
for (let i = 0; i < this.toUpload; i += 1) {
const params = new FormData();
params.set('file', this.form.files[i]);
this.$api.uploadMedia(params).then(() => {
this.onUploaded();
}, () => {
this.onUploaded();
});
}
},
deleteMedia(id) {
this.$api.deleteMedia(id).then(() => {
this.$api.getMedia();
});
},
onUploaded() {
this.uploaded += 1;
if (this.uploaded >= this.toUpload) {
this.toUpload = 0;
this.uploaded = 0;
this.form.files = [];
this.$api.getMedia();
}
},
},
computed: {
...mapState(['media', 'loading']),
isProcessing() {
if (this.toUpload > 0 && this.uploaded < this.toUpload) {
return true;
}
return false;
},
// Filters the list of media items by months into:
// [{"title": "Jan 2020", items: [...]}, ...]
items() {
const out = [];
if (!this.media || !(this.media instanceof Array)) {
return out;
}
let lastStamp = '';
let lastIndex = 0;
this.media.forEach((m) => {
const stamp = dayjs(m.createdAt).format('MMM YYYY');
if (stamp !== lastStamp) {
out.push({ title: stamp, items: [] });
lastStamp = stamp;
lastIndex = out.length;
}
out[lastIndex - 1].items.push(m);
});
return out;
},
},
mounted() {
this.$api.getMedia();
},
});
</script>

View File

@ -0,0 +1,75 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4>Manage lists</h4>
<p>{{ numSubscribers }} subscriber(s) selected</p>
</header>
<section expanded class="modal-card-body">
<b-field label="Action">
<div>
<b-radio v-model="form.action" name="action" native-value="add">Add</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove">Remove</b-radio>
<b-radio
v-model="form.action"
name="action"
native-value="unsubscribe"
>Mark as unsubscribed</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>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button native-type="submit" type="is-primary"
:disabled="form.lists.length === 0">Save</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
export default Vue.extend({
components: {
ListSelector,
},
props: {
numSubscribers: Number,
},
data() {
return {
// Binds form input values.
form: {
action: 'add',
lists: [],
},
};
},
methods: {
onSubmit() {
this.$emit('finished', this.form.action, this.form.lists);
this.$parent.close();
},
},
computed: {
...mapState(['lists', 'loading']),
},
});
</script>

View File

@ -0,0 +1,197 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">{{ data.status }}</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New subscriber</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
</p>
</header>
<section expanded class="modal-card-body">
<b-field label="E-mail">
<b-input :maxlength="200" v-model="form.email" :ref="'focus'"
placeholder="E-mail" required></b-input>
</b-field>
<b-field label="Name">
<b-input :maxlength="200" v-model="form.name" placeholder="Name"></b-input>
</b-field>
<b-field label="Status" message="Blacklisted subscribers will never receive any e-mails.">
<b-select v-model="form.status" placeholder="Status" required>
<option value="enabled">Enabled</option>
<option value="blacklisted">Blacklisted</option>
</b-select>
</b-field>
<list-selector
label="Lists"
placeholder="Lists to subscribe to"
message="Lists from which subscribers have unsubscribed themselves cannot be removed."
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<b-field label="Attributes"
message='Attributes are defined as a JSON map, for example:
{"job": "developer", "location": "Mars", "has_rocket": true}.'>
<b-input v-model="form.strAttribs" type="textarea" />
</b-field>
<a href="https://listmonk.app/docs/concepts"
target="_blank" rel="noopener noreferrer" class="is-size-7">
Learn more <b-icon icon="link" size="is-small" />.
</a>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.subscribers">Save</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import ListSelector from '../components/ListSelector.vue';
export default Vue.extend({
components: {
ListSelector,
},
props: {
data: {
type: Object,
default: () => {},
},
isEditing: Boolean,
},
data() {
return {
// Binds form input values. This is populated by subscriber props passed
// from the parent component in mounted().
form: { lists: [], strAttribs: '{}' },
};
},
methods: {
onSubmit() {
if (this.isEditing) {
this.updateSubscriber();
return;
}
this.createSubscriber();
},
createSubscriber() {
const attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
const data = {
email: this.form.email,
name: this.form.name,
status: this.form.status,
attribs,
// List IDs.
lists: this.form.lists.map((l) => l.id),
};
this.$api.createSubscriber(data).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' created`,
type: 'is-success',
queue: false,
});
});
},
updateSubscriber() {
const attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
const data = {
id: this.form.id,
email: this.form.email,
name: this.form.name,
status: this.form.status,
attribs,
// List IDs.
lists: this.form.lists.map((l) => l.id),
};
this.$api.updateSubscriber(data).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' updated`,
type: 'is-success',
queue: false,
});
});
},
validateAttribs(str) {
// Parse and validate attributes JSON.
let attribs = {};
try {
attribs = JSON.parse(str);
} catch (e) {
this.$buefy.toast.open({
message: `Invalid JSON in attributes: ${e.toString()}`,
type: 'is-danger',
duration: 3000,
queue: false,
});
return null;
}
if (attribs instanceof Array) {
this.$buefy.toast.open({
message: 'Attributes should be a map {} and not an array []',
type: 'is-danger',
duration: 3000,
queue: false,
});
return null;
}
return attribs;
},
},
computed: {
...mapState(['lists', 'loading']),
},
mounted() {
if (this.$props.isEditing) {
this.form = {
...this.$props.data,
// Deep-copy the lists array on to the form.
strAttribs: JSON.stringify(this.$props.data.attribs, null, 4),
};
}
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View File

@ -0,0 +1,450 @@
<template>
<section class="subscribers">
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">Subscribers
<span v-if="subscribers.total > 0">({{ subscribers.total }})</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
</span>
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
</div>
</header>
<section class="subscribers-controls columns">
<div class="column is-4">
<form @submit.prevent="querySubscribers">
<div>
<b-field grouped>
<b-input v-model="queryParams.query"
placeholder="E-mail or name" icon="account-search-outline" ref="query"
:disabled="isSearchAdvanced"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="account-search-outline"
:disabled="isSearchAdvanced"></b-button>
</b-field>
<p>
<a href="#" @click.prevent="toggleAdvancedSearch">
<b-icon icon="cog-outline" size="is-small" /> Advanced</a>
</p>
<div v-if="isSearchAdvanced">
<b-field>
<b-input v-model="queryParams.fullQuery"
type="textarea" ref="fullQuery"
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'">
</b-input>
</b-field>
<b-field>
<span class="is-size-6 has-text-grey">
Partial SQL expression to query subscriber attributes.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank" rel="noopener noreferrer">
Learn more <b-icon icon="link" size="is-small" />.
</a>
</span>
</b-field>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
icon-left="account-search-outline">Query</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="close">Reset</b-button>
</div>
</div><!-- advanced query -->
</div>
</form>
</div><!-- search -->
<div class="column is-4 subscribers-bulk" v-if="bulk.checked.length > 0">
<div>
<p>
<span class="is-size-5 has-text-weight-semibold">
{{ numSelectedSubscribers }} subscriber(s) selected
</span>
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash; <a href="" @click.prevent="selectAllSubscribers">
Select all {{ subscribers.total }}</a>
</span>
</p>
<p class="actions">
<a href='' @click.prevent="showBulkListForm">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
</a>
<a href='' @click.prevent="deleteSubscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<a href='' @click.prevent="blacklistSubscribers">
<b-icon icon="account-off-outline" size="is-small" /> Blacklist
</a>
</p><!-- selection actions //-->
</div>
</div>
</section><!-- control -->
<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>
<template slot-scope="props">
<b-table-column field="status" label="Status">
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
</a>
</b-table-column>
<b-table-column field="email" label="E-mail">
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.email }}
</a>
<b-taglist>
<router-link :to="`/subscribers/lists/${props.row.id}`">
<b-tag :class="l.subscriptionStatus" v-for="l in props.row.lists"
size="is-small" :key="l.id">
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
</b-tag>
</router-link>
</b-taglist>
</b-table-column>
<b-table-column field="name" label="Name">
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
</b-table-column>
<b-table-column field="lists" label="Lists" numeric centered>
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column field="createdAt" label="Created">
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated">
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<a :href="`/api/subscribers/${props.row.id}/export`">
<b-tooltip label="Download data" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.subscribers">
<empty-placeholder />
</template>
</b-table>
<!-- Manage list modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="450">
<subscriber-bulk-list :numSubscribers="this.numSelectedSubscribers"
@finished="bulkChangeLists" />
</b-modal>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="750">
<subscriber-form :data="curItem" :isEditing="isEditing"
@finished="querySubscribers"></subscriber-form>
</b-modal>
</section>
</template>
<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';
export default Vue.extend({
components: {
SubscriberForm,
SubscriberBulkList,
EmptyPlaceholder,
},
data() {
return {
// Current subscriber item being edited.
curItem: null,
isSearchAdvanced: false,
isEditing: false,
isFormVisible: false,
isBulkListFormVisible: false,
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
// Query params to filter the getSubscribers() API call.
queryParams: {
// Simple query field.
query: '',
// Advanced query filled. This value should be accessed via fullQueryExp().
fullQuery: '',
// ID of the list the current subscriber view is filtered by.
listID: null,
page: 1,
},
};
},
methods: {
// Count the lists from which a subscriber has not unsubscribed.
listCount(lists) {
return lists.reduce((defVal, item) => (defVal + item.status !== 'unsubscribed' ? 1 : 0), 0);
},
toggleAdvancedSearch() {
this.isSearchAdvanced = !this.isSearchAdvanced;
// Toggling to simple search.
if (!this.isSearchAdvanced) {
this.$nextTick(() => {
this.$refs.query.focus();
});
return;
}
// Toggling to advanced search.
this.$nextTick(() => {
// Turn the string in the simple query input into an SQL exprssion and
// show in the full query input.
if (this.queryParams.query !== '') {
this.queryParams.fullQuery = this.fullQueryExp;
}
this.$refs.fullQuery.focus();
});
},
// Mark all subscribers in the query as selected.
selectAllSubscribers() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.subscribers.total) {
this.bulk.all = false;
}
},
// Show the edit list form.
showEditForm(sub) {
this.curItem = sub;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new list form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
showBulkListForm() {
this.isBulkListFormVisible = true;
},
sortSubscribers(field, order, event) {
console.log(field, order, event);
},
onPageChange(p) {
this.queryParams.page = p;
this.querySubscribers();
},
// Search / query subscribers.
querySubscribers() {
this.$api.getSubscribers({
list_id: this.queryParams.listID,
query: this.fullQueryExp,
page: this.queryParams.page,
}).then(() => {
this.bulk.checked = [];
});
},
deleteSubscriber(sub) {
this.$utils.confirm(
'Are you sure?',
() => {
this.$api.deleteSubscriber(sub.id).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: `'${sub.name}' deleted.`,
type: 'is-success',
queue: false,
});
});
},
);
},
blacklistSubscribers() {
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, blacklist subscribers by IDs.
fn = () => {
const ids = this.bulk.checked.map((s) => s.id);
this.$api.blacklistSubscribers({ ids })
.then(() => this.querySubscribers());
};
} else {
// 'All' is selected, blacklist by query.
fn = () => {
this.$api.blacklistSubscribersByQuery({
query: this.fullQueryExp,
list_ids: [],
}).then(() => this.querySubscribers());
};
}
this.$utils.confirm(
`Blacklist ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
},
deleteSubscribers() {
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete subscribers by IDs.
fn = () => {
const ids = this.bulk.checked.map((s) => s.id);
this.$api.deleteSubscribers({ id: ids })
.then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
type: 'is-success',
queue: false,
});
});
};
} else {
// 'All' is selected, delete by query.
fn = () => {
this.$api.deleteSubscribersByQuery({
query: this.fullQueryExp,
list_ids: [],
}).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
type: 'is-success',
queue: false,
});
});
};
}
this.$utils.confirm(
`Delete ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
},
bulkChangeLists(action, lists) {
const data = {
action,
target_list_ids: lists.map((l) => l.id),
};
let fn = null;
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, perform by IDs.
fn = this.$api.addSubscribersToLists;
data.ids = this.bulk.checked.map((s) => s.id);
} else {
// 'All' is selected, perform by query.
fn = this.$api.addSubscribersToListsByQuery;
}
fn(data).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: 'List change applied',
type: 'is-success',
queue: false,
});
});
},
},
computed: {
...mapState(['subscribers', 'lists', 'loading']),
// Turns the value into the simple input field into an SQL query expression.
fullQueryExp() {
const q = this.queryParams.query.replace(/'/g, "''").trim();
if (!q) {
return '';
}
return `(name ~* '${q}' OR email ~* '${q}')`;
},
numSelectedSubscribers() {
if (this.bulk.all) {
return this.subscribers.total;
}
return this.bulk.checked.length;
},
// Returns the list that the subscribers are being filtered by in.
currentList() {
if (!this.queryParams.listID || !this.lists.results) {
return null;
}
return this.lists.results.find((l) => l.id === this.queryParams.listID);
},
},
mounted() {
if (this.$route.params.listID) {
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
}
// Get subscribers on load.
this.querySubscribers();
},
});
</script>

View File

@ -0,0 +1,139 @@
<template>
<section>
<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">Preview</b-button>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New template</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Name">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
</b-field>
<b-field label="Raw HTML">
<b-input v-model="form.body" type="textarea" required />
</b-field>
<p class="is-size-7">
The placeholder <code>{{ egPlaceholder }}</code>
should appear in the template.
<a target="_blank" href="https://listmonk.app/docs/templating">Learn more.</a>
</p>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.templates">Save</b-button>
</footer>
</div>
</form>
<campaign-preview v-if="previewItem"
type='template'
:title="previewItem.name"
:body="form.body"
@close="closePreview"></campaign-preview>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
export default Vue.extend({
components: {
CampaignPreview,
},
props: {
data: Object,
isEditing: null,
},
data() {
return {
// Binds form input values.
form: {
name: '',
type: '',
optin: '',
},
previewItem: null,
egPlaceholder: '{{ template "content" . }}',
};
},
methods: {
previewTemplate() {
this.previewItem = this.data;
},
closePreview() {
this.previewItem = null;
},
onSubmit() {
if (this.isEditing) {
this.updateTemplate();
return;
}
this.createTemplate();
},
createTemplate() {
const data = {
id: this.data.id,
name: this.form.name,
body: this.form.body,
};
this.$api.createTemplate(data).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' created`,
type: 'is-success',
queue: false,
});
});
},
updateTemplate() {
const data = {
id: this.data.id,
name: this.form.name,
body: this.form.body,
};
this.$api.updateTemplate(data).then((resp) => {
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${resp.data.name}' updated`,
type: 'is-success',
queue: false,
});
});
},
},
computed: {
...mapState(['loading']),
},
mounted() {
this.form = { ...this.$props.data };
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View File

@ -0,0 +1,160 @@
<template>
<section class="templates">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Templates
<span v-if="templates.length > 0">({{ templates.length }})</span></h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
</div>
</header>
<b-table :data="templates" :hoverable="true" :loading="loading.templates"
default-sort="createdAt">
<template slot-scope="props">
<b-table-column field="name" label="Name" sortable>
<a :href="props.row.id" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-tag v-if="props.row.isDefault">default</b-tag>
</b-table-column>
<b-table-column field="createdAt" label="Created" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<a href="#" @click.prevent="previewTemplate(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))">
<b-tooltip label="Make default" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))">
<b-tooltip label="Delete" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</template>
<template slot="empty" v-if="!loading.templates">
<empty-placeholder />
</template>
</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>
<campaign-preview v-if="previewItem"
type='template'
:id="previewItem.id"
:title="previewItem.name"
@close="closePreview"></campaign-preview>
</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';
export default Vue.extend({
components: {
CampaignPreview,
TemplateForm,
EmptyPlaceholder,
},
data() {
return {
curItem: null,
isEditing: false,
isFormVisible: false,
previewItem: null,
};
},
methods: {
// Show the edit form.
showEditForm(data) {
this.curItem = data;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.$api.getTemplates();
},
previewTemplate(c) {
this.previewItem = c;
},
closePreview() {
this.previewItem = null;
},
makeTemplateDefault(tpl) {
this.$api.makeTemplateDefault(tpl.id).then(() => {
this.$api.getTemplates();
this.$buefy.toast.open({
message: `'${tpl.name}' made default`,
type: 'is-success',
queue: false,
});
});
},
deleteTemplate(tpl) {
this.$api.deleteTemplate(tpl.id).then(() => {
this.$api.getTemplates();
this.$buefy.toast.open({
message: `'${tpl.name}' deleted`,
type: 'is-success',
queue: false,
});
});
},
},
computed: {
...mapState(['templates', 'loading']),
},
mounted() {
this.$api.getTemplates();
},
});
</script>

17
frontend/vue.config.js vendored Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
publicPath: '/',
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: 'frontend',
// Move the index.html file from dist/index.html to dist/frontend/index.html
indexPath: './frontend/index.html',
productionSourceMap: false,
filenameHashing: false,
};

9325
frontend/yarn.lock vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,8 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
func registerHTTPHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
e.GET("/api/config.js", handleGetConfigScript)
e.GET("/api/dashboard/stats", handleGetDashboardStats)
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
@ -115,6 +116,7 @@ func registerHTTPHandlers(e *echo.Echo) {
// Static views.
e.GET("/lists", handleIndexPage)
e.GET("/lists/forms", handleIndexPage)
e.GET("/subscribers", handleIndexPage)
e.GET("/subscribers/lists/:listID", handleIndexPage)
e.GET("/subscribers/import", handleIndexPage)

View File

@ -52,8 +52,10 @@ func initFS(staticDir string) stuffbin.FileSystem {
"static/public:/public",
// The frontend app's static assets are aliased to /frontend
// so that they are accessible at localhost:port/frontend/static/ ...
"frontend/build:/frontend",
// so that they are accessible at /frontend/js/* etc.
// Alias all files inside dist/ and dist/frontend to frontend/*.
"frontend/dist/:/frontend",
"frontend/dist/frontend:/frontend",
}
fs, err = stuffbin.NewLocalFS("/", files...)
@ -85,10 +87,13 @@ func initFS(staticDir string) stuffbin.FileSystem {
// initDB initializes the main DB connection pool and parse and loads the app's
// SQL queries into a prepared query map.
func initDB() *sqlx.DB {
var dbCfg dbConf
if err := ko.Unmarshal("db", &dbCfg); err != nil {
lo.Fatalf("error loading db config: %v", err)
}
lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
db, err := connectDB(dbCfg)
if err != nil {
lo.Fatalf("error connecting to DB: %v", err)

View File

@ -158,7 +158,7 @@ type Campaign struct {
Name string `db:"name" json:"name"`
Subject string `db:"subject" json:"subject"`
FromEmail string `db:"from_email" json:"from_email"`
Body string `db:"body" json:"body,omitempty"`
Body string `db:"body" json:"body"`
SendAt null.Time `db:"send_at" json:"send_at"`
Status string `db:"status" json:"status"`
ContentType string `db:"content_type" json:"content_type"`
@ -177,7 +177,7 @@ type Campaign struct {
// CampaignMeta contains fields tracking a campaign's progress.
type CampaignMeta struct {
CampaignID int `db:"campaign_id" json:""`
CampaignID int `db:"campaign_id" json:"-"`
Views int `db:"views" json:"views"`
Clicks int `db:"clicks" json:"clicks"`

View File

@ -11,7 +11,8 @@ import (
// Queries contains all prepared SQL queries.
type Queries struct {
GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"`
GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"`
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`

View File

@ -379,7 +379,7 @@ FROM campaigns
WHERE ($1 = 0 OR id = $1)
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
AND ($3 = '' OR (to_tsvector(name || subject) @@ to_tsquery($3)))
ORDER BY created_at DESC OFFSET $4 LIMIT $5;
ORDER BY campaigns.updated_at DESC OFFSET $4 LIMIT $5;
-- name: get-campaign
SELECT campaigns.*,
@ -678,21 +678,8 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
RETURNING (SELECT url FROM link);
-- name: get-dashboard-stats
WITH lists AS (
SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row
),
subs AS (
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row
),
orphans AS (
SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
),
camps AS (
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row
),
clicks AS (
-- name: get-dashboard-charts
WITH clicks AS (
-- Clicks by day for the last 3 months
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (SELECT COUNT(*) AS count, created_at::DATE as date
@ -706,9 +693,31 @@ views AS (
FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
) row
)
SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'),
'subscribers', COALESCE((SELECT * FROM subs), '[]'),
'orphan_subscribers', (SELECT * FROM orphans),
'campaigns', COALESCE((SELECT * FROM camps), '[]'),
'link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;
SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]'));
-- name: get-dashboard-counts
SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM subscribers),
'blacklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blacklisted'),
'orphans', (
SELECT COUNT(id) FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
)
),
'lists', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM lists),
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
),
'campaigns', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM campaigns),
'by_status', (
SELECT JSON_OBJECT_AGG (status, num) FROM
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
)
),
'messages', (SELECT SUM(sent) AS messages FROM campaigns));

View File

@ -118,7 +118,7 @@ func handleQuerySubscribers(c echo.Context) error {
defer tx.Rollback()
// Run the query.
if err := tx.Select(&out.Results, stmt, listIDs, "id", pg.Offset, pg.Limit); err != nil {
if err := tx.Select(&out.Results, stmt, listIDs, "updaated_at", pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
}