diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..5d126348
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,13 @@
+# editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..f935a370
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,84 @@
+# Created by .ignore support plugin (hsz.mobi)
+### Node template
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# next.js build output
+.next
+
+# nuxt.js build output
+.nuxt
+
+# Nuxt generate
+dist
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless
+
+# IDE
+.idea
+
+# Service worker
+sw.*
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..b2095be8
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "semi": false,
+ "singleQuote": true
+}
diff --git a/assets/README.md b/assets/README.md
new file mode 100644
index 00000000..34766f93
--- /dev/null
+++ b/assets/README.md
@@ -0,0 +1,7 @@
+# ASSETS
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains your un-compiled assets such as LESS, SASS, or JavaScript.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).
diff --git a/components/Calendar.vue b/components/Calendar.vue
new file mode 100644
index 00000000..3dfcfb31
--- /dev/null
+++ b/components/Calendar.vue
@@ -0,0 +1,94 @@
+
+ v-calendar#calendar.card(
+ show-caps
+ :popover-expanded='true'
+ :attributes='attributes'
+ :from-page.sync='page'
+ is-expanded is-inline)
+ div(slot='popover', slot-scope='{ customData }')
+ router-link(:to="`/event/${customData.id}`") {{customData.start_datetime|hour}} - {{customData.title}} @{{customData.place.name}}
+
+
+
+
diff --git a/components/Event.vue b/components/Event.vue
new file mode 100644
index 00000000..db25a65b
--- /dev/null
+++ b/components/Event.vue
@@ -0,0 +1,64 @@
+
+ b-card(bg-variant='dark' text-variant='white' :class="{ withImg: event.image_path ? true : false }"
+ @click='$router.push("/event/" + event.id)'
+ :img-src='imgPath')
+ strong {{event.title}}
+ div {{event.start_datetime|datetime}}
+ //- span {{event.place.name}}
+ br
+ el-tag.mr-1(:color='tag.color || "grey"' v-for='tag in event.tags' :key='tag.tag'
+ size='small' @click.stop='addSearchTag(tag)') {{tag.tag}}
+
+
+
diff --git a/components/Home.vue b/components/Home.vue
new file mode 100644
index 00000000..2cdda1dd
--- /dev/null
+++ b/components/Home.vue
@@ -0,0 +1,100 @@
+
+ div
+ magic-grid(:animate="false" useMin :gap=5 :maxCols=4
+ :maxColWidth='400' ref='magicgrid')
+ div.mt-1.item
+ //- Search#search
+ no-ssr
+ Calendar
+ Event.item.mt-1(v-for='event in events'
+ :key='event.id'
+ :event='event')
+
+
+
+
diff --git a/components/Logo.vue b/components/Logo.vue
new file mode 100644
index 00000000..6c728541
--- /dev/null
+++ b/components/Logo.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
diff --git a/components/Nav.vue b/components/Nav.vue
new file mode 100644
index 00000000..892755e5
--- /dev/null
+++ b/components/Nav.vue
@@ -0,0 +1,50 @@
+
+ b-navbar(type="dark" variant="dark" toggleable='md')
+ b-navbar-toggle(target='nav_collapse')
+ b-navbar-brand(to='/')
+ b-collapse#nav_collapse(is-nav)
+ b-navbar-nav
+ b-nav-item(v-if='!logged' to='/login' v-b-tooltip :title='$t("Login")')
+ span.d-md-none {{$t('User')}}
+ b-nav-item(to='/new_event' v-b-tooltip :title='$t("Add Event")' )
+ span.d-md-none {{$t('Add Event')}}
+ b-nav-item(v-if='logged' to='/settings' v-b-tooltip :title='$t("Settings")')
+ span.d-md-none {{$t('Settings')}}
+ b-nav-item(v-if='user.is_admin' to='/admin' v-b-tooltip :title='$t("Admin")')
+ span.d-md-none {{$t('Admin')}}
+ b-nav-item(to='/export' v-b-tooltip :title='$t("Export")')
+ span.d-md-none {{$t('Export')}}
+ b-nav-item(v-if='logged' @click='logout' v-b-tooltip :title='$t("Logout")')
+ span.d-md-none {{$t('Logout')}}
+ b-navbar-nav.ml-auto
+ b-nav-item(to='/about')
+ span {{$t('Info')}}
+
+
+
+
diff --git a/components/README.md b/components/README.md
new file mode 100644
index 00000000..a079f106
--- /dev/null
+++ b/components/README.md
@@ -0,0 +1,7 @@
+# COMPONENTS
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+The components directory contains your Vue.js Components.
+
+_Nuxt.js doesn't supercharge these components._
diff --git a/components/Search.vue b/components/Search.vue
new file mode 100644
index 00000000..fbc3ed2d
--- /dev/null
+++ b/components/Search.vue
@@ -0,0 +1,39 @@
+
+ div
+ el-select.mr-1(v-model='filters_places' multiple filterable collapse-tags
+ default-first-option :placeholder='$t("Where")')
+ el-option(v-for='place in places' :value='place.name'
+ :label='place.name' :key='place.id')
+ el-select(v-model='filters_tags' multiple filterable collapse-tags
+ default-first-option :placeholder='$t("Tags")')
+ el-option(v-for='tag in tags' :key='tag.tag'
+ :label='tag.tag' :value='tag.tag')
+
+
+
diff --git a/db.sqlite b/db.sqlite
new file mode 100644
index 00000000..2ad224f1
Binary files /dev/null and b/db.sqlite differ
diff --git a/layouts/README.md b/layouts/README.md
new file mode 100644
index 00000000..cad1ad57
--- /dev/null
+++ b/layouts/README.md
@@ -0,0 +1,7 @@
+# LAYOUTS
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains your Application Layouts.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts).
diff --git a/layouts/default.vue b/layouts/default.vue
new file mode 100644
index 00000000..2fafd1c0
--- /dev/null
+++ b/layouts/default.vue
@@ -0,0 +1,85 @@
+
+ #app
+ Nav
+ Home
+ transition(name="fade" mode="out-in")
+ //- router-view(name='modal')
+ nuxt
+
+
+
+
+
diff --git a/middleware/README.md b/middleware/README.md
new file mode 100644
index 00000000..01595ded
--- /dev/null
+++ b/middleware/README.md
@@ -0,0 +1,8 @@
+# MIDDLEWARE
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains your application middleware.
+Middleware let you define custom functions that can be run before rendering either a page or a group of pages.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).
diff --git a/nuxt.config.js b/nuxt.config.js
new file mode 100644
index 00000000..952fe72d
--- /dev/null
+++ b/nuxt.config.js
@@ -0,0 +1,80 @@
+const pkg = require('./package')
+
+module.exports = {
+ mode: 'universal',
+
+ /*
+ ** Headers of the page
+ */
+ head: {
+ title: pkg.name,
+ meta: [
+ { charset: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { hid: 'description', name: 'description', content: pkg.description }
+ ],
+ link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
+ },
+
+ serverMiddleware: [{ path: '/api', handler: '@/server/api/index.js' }],
+
+ /*
+ ** Customize the progress-bar color
+ */
+ loading: { color: '#fff' },
+
+ /*
+ ** Global CSS
+ */
+ css: [
+ 'element-ui/lib/theme-chalk/index.css',
+ 'bootstrap/dist/css/bootstrap.css',
+ 'bootstrap-vue/dist/bootstrap-vue.css',
+ 'v-calendar/lib/v-calendar.min.css'
+ ],
+
+ /*
+ ** Plugins to load before mounting the App
+ */
+ plugins: ['@/plugins/element-ui', '@/plugins/filters',
+ '@/plugins/i18n', '@/plugins/bootstrap-vue',
+ '@/plugins/vue-awesome',
+ { src: '@/plugins/v-calendar', ssr: false },
+ '@/plugins/magic-grid'],
+
+ /*
+ ** Nuxt.js modules
+ */
+ modules: [
+ // Doc: https://axios.nuxtjs.org/usage
+ '@nuxtjs/axios'
+ ],
+ /*
+ ** Axios module configuration
+ */
+ axios: {
+ // See https://github.com/nuxt-community/axios-module#options
+ },
+
+ /*
+ ** Build configuration
+ */
+ build: {
+ transpile: [/^element-ui/, /^vue-awesome/, /^vue-magic-grid/],
+
+ /*
+ ** You can extend webpack config here
+ */
+ extend(config, ctx) {
+ // Run ESLint on save
+ // if (ctx.isDev && ctx.isClient) {
+ // config.module.rules.push({
+ // enforce: 'pre',
+ // test: /\.(js|vue)$/,
+ // loader: 'eslint-loader',
+ // exclude: /(node_modules)/
+ // })
+ // }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..08665b1d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "gancio",
+ "version": "1.0.0",
+ "description": "My well-made Nuxt.js project",
+ "author": "lesion",
+ "private": true,
+ "scripts": {
+ "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
+ "build": "nuxt build",
+ "start": "cross-env NODE_ENV=production node server/index.js",
+ "generate": "nuxt generate",
+ "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
+ "precommit": "npm run lint"
+ },
+ "dependencies": {
+ "@nuxtjs/axios": "^5.3.6",
+ "axios": "^0.18.0",
+ "bcrypt": "^3.0.5",
+ "bootstrap-vue": "^2.0.0-rc.16",
+ "cors": "^2.8.5",
+ "cross-env": "^5.2.0",
+ "dayjs": "^1.8.11",
+ "element-ui": "^2.4.11",
+ "email-templates": "^5.0.4",
+ "express": "^4.16.4",
+ "ics": "^2.13.2",
+ "jsonwebtoken": "^8.5.1",
+ "mastodon-api": "^1.3.0",
+ "multer": "^1.4.1",
+ "nuxt": "^2.4.0",
+ "sequelize": "^5.2.1",
+ "sharp": "^0.22.0",
+ "sqlite3": "^4.0.6",
+ "v-calendar": "^0.9.7",
+ "vue-awesome": "^3.5.1",
+ "vue-i18n": "^8.10.0",
+ "vue-magic-grid": "^0.0.4"
+ },
+ "devDependencies": {
+ "@nuxtjs/eslint-config": "^0.0.1",
+ "babel-eslint": "^10.0.1",
+ "eslint": "^5.15.1",
+ "eslint-config-prettier": "^4.1.0",
+ "eslint-config-standard": ">=12.0.0",
+ "eslint-loader": "^2.1.2",
+ "eslint-plugin-import": ">=2.16.0",
+ "eslint-plugin-jest": ">=22.3.0",
+ "eslint-plugin-node": ">=8.0.1",
+ "eslint-plugin-nuxt": ">=0.4.2",
+ "eslint-plugin-prettier": "^3.0.1",
+ "eslint-plugin-promise": ">=4.0.1",
+ "eslint-plugin-standard": ">=4.0.0",
+ "eslint-plugin-vue": "^5.2.2",
+ "nodemon": "^1.18.9",
+ "prettier": "^1.16.4",
+ "pug-plain-loader": "^1.0.0"
+ }
+}
diff --git a/pages/Login.vue b/pages/Login.vue
new file mode 100644
index 00000000..6c36308a
--- /dev/null
+++ b/pages/Login.vue
@@ -0,0 +1,65 @@
+
+ b-modal(@shown="$refs.email.focus()" :title='$t("Login")' hide-footer
+ @hidden='$router.replace("/")' :visible='true' ref='modal')
+ el-form(v-loading='loading')
+ p(v-html="$t('login_explanation')")
+ el-input.mb-2(v-model='email' type='email' :placeholder='$t("Email")' autocomplete='email' ref='email')
+ v-icon(name='user' slot='prepend')
+ el-input.mb-1(v-model='password' @keyup.enter.native="submit" type='password' :placeholder='$t("Password")')
+ v-icon(name="lock" slot='prepend')
+ el-button.mr-1(plain type="success" @click='submit') {{$t('Login')}}
+ router-link(to='/register')
+ el-button.mt-1(plain type="primary") {{$t('Not registered?')}}
+ a.float-right(href='#' @click='forgot') {{$t('Forgot password?')}}
+
+
+
diff --git a/pages/README.md b/pages/README.md
new file mode 100644
index 00000000..1d5d48b2
--- /dev/null
+++ b/pages/README.md
@@ -0,0 +1,6 @@
+# PAGES
+
+This directory contains your Application Views and Routes.
+The framework reads all the `*.vue` files inside this directory and creates the router of your application.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing).
diff --git a/pages/Register.vue b/pages/Register.vue
new file mode 100644
index 00000000..ce0afef8
--- /dev/null
+++ b/pages/Register.vue
@@ -0,0 +1,60 @@
+
+ b-modal(hide-footer @hidden='$router.replace("/")' ref='modal'
+ :title="$t('Register')" :visible='true' @shown='$refs.email.focus()')
+ el-form
+ p(v-html="$t('register_explanation')")
+ el-input.mb-2(ref='email' v-model='user.email' type='email'
+ :placeholder='$t("Email")' autocomplete='email')
+ span(slot='prepend') @
+
+ el-input.mb-2(v-model='user.password' type="password" placeholder="Password")
+ v-icon(name='lock' slot='prepend')
+
+ el-input.mb-2(v-model='user.description' type="textarea" rows='3' :placeholder="$t('Description')")
+ v-icon(name='envelope-open-text')
+
+
+ el-button.float-right(plain type="success" icon='el-icon-arrow-right' @click='register') {{$t('Send')}}
+
+
+
diff --git a/pages/event/_id.vue b/pages/event/_id.vue
new file mode 100644
index 00000000..f7e1aa6f
--- /dev/null
+++ b/pages/event/_id.vue
@@ -0,0 +1,121 @@
+
+ b-modal#eventDetail(ref='eventDetail' hide-body hide-header hide-footer @hidden='$router.replace("/")' size='lg' :visible='true')
+ b-card(no-body, :img-src='imgPath' v-loading='loading')
+ el-button.close_button(circle icon='el-icon-close' type='success'
+ @click='$refs.eventDetail.hide()')
+ b-card-header
+ h3 {{event.title}}
+ v-icon(name='clock')
+ span {{event.start_datetime|datetime}}
+ br
+ v-icon(name='map-marker-alt')
+ //- span {{event.place.name}} - {{event.place.address}}
+ br
+ b-card-body(v-if='event.description || event.tags')
+ pre(v-html='event.description')
+ br
+ el-tag.mr-1(:color='tag.color || "grey"' v-for='tag in event.tags'
+ size='mini' :key='tag.tag') {{tag.tag}}
+ .ml-auto(v-if='mine')
+ hr
+ el-button(v-if='event.is_visible' plain type='warning' @click.prevents='toggle' icon='el-icon-view') {{$t('Unconfirm')}}
+ el-button(v-else plain type='success' @click.prevents='toggle' icon='el-icon-view') {{$t('Confirm')}}
+ el-button(plain type='danger' @click.prevent='remove' icon='el-icon-remove') {{$t('Remove')}}
+ el-button(plain type='primary' @click='$router.replace("/edit/"+event.id)') {{$t('Edit')}}
+
+ //- COMMENTS ...
+ //- b-navbar(type="dark" variant="dark" toggleable='lg')
+ //- template(slot='footer')
+ //- b-navbar-nav
+ //- b-button(variant='success') {{$t('Share')}}
+ //- b-nav-item( {{$t('')}})
+ //- b-card-footer.text-right
+ //- span.mr-3 {{event.comments.length}}
+ //- a(href='#', @click='remove')
+ v-icon(color='orange' name='times')
+ //- b-card-footer(v-for='comment in event.comments')
+ strong {{comment.author}}
+ div(v-html='comment.text')
+
+
+
+
+
diff --git a/pages/index.vue b/pages/index.vue
new file mode 100644
index 00000000..28d95324
--- /dev/null
+++ b/pages/index.vue
@@ -0,0 +1,2 @@
+
+
diff --git a/pages/new_event.vue b/pages/new_event.vue
new file mode 100644
index 00000000..d5be35cb
--- /dev/null
+++ b/pages/new_event.vue
@@ -0,0 +1,215 @@
+
+ b-modal(ref='modal' @hidden='$router.replace("/")' size='lg' :visible='true'
+ :title="edit?$t('Edit event'):$t('New event')" hide-footer)
+ el-form
+ el-tabs.mb-2(v-model='activeTab' v-loading='sending')
+
+ //- NOT LOGGED EVENT
+ el-tab-pane(v-if='!logged')
+ span(slot='label') {{$t('anon_newevent')}}
+ p(v-html="$t('anon_newevent_explanation')")
+ el-button.float-right(@click='next' :disabled='!couldProceed') {{$t('Next')}}
+
+ //- WHERE
+ el-tab-pane
+ span(slot='label') {{$t('Where')}}
+ div {{$t('where_explanation')}}
+ el-select.mb-3(v-model='event.place.name' @change='placeChoosed' filterable allow-create default-first-option)
+ el-option(v-for='place in places_name' :label='place' :value='place' :key='place.id')
+ div {{$t("Address")}}
+ el-input.mb-3(ref='address' v-model='event.place.address' @keydown.native.enter='next')
+ el-button.float-right(@click='next' :disabled='!couldProceed') {{$t('Next')}}
+
+ //- WHEN
+ el-tab-pane
+ span(slot='label') {{$t('When')}}
+ span {{event.multidate ? $t('dates_explanation') : $t('date_explanation')}}
+ el-switch.float-right(v-model='event.multidate' :active-text="$t('multidate_explanation')")
+ v-date-picker.mb-3(:mode='event.multidate ? "range" : "single"' v-model='date' is-inline
+ is-expanded :min-date='new Date()' @input='date ? $refs.time_start.focus() : false')
+ div {{$t('time_start_explanation')}}
+ el-time-select.mb-3(ref='time_start'
+ v-model="time.start"
+ :picker-options="{ start: '00:00', step: '00:30', end: '24:00'}")
+ div {{$t('time_end_explanation')}}
+ el-time-select(v-model='time.end'
+ :picker-options="{start: '00:00', step: '00:30', end: '24:00'}")
+ el-button.float-right(@click='next' :disabled='!couldProceed') {{$t('Next')}}
+
+ //- WHAT
+ el-tab-pane
+ span(slot='label') {{$t('What')}}
+ span {{$t('what_explanation')}}
+ el-input.mb-3(v-model='event.title' ref='title')
+ span {{$t('description_explanation')}}
+ el-input.mb-3(v-model='event.description' type='textarea' :rows='9')
+ span {{$t('tag_explanation')}}
+ br
+ el-select(v-model='event.tags' multiple filterable allow-create
+ default-first-option placeholder='Tag')
+ el-option(v-for='tag in tags' :key='tag.tag'
+ :label='tag' :value='tag')
+
+ el-button.float-right(@click.native='next' :disabled='!couldProceed') {{$t('Next')}}
+
+ el-tab-pane
+ span(slot='label') {{$t('Media')}}
+ span {{$t('media_explanation')}}
+ b-form-file.mb-2(v-model='event.image', :placeholder='$t("Poster")' accept='image/*')
+ el-button.float-right(@click='done') {{edit?$t('Edit'):$t('Send')}}
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/README.md b/plugins/README.md
new file mode 100644
index 00000000..ca1f9d8a
--- /dev/null
+++ b/plugins/README.md
@@ -0,0 +1,7 @@
+# PLUGINS
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains Javascript plugins that you want to run before mounting the root Vue.js application.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins).
diff --git a/plugins/api.js b/plugins/api.js
new file mode 100644
index 00000000..72a7dc82
--- /dev/null
+++ b/plugins/api.js
@@ -0,0 +1,82 @@
+import axios from 'axios'
+import { getters } from '@/store'
+const api = axios.create({
+ baseURL: process.env.NODE_ENV === 'development' ? 'http://localhost:3000/api' : '/api',
+ withCredentials: true,
+ responseType: 'json',
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ }
+})
+
+function get(path) {
+ return api.get(path) //, { headers: { 'Authorization': getters.token } })
+ .then(res => res.data)
+ // .catch((e) => {
+ // if (e.response.status === 403) {
+ // // store.commit('logout') // TOFIX
+ // return false
+ // }
+ // throw e.response && e.response.data &&
+ // e.response.data.errors && e.response.data.errors[0].message
+ // })
+}
+
+function post(path, data) {
+ return api.post(path, data, { headers: { 'Authorization': getters.token } })
+ .then(res => res.data)
+ // .catch((e) => {
+ // if (e.response.status === 403) {
+ // // store.commit('logout') // TOFIX
+ // return false
+ // }
+ // throw e.response && e.response.data &&
+ // e.response.data.errors && e.response.data.errors[0].message
+ // })
+}
+function put(path, data) {
+ return api.put(path, data, { headers: { 'Authorization': getters.token } })
+ .then(ret => ret.data)
+}
+
+function del(path) {
+ return api.delete(path, { headers: { 'Authorization': getters.token } }).then(ret => ret.data)
+}
+
+export default {
+ login: (email, password) => post('/login', { email, password }),
+ register: user => post('/user', user),
+
+ // password recovery
+ forgotPassword: email => post('/user/recover', { email }),
+ checkRecoverCode: recover_code => post('/user/check_recover_code', { recover_code }),
+ recoverPassword: (recover_code, password) => post('/user/recover_password', { recover_code, password }),
+
+ getAllEvents: (month, year) => get(`/event/${year}/${month}/`),
+ getUnconfirmedEvents: () => get('/event/unconfirmed'),
+
+ confirmEvent: id => get(`/event/confirm/${id}`),
+ unconfirmEvent: id => get(`/event/unconfirm/${id}`),
+
+ addNotification: notification => post('/event/notification', notification),
+ delNotification: code => del(`/event/notification/${code}`),
+
+ addEvent: event => post('/user/event', event),
+ updateEvent: event => put('/user/event', event),
+
+ updatePlace: place => put('/place', place),
+ delEvent: eventId => del(`/user/event/${eventId}`),
+ getEvent: eventId => get(`/event/${eventId}`),
+ getMeta: () => get('/event/meta'),
+ getUser: () => get('/user'),
+ getUsers: () => get('/users'),
+ updateTag: tag => put('/tag', tag),
+ updateUser: user => put('/user', user),
+ getAuthURL: mastodonInstance => post('/user/getauthurl', mastodonInstance),
+ setCode: code => post('/user/code', code),
+ getAdminSettings: () => get('/settings')
+ // setAdminSetting: (key, value) => post('/settings', { key, value })
+}
diff --git a/plugins/bootstrap-vue.js b/plugins/bootstrap-vue.js
new file mode 100644
index 00000000..24f906d9
--- /dev/null
+++ b/plugins/bootstrap-vue.js
@@ -0,0 +1,6 @@
+import Vue from 'vue'
+import BootstrapVue from 'bootstrap-vue'
+
+export default () => {
+ Vue.use(BootstrapVue)
+}
diff --git a/plugins/element-ui.js b/plugins/element-ui.js
new file mode 100644
index 00000000..53fc4940
--- /dev/null
+++ b/plugins/element-ui.js
@@ -0,0 +1,26 @@
+import Vue from 'vue'
+import { Button, Select, Tag, Option, Table, FormItem, Card,
+ Form, Tabs, TabPane, Switch, Input, Loading, TimeSelect,
+ TableColumn, ColorPicker, Pagination, Popover } from 'element-ui'
+// import locale from 'element-ui/lib/locale/lang/en'
+
+export default () => {
+ Vue.use(Button)
+ Vue.use(Popover)
+ Vue.use(Card)
+ Vue.use(Select)
+ Vue.use(Tag)
+ Vue.use(Input)
+ Vue.use(Tabs)
+ Vue.use(TabPane)
+ Vue.use(Option)
+ Vue.use(Switch)
+ Vue.use(ColorPicker)
+ Vue.use(Table)
+ Vue.use(TableColumn)
+ Vue.use(Pagination)
+ Vue.use(FormItem)
+ Vue.use(Form)
+ Vue.use(TimeSelect)
+ Vue.use(Loading.directive)
+}
diff --git a/plugins/filters.js b/plugins/filters.js
new file mode 100644
index 00000000..3e317fc8
--- /dev/null
+++ b/plugins/filters.js
@@ -0,0 +1,8 @@
+import Vue from 'vue'
+import moment from 'dayjs'
+import 'dayjs/locale/it'
+moment.locale('it')
+
+Vue.filter('datetime', value => moment(value).format('ddd, D MMMM HH:mm'))
+Vue.filter('short_datetime', value => moment(value).format('D/MM HH:mm'))
+Vue.filter('hour', value => moment(value).format('HH:mm'))
diff --git a/plugins/i18n.js b/plugins/i18n.js
new file mode 100644
index 00000000..af35c413
--- /dev/null
+++ b/plugins/i18n.js
@@ -0,0 +1,25 @@
+import Vue from 'vue'
+import VueI18n from 'vue-i18n'
+
+Vue.use(VueI18n)
+
+export default ({ app, store }) => {
+ // Set i18n instance on app
+ // This way we can use it in middleware and pages asyncData/fetch
+ app.i18n = new VueI18n({
+ locale: store.state.locale,
+ fallbackLocale: 'en'
+ // messages: {
+ // 'en': require('~/locales/en.json'),
+ // 'fr': require('~/locales/fr.json')
+ // }
+ })
+
+ app.i18n.path = (link) => {
+ if (app.i18n.locale === app.i18n.fallbackLocale) {
+ return `/${link}`
+ }
+
+ return `/${app.i18n.locale}/${link}`
+ }
+}
diff --git a/plugins/magic-grid.js b/plugins/magic-grid.js
new file mode 100644
index 00000000..c240181d
--- /dev/null
+++ b/plugins/magic-grid.js
@@ -0,0 +1,6 @@
+import Vue from 'vue'
+import MagicGrid from 'vue-magic-grid'
+
+export default () => {
+ Vue.use(MagicGrid)
+}
diff --git a/plugins/v-calendar.js b/plugins/v-calendar.js
new file mode 100644
index 00000000..fd7f3431
--- /dev/null
+++ b/plugins/v-calendar.js
@@ -0,0 +1,9 @@
+import Vue from 'vue'
+import VCalendar from 'v-calendar'
+import 'v-calendar/lib/v-calendar.min.css'
+
+export default () => {
+ Vue.use(VCalendar, {
+ firstDayOfWeek: 2
+ })
+}
diff --git a/plugins/vue-awesome.js b/plugins/vue-awesome.js
new file mode 100644
index 00000000..333743f7
--- /dev/null
+++ b/plugins/vue-awesome.js
@@ -0,0 +1,24 @@
+import Vue from 'vue'
+import 'vue-awesome/icons/lock'
+import 'vue-awesome/icons/user'
+import 'vue-awesome/icons/plus'
+import 'vue-awesome/icons/cog'
+import 'vue-awesome/icons/tools'
+import 'vue-awesome/icons/file-export'
+import 'vue-awesome/icons/sign-out-alt'
+import 'vue-awesome/icons/clock'
+import 'vue-awesome/icons/map-marker-alt'
+import 'vue-awesome/icons/file-alt'
+import 'vue-awesome/icons/image'
+import 'vue-awesome/icons/tag'
+import 'vue-awesome/icons/users'
+import 'vue-awesome/icons/calendar'
+import 'vue-awesome/icons/edit'
+import 'vue-awesome/icons/envelope-open-text'
+import 'vue-awesome/icons/user-secret'
+import 'vue-awesome/icons/question-circle'
+import Icon from 'vue-awesome/components/Icon'
+
+export default () => {
+ Vue.component('v-icon', Icon)
+}
diff --git a/server/api/auth.js b/server/api/auth.js
new file mode 100644
index 00000000..0727af5d
--- /dev/null
+++ b/server/api/auth.js
@@ -0,0 +1,48 @@
+const jwt = require('jsonwebtoken')
+const { Op } = require('sequelize')
+const config = require('./config')
+const User = require('./models/user')
+
+const Auth = {
+ fillUser(req, res, next) {
+ const token =
+ req.body.token || req.params.token || req.headers['x-access-token']
+ if (!token) return next()
+ jwt.verify(token, config.secret, async (err, decoded) => {
+ if (err) return next()
+ req.user = await User.findOne({
+ where: { email: { [Op.eq]: decoded.email }, is_active: true }
+ })
+ next()
+ })
+ },
+ isAuth(req, res, next) {
+ const token =
+ (req.body && req.body.token) ||
+ req.params.token ||
+ req.headers['x-access-token']
+ if (!token) return res.status(403).send({ message: 'Token not found' })
+ jwt.verify(token, config.secret, async (err, decoded) => {
+ if (err) {
+ return res
+ .status(403)
+ .send({ message: 'Failed to authenticate token ' + err })
+ }
+ req.user = await User.findOne({
+ where: { email: { [Op.eq]: decoded.email }, is_active: true }
+ })
+ if (!req.user) {
+ return res
+ .status(403)
+ .send({ message: 'Failed to authenticate token ' + err })
+ }
+ next()
+ })
+ },
+ isAdmin(req, res, next) {
+ if (req.user.is_admin && req.user.is_active) return next()
+ return res.status(403).send({ message: 'Admin needed' })
+ }
+}
+
+module.exports = Auth
diff --git a/server/api/config.js b/server/api/config.js
new file mode 100644
index 00000000..e0f2b99f
--- /dev/null
+++ b/server/api/config.js
@@ -0,0 +1,27 @@
+/* backend configuration */
+const env = process.env.NODE_ENV || 'development'
+const db = require('./config/config.json')[env]
+
+module.exports = {
+ locale: process.env.LOCALE || 'it',
+ title: process.env.TITLE || 'GANCIO',
+ description: process.env.DESCRIPTION || 'A calendar for radical communities',
+ baseurl: process.env.BASE_URL || 'http://localhost:8080',
+ apiurl:
+ env === 'production'
+ ? process.env.BASE_URL + '/api'
+ : 'http://localhost:9000',
+ db,
+ admin: process.env.ADMIN_EMAIL,
+
+ smtp: {
+ host: process.env.SMTP_HOST,
+ secure: true,
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS
+ }
+ },
+
+ secret: process.env.SECRET || 'notsosecret'
+}
diff --git a/server/api/config/config.json b/server/api/config/config.json
new file mode 100644
index 00000000..b0b61bda
--- /dev/null
+++ b/server/api/config/config.json
@@ -0,0 +1,14 @@
+{
+ "development": {
+ "storage": "/home/les/dev/hacklab/gancio/db.sqlite",
+ "dialect": "sqlite",
+ "logging": false
+ },
+ "production": {
+ "username": "docker",
+ "password": "docker",
+ "database": "gancio",
+ "host": "db",
+ "dialect": "postgres"
+ }
+}
diff --git a/server/api/controller/bot.js b/server/api/controller/bot.js
new file mode 100644
index 00000000..bedcae91
--- /dev/null
+++ b/server/api/controller/bot.js
@@ -0,0 +1,82 @@
+// const { User, Event, Comment, Tag } = require('../model')
+const config = require('../config')
+const Mastodon = require('mastodon-api')
+// const Sequelize = require('sequelize')
+// const Op = Sequelize.Op
+const fs = require('fs')
+const path = require('path')
+const moment = require('moment')
+moment.locale('it')
+
+const botController = {
+ bots: [],
+ // async initialize () {
+ // console.log('initialize bots')
+ // const botUsers = await User.findAll({ where: { mastodon_auth: { [Op.ne]: null } } })
+ // console.log(botUsers)
+ // botController.bots = botUsers.map(user => {
+ // console.log('initialize bot ', user.name)
+ // console.log('.. ', user.mastodon_auth)
+ // const { client_id, client_secret, access_token } = user.mastodon_auth
+ // const bot = new Mastodon({ access_token, api_url: `https://${user.mastodon_instance}/api/v1/` })
+ // const listener = bot.stream('streaming/direct')
+ // listener.on('message', botController.message)
+ // listener.on('error', botController.error)
+ // return { email: user.email, bot }
+ // })
+ // console.log(botController.bots)
+ // },
+ // add (user, token) {
+ // const bot = new Mastodon({ access_token: user.mastodon_auth.access_token, api_url: `https://${user.mastodon_instance}/api/v1/` })
+ // const listener = bot.stream('streaming/direct')
+ // listener.on('message', botController.message)
+ // listener.on('error', botController.error)
+ // botController.bots.push({ email: user.email, bot })
+ // },
+ async post (mastodon_auth, event) {
+ const { access_token, instance } = mastodon_auth
+ const bot = new Mastodon({ access_token, api_url: `https://${instance}/api/v1/` })
+ const status = `${event.title} @ ${event.place.name} ${moment(event.start_datetime).format('ddd, D MMMM HH:mm')} -
+${event.description.length > 200 ? event.description.substr(0, 200) + '...' : event.description} - ${event.tags.map(t => '#' + t.tag).join(' ')} ${config.baseurl}/event/${event.id}`
+
+ let media
+ if (event.image_path) {
+ const file = path.join(__dirname, '..', '..', 'uploads', event.image_path)
+ if (fs.statSync(file)) {
+ media = await bot.post('media', { file: fs.createReadStream(file) })
+ }
+ }
+ return bot.post('statuses', { status, visibility: 'public', media_ids: media ? [media.data.id] : [] })
+ }
+ // async message (msg) {
+ // console.log(msg)
+ // console.log(msg.data.accounts)
+ // const replyid = msg.data.in_reply_to_id || msg.data.last_status.in_reply_to_id
+ // if (!replyid) return
+ // const event = await Event.findOne({ where: { activitypub_id: replyid } })
+ // if (!event) {
+ // check for comment..
+ // const comment = await Comment.findOne( {where: { }})
+ // }
+ // const comment = await Comment.create({activitypub_id: msg.data.last_status.id, text: msg.data.last_status.content, author: msg.data.accounts[0].username })
+ // event.addComment(comment)
+ // console.log(event)
+ // const comment = await Comment.findOne( { where: {activitypub_id: msg.data.in_reply_to}} )
+ // console.log('dentro message ', data)
+
+ // add comment to specified event
+ // let comment
+ // if (!event) {
+ // const comment = await Comment.findOne({where: {activitypub_id: req.body.id}, include: Event})
+ // event = comment.event
+ // }
+ // const comment = new Comment(req.body)
+ // event.addComment(comment)
+ // },
+ // error (err) {
+ // console.log('error ', err)
+ // }
+}
+
+// setTimeout(botController.initialize, 2000)
+module.exports = botController
diff --git a/server/api/controller/event.js b/server/api/controller/event.js
new file mode 100644
index 00000000..d1c97801
--- /dev/null
+++ b/server/api/controller/event.js
@@ -0,0 +1,169 @@
+const crypto = require('crypto')
+const moment = require('moment')
+const { Op } = require('sequelize')
+const lodash = require('lodash')
+const { User, Event, Comment, Tag, Place, Notification } = require('../model')
+
+const eventController = {
+
+ async addComment(req, res) {
+ // comment could be added to an event or to another comment
+ let event = await Event.findOne({ where: { activitypub_id: { [Op.eq]: req.body.id } } })
+ if (!event) {
+ const comment = await Comment.findOne({ where: { activitypub_id: { [Op.eq]: req.body.id } }, include: Event })
+ event = comment.event
+ }
+ const comment = new Comment(req.body)
+ event.addComment(comment)
+ res.json(comment)
+ },
+
+ async getMeta(req, res) {
+ const places = await Place.findAll()
+ const tags = await Tag.findAll()
+ res.json({ tags, places })
+ },
+
+ async getNotifications(event) {
+ function match(event, filters) {
+ // matches if no filter specified
+ if (!filters) return true
+
+ // check for visibility
+ if (typeof filters.is_visible !== 'undefined' && filters.is_visible !== event.is_visible) return false
+
+ if (!filters.tags && !filters.places) return true
+ if (!filters.tags.length && !filters.places.length) return true
+ if (filters.tags.length) {
+ const m = lodash.intersection(event.tags.map(t => t.tag), filters.tags)
+ if (m.length > 0) return true
+ }
+ if (filters.places.length) {
+ if (filters.places.find(p => p === event.place.name)) {
+ return true
+ }
+ }
+ }
+ const notifications = await Notification.findAll()
+
+ // get notification that matches with selected event
+ return notifications.filter(notification => match(event, notification.filters))
+ },
+
+ async updateTag(req, res) {
+ const tag = await Tag.findByPk(req.body.tag)
+ if (tag) {
+ res.json(await tag.update(req.body))
+ } else {
+ res.sendStatus(404)
+ }
+ },
+
+ async updatePlace(req, res) {
+ const place = await Place.findByPk(req.body.id)
+ await place.update(req.body)
+ res.json(place)
+ },
+
+ async get(req, res) {
+ const id = req.params.event_id
+ const event = await Event.findByPk(id, { include: [User, Tag, Comment, Place] })
+ res.json(event)
+ },
+
+ async confirm(req, res) {
+ const id = req.params.event_id
+ const event = await Event.findByPk(id)
+
+ try {
+ await event.update({ is_visible: true })
+ // insert notification
+ const notifications = await eventController.getNotifications(event)
+ await event.setNotifications(notifications)
+ res.sendStatus(200)
+ } catch (e) {
+ res.sendStatus(404)
+ }
+ },
+
+ async unconfirm(req, res) {
+ const id = req.params.event_id
+ const event = await Event.findByPk(id)
+
+ try {
+ await event.update({ is_visible: false })
+ res.sendStatus(200)
+ } catch (e) {
+ res.sendStatus(404)
+ }
+ },
+
+ async getUnconfirmed(req, res) {
+ const events = await Event.findAll({
+ where: {
+ is_visible: false
+ },
+ order: [['start_datetime', 'ASC']],
+ include: [Tag, Place]
+ })
+ res.json(events)
+ },
+
+ async addNotification(req, res) {
+ try {
+ const notification = {
+ filters: { is_visible: true },
+ email: req.body.email,
+ type: 'mail',
+ remove_code: crypto.randomBytes(16).toString('hex')
+ }
+ await Notification.create(notification)
+ res.sendStatus(200)
+ } catch (e) {
+ res.sendStatus(404)
+ }
+ },
+
+ async delNotification(req, res) {
+ const remove_code = req.params.code
+ try {
+ const notification = await Notification.findOne({ where: { remove_code: { [Op.eq]: remove_code } } })
+ await notification.destroy()
+ } catch (e) {
+ return res.sendStatus(404)
+ }
+ res.sendStatus(200)
+ },
+
+ async getAll(req, res) {
+ console.log('sono qui dentro !')
+ // this is due how v-calendar shows dates
+ const start = moment().year(req.params.year).month(req.params.month)
+ .startOf('month').startOf('isoWeek')
+ let end = moment().year(req.params.year).month(req.params.month).endOf('month')
+ const shownDays = end.diff(start, 'days')
+ if (shownDays <= 34) end = end.add(1, 'week')
+ end = end.endOf('isoWeek')
+ const events = await Event.findAll({
+ // where: {
+ // is_visible: true,
+ // [Op.and]: [
+ // { start_datetime: { [Op.gte]: start } },
+ // { start_datetime: { [Op.lte]: end } }
+ // ]
+ // },
+ // order: [['start_datetime', 'ASC']],
+ // include: [
+ // { model: User, required: false },
+ // Comment,
+ // Tag,
+ // { model: Place, required: false }
+ // ]
+ })
+ console.log(events)
+ res.json(events)
+ }
+
+}
+
+module.exports = eventController
diff --git a/server/api/controller/export.js b/server/api/controller/export.js
new file mode 100644
index 00000000..3f54ecf2
--- /dev/null
+++ b/server/api/controller/export.js
@@ -0,0 +1,64 @@
+const { Event, Comment, Tag, Place } = require('../model')
+const { Op } = require('sequelize')
+const config = require('../config')
+const moment = require('moment')
+const ics = require('ics')
+
+const exportController = {
+
+ async export (req, res) {
+ console.log('type ', req.params.type)
+ const type = req.params.type
+ const tags = req.query.tags
+ const places = req.query.places
+ const whereTag = {}
+ const wherePlace = {}
+ const yesterday = moment().subtract('1', 'day')
+ if (tags) {
+ whereTag.tag = tags.split(',')
+ }
+ if (places) {
+ wherePlace.name = places.split(',')
+ }
+ const events = await Event.findAll({
+ where: { is_visible: true, start_datetime: { [Op.gte]: yesterday } },
+ include: [Comment, {
+ model: Tag,
+ where: whereTag
+ }, { model: Place, where: wherePlace } ]
+ })
+ switch (type) {
+ case 'feed':
+ return exportController.feed(res, events.slice(0, 20))
+ case 'ics':
+ return exportController.ics(res, events)
+ }
+ },
+
+ async feed (res, events) {
+ res.type('application/rss+xml; charset=UTF-8')
+ res.render('feed/rss.pug', { events, config, moment })
+ },
+
+ async ics (res, events) {
+ const eventsMap = events.map(e => {
+ const tmpStart = moment(e.start_datetime)
+ const tmpEnd = moment(e.end_datetime)
+ const start = [tmpStart.year(), tmpStart.month() + 1, tmpStart.date(), tmpStart.hour(), tmpStart.minute()]
+ const end = [tmpEnd.year(), tmpEnd.month() + 1, tmpEnd.date(), tmpEnd.hour(), tmpEnd.minute()]
+ return {
+ start,
+ end,
+ title: e.title,
+ description: e.description,
+ location: e.place.name + ' ' + e.place.address
+ }
+ })
+ res.type('text/calendar; charset=UTF-8')
+ const { error, value } = ics.createEvents(eventsMap)
+ console.log(error, value)
+ res.send(value)
+ }
+}
+
+module.exports = exportController
diff --git a/server/api/controller/settings.js b/server/api/controller/settings.js
new file mode 100644
index 00000000..85f32819
--- /dev/null
+++ b/server/api/controller/settings.js
@@ -0,0 +1,27 @@
+const { Settings } = require('../model')
+
+const settingsController = {
+ async setAdminSetting (key, value) {
+ await Settings.findOrCreate({ where: { key },
+ defaults: { value } })
+ .spread((settings, created) => {
+ if (!created) return settings.update({ value })
+ })
+ },
+
+ async getAdminSettings (req, res) {
+ const settings = await settingsController.settings()
+ res.json(settings)
+ },
+
+ async settings () {
+ const settings = await Settings.findAll()
+ const map = {}
+ settings.forEach(setting => {
+ map[setting.key] = setting.value
+ })
+ return map
+ }
+}
+
+module.exports = settingsController
diff --git a/server/api/controller/user.js b/server/api/controller/user.js
new file mode 100644
index 00000000..923e2414
--- /dev/null
+++ b/server/api/controller/user.js
@@ -0,0 +1,283 @@
+const jwt = require('jsonwebtoken')
+const Mastodon = require('mastodon-api')
+
+const User = require('../models/user')
+const { Event, Tag, Place } = require('../models/event')
+const settingsController = require('./settings')
+const eventController = require('./event')
+const config = require('../config')
+const mail = require('../mail')
+const { Op } = require('sequelize')
+const fs = require('fs')
+const path = require('path')
+const crypto = require('crypto')
+
+const userController = {
+ async login (req, res) {
+ // find the user
+ const user = await User.findOne({ where: { email: { [Op.eq]: req.body.email } } })
+ if (!user) {
+ res.status(404).json({ success: false, message: 'AUTH_FAIL' })
+ } else if (user) {
+ if (!user.is_active) {
+ res.status(403).json({ success: false, message: 'NOT_CONFIRMED' })
+ // check if password matches
+ } else if (!await user.comparePassword(req.body.password)) {
+ res.status(403).json({ success: false, message: 'AUTH_FAIL' })
+ } else {
+ // if user is found and password is right
+ // create a token
+ const payload = { email: user.email }
+ var token = jwt.sign(payload, config.secret)
+ res.json({
+ success: true,
+ message: 'Enjoy your token!',
+ token,
+ user
+ })
+ }
+ }
+ },
+
+ async setToken (req, res) {
+ req.user.mastodon_auth = req.body
+ await req.user.save()
+ res.json(req.user)
+ },
+
+ async delEvent (req, res) {
+ const event = await Event.findByPk(req.params.id)
+ // check if event is mine (or user is admin)
+ if (event && (req.user.is_admin || req.user.id === event.userId)) {
+ if (event.image_path) {
+ const old_path = path.resolve(__dirname, '..', '..', 'uploads', event.image_path)
+ const old_thumb_path = path.resolve(__dirname, '..', '..', 'uploads', 'thumb', event.image_path)
+ await fs.unlink(old_path)
+ await fs.unlink(old_thumb_path)
+ }
+ await event.destroy()
+ res.sendStatus(200)
+ } else {
+ res.sendStatus(403)
+ }
+ },
+
+ // ADD EVENT
+ async addEvent (req, res) {
+ const body = req.body
+
+ // remove description tag and create anchor tags
+ const description = body.description
+ .replace(/(<([^>]+)>)/ig, '')
+ .replace(/(https?:\/\/[^\s]+)/g, '$1')
+
+ const eventDetails = {
+ title: body.title,
+ description,
+ multidate: body.multidate,
+ start_datetime: body.start_datetime,
+ end_datetime: body.end_datetime,
+ is_visible: !!req.user
+ }
+
+ if (req.file) {
+ eventDetails.image_path = req.file.filename
+ }
+
+ let event = await Event.create(eventDetails)
+
+ // create place
+ let place
+ try {
+ place = await Place.findOrCreate({ where: { name: body.place_name },
+ defaults: { address: body.place_address } })
+ .spread((place, created) => place)
+ await event.setPlace(place)
+ } catch (e) {
+ console.error(e)
+ }
+
+ // create/assign tags
+ if (body.tags) {
+ await Tag.bulkCreate(body.tags.map(t => ({ tag: t })), { ignoreDuplicates: true })
+ const tags = await Tag.findAll({ where: { tag: { [Op.in]: body.tags } } })
+ await event.addTags(tags)
+ }
+ if (req.user) await req.user.addEvent(event)
+ event = await Event.findByPk(event.id, { include: [User, Tag, Place] })
+
+ // insert notifications
+ const notifications = await eventController.getNotifications(event)
+ await event.setNotifications(notifications)
+
+ return res.json(event)
+ },
+
+ async updateEvent (req, res) {
+ const body = req.body
+ const event = await Event.findByPk(body.id)
+ if (!req.user.is_admin && event.userId !== req.user.id) {
+ return res.sendStatus(403)
+ }
+
+ if (req.file) {
+ if (event.image_path) {
+ const old_path = path.resolve(__dirname, '..', '..', 'uploads', event.image_path)
+ const old_thumb_path = path.resolve(__dirname, '..', '..', 'uploads', 'thumb', event.image_path)
+ await fs.unlink(old_path, e => console.error(e))
+ await fs.unlink(old_thumb_path, e => console.error(e))
+ }
+ body.image_path = req.file.filename
+ }
+
+ body.description = body.description
+ .replace(/(<([^>]+)>)/ig, '') // remove all tags from description
+ .replace(/(https?:\/\/[^\s]+)/g, '$1') // add links
+
+ await event.update(body)
+ let place
+ try {
+ place = await Place.findOrCreate({ where: { name: body.place_name },
+ defaults: { address: body.place_address } })
+ .spread((place, created) => place)
+ } catch (e) {
+ console.log('error', e)
+ }
+ await event.setPlace(place)
+ await event.setTags([])
+ if (body.tags) {
+ await Tag.bulkCreate(body.tags.map(t => ({ tag: t })), { ignoreDuplicates: true })
+ const tags = await Tag.findAll({ where: { tag: { [Op.in]: body.tags } } })
+ await event.addTags(tags)
+ }
+ const newEvent = await Event.findByPk(event.id, { include: [User, Tag, Place] })
+ return res.json(newEvent)
+ },
+
+ async getAuthURL (req, res) {
+ const instance = req.body.instance
+ const is_admin = req.body.admin && req.user.is_admin
+ const callback = `${config.baseurl}/${is_admin ? 'admin/oauth' : 'settings'}`
+ const { client_id, client_secret } = await Mastodon.createOAuthApp(`https://${instance}/api/v1/apps`,
+ config.title, 'read write', callback)
+ const url = await Mastodon.getAuthorizationUrl(client_id, client_secret,
+ `https://${instance}`, 'read write', callback)
+
+ if (is_admin) {
+ await settingsController.setAdminSetting('mastodon_auth', { client_id, client_secret, instance })
+ } else {
+ req.user.mastodon_auth = { client_id, client_secret, instance }
+ await req.user.save()
+ }
+ res.json(url)
+ },
+
+ async code (req, res) {
+ const { code, is_admin } = req.body
+ let client_id, client_secret, instance
+ const callback = `${config.baseurl}/${is_admin ? 'admin/oauth' : 'settings'}`
+
+ if (is_admin) {
+ const settings = await settingsController.settings();
+ ({ client_id, client_secret, instance } = settings.mastodon_auth)
+ } else {
+ ({ client_id, client_secret, instance } = req.user.mastodon_auth)
+ }
+
+ try {
+ const token = await Mastodon.getAccessToken(client_id, client_secret, code,
+ `https://${instance}`, callback)
+ const mastodon_auth = { client_id, client_secret, access_token: token, instance }
+ if (is_admin) {
+ await settingsController.setAdminSetting('mastodon_auth', mastodon_auth)
+ res.json(instance)
+ } else {
+ req.user.mastodon_auth = mastodon_auth
+ await req.user.save()
+ // await bot.add(req.user, token)
+ res.json(req.user)
+ }
+ } catch (e) {
+ res.json(e)
+ }
+ },
+
+ async forgotPassword (req, res) {
+ const email = req.body.email
+ const user = await User.findOne({ where: { email: { [Op.eq]: email } } })
+ if (!user) return res.sendStatus(200)
+
+ user.recover_code = crypto.randomBytes(16).toString('hex')
+ mail.send(user.email, 'recover', { user, config })
+ await user.save()
+ res.sendStatus(200)
+ },
+
+ async checkRecoverCode (req, res) {
+ const recover_code = req.body.recover_code
+ if (!recover_code) return res.sendStatus(400)
+ const user = await User.findOne({ where: { recover_code: { [Op.eq]: recover_code } } })
+ if (!user) return res.sendStatus(400)
+ res.json(user)
+ },
+
+ async updatePasswordWithRecoverCode (req, res) {
+ const recover_code = req.body.recover_code
+ if (!recover_code) return res.sendStatus(400)
+ const password = req.body.password
+ const user = await User.findOne({ where: { recover_code: { [Op.eq]: recover_code } } })
+ if (!user) return res.sendStatus(400)
+ user.password = password
+ await user.save()
+ res.sendStatus(200)
+ },
+
+ async current (req, res) {
+ res.json(req.user)
+ },
+
+ async getAll (req, res) {
+ const users = await User.findAll({
+ order: [['createdAt', 'DESC']]
+ })
+ res.json(users)
+ },
+
+ async update (req, res) {
+ const user = await User.findByPk(req.body.id)
+ if (user) {
+ if (!user.is_active && req.body.is_active) {
+ await mail.send(user.email, 'confirm', { user, config })
+ }
+ await user.update(req.body)
+ res.json(user)
+ } else {
+ res.sendStatus(400)
+ }
+ },
+
+ async register (req, res) {
+ const n_users = await User.count()
+ try {
+ if (n_users === 0) {
+ // the first registered user will be an active admin
+ req.body.is_active = req.body.is_admin = true
+ } else {
+ req.body.is_active = false
+ }
+ const user = await User.create(req.body)
+ try {
+ mail.send([user.email, config.admin], 'register', { user, config })
+ } catch (e) {
+ return res.status(400).json(e)
+ }
+ const payload = { email: user.email }
+ const token = jwt.sign(payload, config.secret)
+ res.json({ user, token })
+ } catch (e) {
+ res.status(404).json(e)
+ }
+ }
+}
+
+module.exports = userController
diff --git a/server/api/db.js b/server/api/db.js
new file mode 100644
index 00000000..7de7ec67
--- /dev/null
+++ b/server/api/db.js
@@ -0,0 +1,5 @@
+const Sequelize = require('sequelize')
+const conf = require('./config.js')
+const db = new Sequelize(conf.db)
+// db.sync({force: true})
+module.exports = db
diff --git a/server/api/index.js b/server/api/index.js
new file mode 100644
index 00000000..ebf7fb8b
--- /dev/null
+++ b/server/api/index.js
@@ -0,0 +1,86 @@
+const express = require('express')
+const multer = require('multer')
+const { fillUser, isAuth, isAdmin } = require('./auth')
+const eventController = require('./controller/event')
+const exportController = require('./controller/export')
+const userController = require('./controller/user')
+const settingsController = require('./controller/settings')
+
+// const botController = require('./controller/bot')
+
+const storage = require('./storage')({
+ destination: 'uploads/'
+})
+
+const upload = multer({ storage })
+const api = express.Router()
+// login
+api.post('/login', userController.login)
+api.post('/user/recover', userController.forgotPassword)
+api.post('/user/check_recover_code', userController.checkRecoverCode)
+api.post('/user/recover_password', userController.updatePasswordWithRecoverCode)
+
+api
+ .route('/user')
+ // register
+ .post(userController.register)
+ // get current user
+ .get(isAuth, userController.current)
+ // update user (eg. confirm)
+ .put(isAuth, isAdmin, userController.update)
+
+// get all users
+api.get('/users', isAuth, isAdmin, userController.getAll)
+
+// update a tag (modify color)
+api.put('/tag', isAuth, isAdmin, eventController.updateTag)
+
+// update a place (modify address..)
+api.put('/place', isAuth, isAdmin, eventController.updatePlace)
+
+api
+ .route('/user/event')
+ // add event
+ .post(fillUser, upload.single('image'), userController.addEvent)
+ // update event
+ .put(isAuth, upload.single('image'), userController.updateEvent)
+
+// remove event
+api.delete('/user/event/:id', isAuth, userController.delEvent)
+
+// get tags/places
+api.get('/event/meta', eventController.getMeta)
+
+// get unconfirmed events
+api.get('/event/unconfirmed', isAuth, isAdmin, eventController.getUnconfirmed)
+
+// add event notification
+api.post('/event/notification', eventController.addNotification)
+api.delete('/event/notification/:code', eventController.delNotification)
+
+api.get('/settings', settingsController.getAdminSettings)
+api.post('/settings', settingsController.setAdminSetting)
+
+// get event
+api.get('/event/:event_id', eventController.get)
+
+// confirm event
+api.get('/event/confirm/:event_id', isAuth, isAdmin, eventController.confirm)
+api.get(
+ '/event/unconfirm/:event_id',
+ isAuth,
+ isAdmin,
+ eventController.unconfirm
+)
+
+// export events (rss/ics)
+api.get('/export/:type', exportController.export)
+
+// get events in this range
+api.get('/event/:year/:month', eventController.getAll)
+
+// mastodon oauth auth
+api.post('/user/getauthurl', isAuth, userController.getAuthURL)
+api.post('/user/code', isAuth, userController.code)
+
+module.exports = api
diff --git a/server/api/mail.js b/server/api/mail.js
new file mode 100644
index 00000000..4f38ee11
--- /dev/null
+++ b/server/api/mail.js
@@ -0,0 +1,44 @@
+const Email = require('email-templates')
+const path = require('path')
+const config = require('./config')
+const moment = require('moment')
+moment.locale('it')
+
+const mail = {
+ send (addresses, template, locals) {
+ const email = new Email({
+ views: { root: path.join(__dirname, 'emails') },
+ juice: true,
+ juiceResources: {
+ preserveImportant: true,
+ webResources: {
+ relativeTo: path.join(__dirname, 'emails')
+ }
+ },
+ message: {
+ from: `${config.title} <${config.smtp.auth.user}>`
+ },
+ send: true,
+ i18n: {
+ locales: ['en', 'es', 'it'],
+ defaultLocale: config.locale
+ },
+ transport: config.smtp
+ })
+ return email.send({
+ template,
+ message: {
+ to: addresses,
+ bcc: config.admin
+ },
+ locals: {
+ ...locals,
+ locale: config.locale,
+ config,
+ datetime: datetime => moment(datetime).format('ddd, D MMMM HH:mm')
+ }
+ })
+ }
+}
+
+module.exports = mail
diff --git a/server/api/model.js b/server/api/model.js
new file mode 100644
index 00000000..303fa1dd
--- /dev/null
+++ b/server/api/model.js
@@ -0,0 +1,14 @@
+const User = require('./models/user')
+const { Event, Comment, Tag, Place, Notification, EventNotification } = require('./models/event')
+const Settings = require('./models/settings')
+
+module.exports = {
+ User,
+ Event,
+ Comment,
+ Tag,
+ Place,
+ Notification,
+ EventNotification,
+ Settings
+}
diff --git a/server/api/models/event.js b/server/api/models/event.js
new file mode 100644
index 00000000..35524b74
--- /dev/null
+++ b/server/api/models/event.js
@@ -0,0 +1,72 @@
+const Sequelize = require('sequelize')
+const db = require('../db')
+const User = require('./user')
+
+const Event = db.define('event', {
+ title: Sequelize.STRING,
+ description: Sequelize.TEXT,
+ multidate: Sequelize.BOOLEAN,
+ start_datetime: { type: Sequelize.DATE, index: true },
+ end_datetime: { type: Sequelize.DATE, index: true },
+ image_path: Sequelize.STRING,
+ activitypub_id: { type: Sequelize.INTEGER, index: true },
+ is_visible: Sequelize.BOOLEAN
+})
+
+const Tag = db.define('tag', {
+ tag: { type: Sequelize.STRING, index: true, unique: true, primaryKey: true },
+ color: { type: Sequelize.STRING }
+})
+
+const Comment = db.define('comment', {
+ activitypub_id: { type: Sequelize.INTEGER, index: true },
+ author: Sequelize.STRING,
+ text: Sequelize.STRING
+})
+
+const Notification = db.define('notification', {
+ filters: Sequelize.JSON,
+ email: Sequelize.STRING,
+ remove_code: Sequelize.STRING,
+ type: {
+ type: Sequelize.ENUM,
+ values: ['mail', 'admin_email', 'mastodon']
+ }
+})
+
+const Place = db.define('place', {
+ name: { type: Sequelize.STRING, unique: true, index: true },
+ address: { type: Sequelize.STRING }
+})
+
+Comment.belongsTo(Event)
+Event.hasMany(Comment)
+
+Event.belongsToMany(Tag, { through: 'tagEvent' })
+Tag.belongsToMany(Event, { through: 'tagEvent' })
+
+const EventNotification = db.define('EventNotification', {
+ status: {
+ type: Sequelize.ENUM,
+ values: ['new', 'sent', 'error'],
+ defaultValue: 'new',
+ index: true
+ }
+})
+
+Event.belongsToMany(Notification, { through: EventNotification })
+Notification.belongsToMany(Event, { through: EventNotification })
+
+Event.belongsTo(User)
+Event.belongsTo(Place)
+
+User.hasMany(Event)
+Place.hasMany(Event)
+
+// async function init() {
+// await Notification.findOrCreate({ where: { type: 'mastodon', filters: { is_visible: true } } })
+// await Notification.findOrCreate({ where: { type: 'admin_email', filters: { is_visible: false } } })
+// }
+
+// init()
+module.exports = { Event, Comment, Tag, Place, Notification, EventNotification }
diff --git a/server/api/models/index.js b/server/api/models/index.js
new file mode 100644
index 00000000..c1a3d6d5
--- /dev/null
+++ b/server/api/models/index.js
@@ -0,0 +1,37 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const Sequelize = require('sequelize');
+const basename = path.basename(__filename);
+const env = process.env.NODE_ENV || 'development';
+const config = require(__dirname + '/../config/config.json')[env];
+const db = {};
+
+let sequelize;
+if (config.use_env_variable) {
+ sequelize = new Sequelize(process.env[config.use_env_variable], config);
+} else {
+ sequelize = new Sequelize(config.database, config.username, config.password, config);
+}
+
+fs
+ .readdirSync(__dirname)
+ .filter(file => {
+ return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
+ })
+ .forEach(file => {
+ const model = sequelize['import'](path.join(__dirname, file));
+ db[model.name] = model;
+ });
+
+Object.keys(db).forEach(modelName => {
+ if (db[modelName].associate) {
+ db[modelName].associate(db);
+ }
+});
+
+db.sequelize = sequelize;
+db.Sequelize = Sequelize;
+
+module.exports = db;
diff --git a/server/api/models/settings.js b/server/api/models/settings.js
new file mode 100644
index 00000000..148b7bdb
--- /dev/null
+++ b/server/api/models/settings.js
@@ -0,0 +1,9 @@
+const db = require('../db')
+const Sequelize = require('sequelize')
+
+const Settings = db.define('settings', {
+ key: { type: Sequelize.STRING, primaryKey: true, index: true },
+ value: Sequelize.JSON
+})
+
+module.exports = Settings
diff --git a/server/api/models/user.js b/server/api/models/user.js
new file mode 100644
index 00000000..2fcfbac5
--- /dev/null
+++ b/server/api/models/user.js
@@ -0,0 +1,34 @@
+const bcrypt = require('bcrypt')
+const db = require('../db')
+const Sequelize = require('sequelize')
+
+const User = db.define('user', {
+ email: {
+ type: Sequelize.STRING,
+ unique: { msg: 'Email already exists' },
+ index: true,
+ allowNull: false
+ },
+ description: Sequelize.TEXT,
+ password: Sequelize.STRING,
+ recover_code: Sequelize.STRING,
+ is_admin: Sequelize.BOOLEAN,
+ is_active: Sequelize.BOOLEAN,
+ mastodon_auth: Sequelize.JSON
+})
+
+User.prototype.comparePassword = async function (pwd) {
+ if (!this.password) return false
+ const ret = await bcrypt.compare(pwd, this.password)
+ return ret
+}
+
+User.beforeSave(async (user, options) => {
+ if (user.changed('password')) {
+ const salt = await bcrypt.genSalt(10)
+ const hash = await bcrypt.hash(user.password, salt)
+ user.password = hash
+ }
+})
+
+module.exports = User
diff --git a/server/api/storage.js b/server/api/storage.js
new file mode 100644
index 00000000..da58bae4
--- /dev/null
+++ b/server/api/storage.js
@@ -0,0 +1,62 @@
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const crypto = require('crypto')
+const mkdirp = require('mkdirp')
+const sharp = require('sharp')
+
+function getDestination(req, file, cb) {
+ cb(null, os.tmpdir())
+}
+
+function DiskStorage(opts) {
+ if (typeof opts.destination === 'string') {
+ mkdirp.sync(opts.destination)
+ this.getDestination = function ($0, $1, cb) { cb(null, opts.destination) }
+ } else {
+ this.getDestination = (opts.destination || getDestination)
+ }
+}
+
+DiskStorage.prototype._handleFile = function _handleFile(req, file, cb) {
+ const that = this
+ that.getDestination(req, file, function (err, destination) {
+ if (err) return cb(err)
+
+ const filename = crypto.randomBytes(16).toString('hex') + '.jpg'
+ const finalPath = path.join(destination, filename)
+ const thumbPath = path.join(destination, 'thumb', filename)
+ const outStream = fs.createWriteStream(finalPath)
+ const thumbStream = fs.createWriteStream(thumbPath)
+ const resizer = sharp().resize(800).jpeg({ quality: 80 })
+ const thumbnailer = sharp().resize(400).jpeg({ quality: 60 })
+
+ file.stream.pipe(thumbnailer).pipe(thumbStream)
+ thumbStream.on('error', e => console.log('thumbStream error ', e))
+
+ file.stream.pipe(resizer).pipe(outStream)
+ outStream.on('error', cb)
+ outStream.on('finish', function () {
+ cb(null, {
+ destination,
+ filename,
+ path: finalPath,
+ size: outStream.bytesWritten
+ })
+ })
+ })
+}
+
+DiskStorage.prototype._removeFile = function _removeFile(req, file, cb) {
+ let path = file.path
+
+ delete file.destination
+ delete file.filename
+ delete file.path
+
+ fs.unlink(path, cb)
+}
+
+module.exports = function (opts) {
+ return new DiskStorage(opts)
+}
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 00000000..ebcf3d73
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,36 @@
+const express = require('express')
+const consola = require('consola')
+const { Nuxt, Builder } = require('nuxt')
+const app = express()
+const cors = require('cors')
+
+// Import and Set Nuxt.js options
+const config = require('../nuxt.config.js')
+config.dev = !(process.env.NODE_ENV === 'production')
+
+async function start() {
+ // Init Nuxt.js
+ const nuxt = new Nuxt(config)
+
+ const { host, port } = nuxt.options.server
+
+ // Build only in dev mode
+ if (config.dev) {
+ const builder = new Builder(nuxt)
+ await builder.build()
+ } else {
+ await nuxt.ready()
+ }
+
+ // Give nuxt middleware to express
+ app.use(cors())
+ app.use(nuxt.render)
+
+ // Listen the server
+ app.listen(port, host)
+ consola.ready({
+ message: `Server listening on http://${host}:${port}`,
+ badge: true
+ })
+}
+start()
diff --git a/static/README.md b/static/README.md
new file mode 100644
index 00000000..cf004353
--- /dev/null
+++ b/static/README.md
@@ -0,0 +1,11 @@
+# STATIC
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains your static files.
+Each file inside this directory is mapped to `/`.
+Thus you'd want to delete this README.md before deploying to production.
+
+Example: `/static/robots.txt` is mapped as `/robots.txt`.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static).
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 00000000..382fecbb
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/store/README.md b/store/README.md
new file mode 100644
index 00000000..1972d277
--- /dev/null
+++ b/store/README.md
@@ -0,0 +1,10 @@
+# STORE
+
+**This directory is not required, you can delete it if you don't want to use it.**
+
+This directory contains your Vuex Store files.
+Vuex Store option is implemented in the Nuxt.js framework.
+
+Creating a file in this directory automatically activates the option in the framework.
+
+More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store).
diff --git a/store/index.js b/store/index.js
new file mode 100644
index 00000000..e16fe1ac
--- /dev/null
+++ b/store/index.js
@@ -0,0 +1,167 @@
+import moment from 'dayjs'
+import { intersection } from 'lodash'
+import api from '~/plugins/api'
+import Vue from 'vue'
+
+Vue.config.errorHandler = function (err, vm, info) {
+ // handle error
+ // `info` is a Vue-specific error info, e.g. which lifecycle hook
+ // the error was found in. Only available in 2.2.0+
+ console.error(err)
+ console.error(info)
+}
+
+export const state = () => ({
+ events: [],
+ user: {},
+ logged: false,
+ token: '',
+ tags: [],
+ places: [],
+ filters: {
+ tags: [],
+ places: []
+ }
+})
+
+export const getters = {
+ token: state => state.token,
+ // filter current + future events only
+ // plus, filter matches search tag/place
+ filteredEvents: (state) => {
+ const events = state.events.map((e) => {
+ const end_datetime = e.end_datetime || moment(e.start_datetime).add('3', 'hour')
+ const past = (moment().diff(end_datetime, 'minutes') > 0)
+ e.past = past
+ return e
+ })
+ if (!state.filters.tags.length && !state.filters.places.length) {
+ return events
+ }
+ return events.filter((e) => {
+ if (state.filters.tags.length) {
+ const m = intersection(e.tags.map(t => t.tag), state.filters.tags)
+ if (m.length > 0) return true
+ }
+ if (state.filters.places.length) {
+ if (state.filters.places.find(p => p === e.place.name)) {
+ return true
+ }
+ }
+ return 0
+ })
+ }
+
+}
+
+export const mutations = {
+ logout(state) {
+ state.logged = false
+ state.token = ''
+ state.user = {}
+ },
+ login(state, user) {
+ state.logged = true
+ state.user = user.user
+ state.token = user.token
+ },
+ setEvents(state, events) {
+ state.events = events
+ },
+ addEvent(state, event) {
+ state.events.push(event)
+ },
+ updateEvent(state, event) {
+ state.events = state.events.map((e) => {
+ if (e.id !== event.id) return e
+ return event
+ })
+ },
+ delEvent(state, eventId) {
+ state.events = state.events.filter(ev => ev.id !== eventId)
+ },
+ update(state, { tags, places }) {
+ state.tags = tags
+ state.places = places
+ },
+ // search
+ addSearchTag(state, tag) {
+ if (!state.filters.tags.find(t => t === tag.tag)) {
+ state.filters.tags.push(tag.tag)
+ } else {
+ state.filters.tags = state.filters.tags.filter(t => t !== tag.tag)
+ }
+ },
+ setSearchTags(state, tags) {
+ state.filters.tags = tags
+ },
+ addSearchPlace(state, place) {
+ if (state.filters.places.find(p => p.name === place.name)) {
+ state.filters.places.push(place)
+ }
+ },
+ setSearchPlaces(state, places) {
+ state.filters.places = places
+ }
+}
+
+export const actions = {
+ // called on server request
+ // get current month's event
+ async nuxtServerInit({ commit }, { req }) {
+ // set user if logged! TODO
+
+ const now = new Date()
+ const events = await api.getAllEvents(now.getMonth() - 1, now.getFullYear())
+ commit('setEvents', events)
+ },
+ async updateEvents({ commit }, date) {
+ console.log('dentro updateEvents ', date.month, api)
+ try {
+ const events = await api.getAllEvents(date.month - 1, date.year)
+ console.log('dopo getAll events', events)
+ commit('setEvents', events)
+ } catch (e) {
+ console.log(e)
+ }
+ },
+ async updateMeta({ commit }) {
+ const { tags, places } = await api.getMeta()
+ commit('update', { tags, places })
+ },
+ async addEvent({ commit }, formData) {
+ const event = await api.addEvent(formData)
+ if (this.state.logged) {
+ commit('addEvent', event)
+ }
+ },
+ async updateEvent({ commit }, formData) {
+ const event = await api.updateEvent(formData)
+ commit('updateEvent', event)
+ },
+ delEvent({ commit }, eventId) {
+ commit('delEvent', eventId)
+ },
+ login({ commit }, user) {
+ commit('login', user)
+ },
+ logout({ commit }) {
+ commit('logout')
+ },
+ // search
+ addSearchTag({ commit }, tag) {
+ commit('addSearchTag', tag)
+ },
+ setSearchTags({ commit }, tags) {
+ commit('setSearchTags', tags)
+ },
+ addSearchPlace({ commit }, place) {
+ commit('addSearchPlace', place)
+ },
+ setSearchPlaces({ commit }, places) {
+ commit('setSearchPlaces', places)
+ }
+}
+// export const getters = {
+// filteredEvents: state => state.events
+// }