Merge branch 'master' into gh

This commit is contained in:
lesion 2021-12-07 01:35:18 +01:00
commit 60e9d95ba8
No known key found for this signature in database
GPG key ID: 352918250B012177
160 changed files with 7131 additions and 5420 deletions

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
# Created by .ignore support plugin (hsz.mobi)
### Gancio dev configuration
*.sqlite
releases
wp-plugin/wpgancio
config/development.json
gancio_config.json
config.json

View file

@ -1,6 +1,100 @@
All notable changes to this project will be documented in this file.
### Unreleased
### 1.2.2 - 7 dic '21
- shiny new gancio-event\[s\] webcomponents => [docs](https://gancio.org/usage/embed)
- new backend plugin system
- improve media focal point selection
- improve non-js experience (load img, use native lazy loading)
- improve user_confirm / recover code flow
- fix task manager exception
- fix db initialization when a custom setup is used, #131
- remove vue-clipboard2 dependency due to [this](https://github.com/euvl/v-clipboard/issues/18) bug and using a [native with fallback mixin instead](./assets/clipboard.js)
- fix a regression to support old CPU, #130
- makes dialog use fullscreen on mobile
- fix Delete AP Actor Action from fediverse when remote Actor is gone
- add `max` param to /events API
### 1.2.1 - 11 nov '21
- fix `Note` remove from fediverse
- AP Actor is now `Application`, was `Person`
- better handling event AP representations
this release is a step forward to improve AP compatibility with other platforms, thanks @tcit
### 1.2.0 - 9 nov '21
- do not overwrite event slug when title is modified to preserve links
- add public cache to events images
- fix baseurl in initial setup configuration
- fix user removal
- load settings during startup and not for each request
- refactoring user custom locale
- published AP event's type is not `Note` anymore but `Event`
### 1.1.1 - 29 ott '21
- fix issue adding event with dueHour resulting in `bad request`
- fix restart during setup
- do not use @nuxt/vuetify module, manually preload vuetify via plugin
- remove deprecated nuxt-express-module and use serverMiddleware directly
### 1.1.0 - 26 ott '21
- a whole new setup via web! fix #126
- new SMTP configuration dialog, fix #115
- re-order general settings in admin panel
- new dark/light theme setting
- move quite all configuration into db
- fix some email recipients
- fix hidden events when not ended
- update translations
- improve install documentation
- add systemd gancio.service
- allow italic and span tags inside editor
- remove moment-timezone, consola, config, inquirer dependencies
- update deps
### 1.0.6 (alpha)
- fix Dockerfile yarn cache issue on update, #123
- fix overflow on event title @homepage
- better import dialog on mobile
- re-add attachment to AP
- fix max event export
- update deps
### 1.0.5 (alpha)
- fix/improve debian install docs
- fix ics export, use new ics package
- use slug url everywhere (rss feed, embedded list)
- use i18n in event confirmation email
- remove lot of deps warning and remove some unused dependencies
- fix show_recurrent in embedded list
- remove old to-ico dep, use png favicon instead
### 1.0.4 (alpha)
- shows a generic img for events without it
### 1.0.3 (alpha)
- 12 hour clock selection, #119
- improve media management
- add alt-text to featured image, fix #106
- add focalPoint support, fix #116
- improve a11y
- improve node v16 compatibility
- fix #122 ? (downgrade prettier)
### 1.0.2 (alpha)
- improve oauth flow UI
- [WordPress plugin](https://wordpress.org/plugins/wpgancio/)
- fix h-event import
- improve error logging (add stack trace to exception)
- choose start date for recurreing events (#120)
- fix user delete from admin
### 1.0.1 (alpha)
- fix AP resource removal
- improve AP resource UI
- fix Docker setup
- update deps
### 1.0 (alpha)
This release is a complete rewrite of frontend UI and many internals, main changes are:

11
RELEASE.md Normal file
View file

@ -0,0 +1,11 @@
- change version in package.json
- add changes to CHANGELOG / changelog.md
- yarn build
- yarn pack
- yarn publish
- yarn doc
- git add .
- git ci -m 'v...'
- git tag ...
- git push --tags
-

View file

@ -1,3 +0,0 @@
export default function (to, from, savedPosition) {
return { x: 0, y: 0 }
}

18
assets/clipboard.js Normal file
View file

@ -0,0 +1,18 @@
export default {
methods: {
clipboard (str, msg = 'common.copied') {
try {
navigator.clipboard.writeText(str)
} catch (e) {
const el = document.createElement('textarea')
el.addEventListener('focusin', e => e.stopPropagation())
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
this.$root.$message(msg)
}
}
}

View file

@ -37,6 +37,9 @@ li {
.v-dialog {
width: 600px;
max-width: 800px;
&.v-dialog--fullscreen {
max-width: 100%;
}
}
.theme--dark.v-list {
@ -62,14 +65,14 @@ li {
overflow: hidden;
.title {
transition: all .5s;
display: block;
max-height: 3em;
color: white;
display: -webkit-box;
overflow: hidden;
margin: 0.5rem 1rem 0.5rem 1rem;
text-overflow: ellipsis;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 1.1em !important;
line-height: 1em !important;
line-height: 1.2em !important;
}
.body {
@ -80,9 +83,9 @@ li {
width: 100%;
max-height: 250px;
min-height: 160px;
background-color: #222;
object-fit: cover;
object-position: top;
aspect-ratio: 1.7778;
}
.place {
@ -99,10 +102,6 @@ li {
}
}
.v-list {
background-color: #333 !important;
}
.vc-past {
opacity: 0.4;
}
@ -116,4 +115,14 @@ li {
white-space: nowrap;
overflow: hidden;
display: block;
}
.cursorPointer {
cursor: pointer;
}
pre {
white-space: break-spaces;
font-size: 13px;
}

View file

@ -8,6 +8,7 @@
:locale='$i18n.locale'
:attributes='attributes'
transition='fade'
aria-label='Calendar'
is-expanded
is-inline
@dayclick='click')

View file

@ -1,6 +1,7 @@
<template lang="pug">
v-dialog(v-model='show'
:fullscreen='$vuetify.breakpoint.xsOnly'
:color='options.color'
:title='title'
:max-width='options.width'
@ -11,8 +12,8 @@
v-card-text(v-show='!!message') {{ message }}
v-card-actions
v-spacer
v-btn(color='error' @click='cancel') {{$t('common.cancel')}}
v-btn(color='primary' @click='agree') {{$t('common.ok')}}
v-btn(text color='error' @click='cancel') {{$t('common.cancel')}}
v-btn(text color='primary' @click='agree') {{$t('common.ok')}}
</template>
<script>

View file

@ -1,5 +1,5 @@
<template lang='pug'>
.editor.grey.darken-4(:class='focused')
.editor(:class='focused')
.label {{label}}
editor-menu-bar.menubar.is-hidden(:editor='editor'
:keep-in-bounds='true' v-slot='{ commands, isActive, getMarkAttrs, focused }')

View file

@ -1,7 +1,7 @@
<template lang="pug">
v-card.h-event.event.d-flex
nuxt-link(:to='`/event/${event.slug || event.id}`')
v-img.u-featured.img(:src="`/media/thumb/${event.image_path || 'logo.svg' }`")
img.img.u-featured(:src='thumbnail' :alt='alt' loading='lazy' :style="{ 'object-position': thumbnailPosition }")
v-icon.float-right.mr-1(v-if='event.parentId' color='success') mdi-repeat
.title.p-name {{event.title}}
@ -17,17 +17,16 @@
v-menu(offset-y)
template(v-slot:activator="{on}")
v-btn.align-self-end(icon v-on='on' color='primary')
v-btn.align-self-end(icon v-on='on' color='primary' alt='more')
v-icon mdi-dots-vertical
v-list(dense)
v-list-item-group
v-list-item(v-clipboard:success="() => $root.$message('common.copied', { color: 'success' })"
v-clipboard:copy='`${settings.baseurl}/event/${event.id}`')
v-list-item(@click='clipboard(`${settings.baseurl}/event/${event.slug || event.id}`)')
v-list-item-icon
v-icon mdi-content-copy
v-list-item-content
v-list-item-title {{$t('common.copy_link')}}
v-list-item(:href='`/api/event/${event.id}.ics`')
v-list-item(:href='`/api/event/${event.slug || event.id}.ics`')
v-list-item-icon
v-icon mdi-calendar-export
v-list-item-content
@ -45,13 +44,34 @@
</template>
<script>
import { mapState } from 'vuex'
import clipboard from '../assets/clipboard'
export default {
props: {
event: { type: Object, default: () => ({}) }
},
mixins: [clipboard],
computed: {
...mapState(['settings']),
thumbnail () {
let path
if (this.event.media && this.event.media.length) {
path = '/media/thumb/' + this.event.media[0].url
} else {
path = '/noimg.svg'
}
return path
},
alt () {
return this.event.media && this.event.media.length ? this.event.media[0].name : ''
},
thumbnailPosition () {
if (this.event.media && this.event.media.length && this.event.media[0].focalpoint) {
const focalpoint = this.event.media[0].focalpoint
return `${(focalpoint[0] + 1) * 50}% ${(focalpoint[1] + 1) * 50}%`
}
return 'center center'
},
is_mine () {
if (!this.$auth.user) {
return false

View file

@ -2,7 +2,7 @@
v-card
v-card-title(v-text="$t('common.follow_me_title')")
v-card-text
p(v-html="$t('event.follow_me_description', { title: settings.title, account: `@${settings.instance_name}@${domain}`})")
p(v-html="$t('event.follow_me_description', { title: settings.title, account: `@${settings.instance_name}@${settings.hostname}`})")
v-text-field(
:rules="[$validators.required('common.url')]"
:loading='loading'
@ -39,10 +39,6 @@ export default {
computed: {
...mapState(['settings']),
domain () {
const URL = new window.URL(this.settings.baseurl)
return URL.hostname
},
couldGo () {
// check if is mastodon
this.get_instance_info(this.instance_hostname)
@ -50,7 +46,7 @@ export default {
},
link () {
// check if exists
return `https://${this.instance_hostname}/authorize_interaction?uri=${this.settings.instance_name}@${this.domain}`
return `https://${this.instance_hostname}/authorize_interaction?uri=${this.settings.instance_name}@${this.settings.hostname}`
}
},
methods: {
@ -76,7 +72,7 @@ export default {
}
}
</script>
<style lang="less">
<style>
.instance_thumb {
height: 20px;
}

View file

@ -1,7 +1,7 @@
<template lang="pug">
v-footer(color='secondary')
v-footer(aria-label='Footer')
v-dialog(v-model='showFollowMe' destroy-on-close max-width='700px')
v-dialog(v-model='showFollowMe' destroy-on-close max-width='700px' :fullscreen='$vuetify.breakpoint.xsOnly')
FollowMe(@close='showFollowMe=false' is-dialog)
v-btn(color='primary' text href='https://gancio.org' target='_blank') Gancio <small>{{settings.version}}</small>
@ -20,7 +20,7 @@
:href='instance.url'
two-line)
v-list-item-avatar
v-img(:src='`${instance.url}/favicon.ico`')
v-img(:src='`${instance.url}/logo.png`')
v-list-item-content
v-list-item-title {{instance.name}}
v-list-item-subtitle {{instance.label}}
@ -41,6 +41,7 @@ export default {
computed: {
...mapState(['settings']),
footerLinks () {
if (!this.settings || !this.settings.footerLinks) return []
return this.settings.footerLinks.map(link => {
if (/^https?:\/\//.test(link.href)) {
return { href: link.href, label: link.label }

View file

@ -12,7 +12,6 @@ div#list
v-list-item-subtitle <v-icon small color='success' v-if='event.parentId'>mdi-repeat</v-icon> {{event|when}}
span.primary--text.ml-1 @{{event.place.name}}
v-list-item-title(v-text='event.title')
//- a.text-body-1(:href='`/event/${event.id}`' target='_blank') {{event.title}}
</template>
<script>

View file

@ -1,5 +1,5 @@
<template lang="pug">
v-app-bar(app)
v-app-bar(app aria-label='Menu')
//- logo, title and description
v-list-item(:to='$route.name==="index"?"/about":"/"')
@ -14,23 +14,23 @@
v-tooltip(bottom) {{$t('common.add_event')}}
template(v-slot:activator='{ on }')
v-btn(v-if='could_add' icon nuxt to='/add' v-on='on')
v-btn(v-if='could_add' icon nuxt to='/add' v-on='on' :aria-label='$t("common.add_event")')
v-icon(large color='primary') mdi-plus
v-tooltip(bottom) {{$t('common.share')}}
template(v-slot:activator='{ on }')
v-btn(icon nuxt to='/export' v-on='on')
v-btn(icon nuxt to='/export' v-on='on' :aria-label='$t("common.share")')
v-icon mdi-share-variant
v-tooltip(v-if='!$auth.loggedIn' bottom) {{$t('common.login')}}
template(v-slot:activator='{ on }')
v-btn(icon nuxt to='/login' v-on='on')
v-btn(icon nuxt to='/login' v-on='on' :aria-label='$t("common.login")')
v-icon mdi-login
v-menu(v-else
offset-y bottom open-on-hover transition="slide-y-transition")
template(v-slot:activator="{ on, attrs }")
v-btn(icon v-bind='attrs' v-on='on')
v-btn(icon v-bind='attrs' v-on='on' aria-label='Menu')
v-icon mdi-dots-vertical
v-list
v-list-item(nuxt to='/settings')
@ -51,15 +51,17 @@
v-list-item-content
v-list-item-title {{$t('common.logout')}}
v-btn(icon v-clipboard:copy='feedLink' v-clipboard:success='copyLink')
v-btn(icon @click='clipboard(feedLink, "common.feed_url_copied")' aria-label='RSS')
v-icon(color='orange') mdi-rss
</template>
<script>
import { mapState } from 'vuex'
import clipboard from '../assets/clipboard'
export default {
name: 'Nav',
mixins: [clipboard],
computed: {
...mapState(['filters', 'settings']),
feedLink () {
@ -83,9 +85,6 @@ export default {
}
},
methods: {
copyLink () {
this.$root.$message('common.feed_url_copied')
},
logout () {
this.$root.$message('common.logout_ok')
this.$auth.logout()

View file

@ -2,7 +2,7 @@
v-container
v-card-title {{$t('common.announcements')}}
v-card-subtitle(v-html="$t('admin.announcement_description')")
v-dialog(v-model='dialog' width='800px')
v-dialog(v-model='dialog' width='800px' :fullscreen='$vuetify.breakpoint.xsOnly')
v-card
v-card-title {{$t('admin.new_announcement')}}
v-card-text.px-0

View file

@ -9,7 +9,7 @@
:headers='headers')
template(v-slot:item.actions='{ item }')
v-btn(text small @click='confirm(item)' color='success') {{$t('common.confirm')}}
v-btn(text small :to='`/event/${item.id}`' color='success') {{$t('common.preview')}}
v-btn(text small :to='`/event/${item.slug || item.id}`' color='success') {{$t('common.preview')}}
v-btn(text small :to='`/add/${item.id}`' color='warning') {{$t('common.edit')}}
v-btn(text small @click='remove(item)'
color='error') {{$t('common.delete')}}

View file

@ -40,7 +40,7 @@
@blur='save("instance_place", instance_place)'
)
v-dialog(v-model='dialogAddInstance' width="500px")
v-dialog(v-model='dialogAddInstance' width='500px' :fullscreen='$vuetify.breakpoint.xsOnly')
v-card
v-card-title {{$t('admin.add_trusted_instance')}}
v-card-text

View file

@ -13,8 +13,7 @@
dense :headers='instancesHeader'
@click:row='instanceSelected')
template(v-slot:item.blocked="{ item }")
v-icon(v-if='item.blocked') mdi-checkbox-intermediate
v-icon(v-else) mdi-checkbox-blank-outline
v-icon(@click='toggleBlock(item)') {{item.blocked ? 'mdi-checkbox-intermediate' : 'mdi-checkbox-blank-outline'}}
v-col(:span='11')
span {{$t('common.users')}}
@ -24,49 +23,39 @@
:search='usersFilter'
:hide-default-footer='users.length<5'
dense :headers='usersHeader')
//- template(v-slot:item.username="{item}")
//- a(:href='item.ap_id') {{item.object.preferredUsername}}
//- el-table-column(:label="$t('common.user')" width='150')
//- template(slot-scope='data')
//- span(slot='reference')
//- a(:href='data.row.object.id' target='_blank') {{data.row.object.name}}
//- small ({{data.row.object.preferredUsername}})
//- el-table-column(:label="$t('common.resources')" width='90')
//- template(slot-scope='data')
//- span {{data.row.resources.length}}
//- el-table-column(:label="$t('common.actions')" width='200')
//- template(slot-scope='data')
//- el-button-group
//- el-button(size='mini'
//- :type='data.row.blocked?"danger":"warning"'
//- @click='toggleUserBlock(data.row)') {{data.row.blocked?$t('admin.unblock'):$t('admin.block')}}
template(v-slot:item.blocked="{ item }")
v-icon(@click='toggleUserBlock(item)') {{item.blocked?'mdi-checkbox-intermediate':'mdi-checkbox-blank-outline'}}
div
v-card-title {{$t('common.resources')}}
v-data-table(:items='resources'
v-data-table(:items='resources' dense
:headers='resourcesHeader'
:hide-default-footer='resources.length<10'
)
//- el-table-column(:label="$t('common.event')")
//- template(slot-scope='data')
//- span {{data.row.event}}
//- el-table-column(:label="$t('common.resources')")
//- template(slot-scope='data')
//- span(:class='{disabled: data.row.hidden}' v-html='data.row.data.content')
//- el-table-column(:label="$t('common.user')" width='200')
//- template(slot-scope='data')
//- span(:class='{disabled: data.row.hidden}' v-html='data.row.data.actor')
//- el-table-column(:label="$t('common.actions')" width="150")
//- template(slot-scope='data')
//- el-dropdown
//- el-button(type="primary" icon="el-icon-arrow-down" size='mini') {{$t('common.moderation')}}
//- el-dropdown-menu(slot='dropdown')
//- el-dropdown-item(v-if='!data.row.hidden' icon='el-icon-remove' @click.native='hideResource(data.row, true)') {{$t('admin.hide_resource')}}
//- el-dropdown-item(v-else icon='el-icon-success' @click.native='hideResource(data.row, false)') {{$t('admin.show_resource')}}
//- el-dropdown-item(icon='el-icon-delete' @click.native='deleteResource(data.row)') {{$t('admin.delete_resource')}}
//- el-dropdown-item(icon='el-icon-lock' @click.native='toggleUserBlock(data.row.ap_user)') {{$t('admin.block_user')}}
:items-per-page='10')
template(v-slot:item.content='{ item }')
span(v-html='item.data.content')
template(v-slot:item.user='{ item }')
span {{item.ap_user.preferredUsername}}
template(v-slot:item.event='{ item }')
span {{item.event.title}}
template(v-slot:item.actions='{ item }')
v-menu(offset-y)
template(v-slot:activator="{ on }")
v-btn.mr-2(v-on='on' color='primary' small icon)
v-icon mdi-dots-vertical
v-list
v-list-item(v-if='!item.hidden' @click='hideResource(item, true)')
v-list-item-title <v-icon left>mdi-eye-off</v-icon> {{$t('admin.hide_resource')}}
v-list-item(v-else @click='hideResource(item, false)')
v-list-item-title <v-icon left>mdi-eye</v-icon> {{$t('admin.show_resource')}}
v-list-item(@click='deleteResource(item)')
v-list-item-title <v-icon left>mdi-delete</v-icon> {{$t('admin.delete_resource')}}
//- v-list-item(@click='toggleUserBlock(item.ap_user)')
//- v-list-item-title <v-icon left>mdi-lock</v-icon> {{$t('admin.block_user')}}
</template>
<script>
import { mapState, mapActions } from 'vuex'
import get from 'lodash/get'
export default {
name: 'Moderation',
@ -76,7 +65,8 @@ export default {
resources: [],
users: [],
usersHeader: [
{ value: 'object.preferredUsername', text: 'Name' }
{ value: 'object.preferredUsername', text: 'Name' },
{ value: 'blocked', text: 'Blocked' }
],
instancesHeader: [
{ value: 'domain', text: 'Domain' },
@ -85,44 +75,24 @@ export default {
{ value: 'users', text: 'known users' }
],
resourcesHeader: [
{ value: '', text: '' }
{ value: 'created', text: 'Created' },
{ value: 'event', text: 'Event' },
{ value: 'user', text: 'user' },
{ value: 'content', text: 'Content' },
{ value: 'actions', text: 'Actions' }
],
usersFilter: '',
instancesFilter: ''
}
},
computed: {
...mapState(['settings'])
// paginatedResources () {
// return this.resources.slice((this.resourcePage - 1) * this.perPage,
// this.resourcePage * this.perPage)
// },
// paginatedInstances () {
// return this.filteredInstances.slice((this.instancePage - 1) * this.perPage,
// this.instancePage * this.perPage)
// },
// filteredUsers () {
// if (!this.usersFilter) { return this.users }
// const usersFilter = this.usersFilter.toLowerCase()
// return this.users.filter(user => user.name.includes(usersFilter) || user.preferredName.includes(usersFilter))
// },
// filteredInstances () {
// if (!this.instancesFilter) { return this.instances }
// const instancesFilter = this.instancesFilter.toLowerCase()
// return this.instances.filter(instance =>
// (instance.name && instance.name.includes(instancesFilter)) ||
// (instance.domain && instance.domain.includes(instancesFilter))
// )
// },
// paginatedSelectedUsers () {
// return this.filteredUsers.slice((this.userPage - 1) * this.perPage,
// this.userPage * this.perPage)
// }
},
computed: mapState(['settings']),
async mounted () {
this.instances = await this.$axios.$get('/instances')
if (!this.instances.length) {
return
}
this.users = await this.$axios.$get(`/instances/${this.instances[0].domain}`)
this.resources = await this.$axios.$get('/resources')
// this.users = await this.$axios.$get('/users')
},
methods: {
...mapActions(['setSetting']),
@ -133,6 +103,7 @@ export default {
},
async instanceSelected (instance) {
this.users = await this.$axios.$get(`/instances/${instance.domain}`)
this.resources = await this.$axios.$get('/resources', { filters: { instance: instance.domain } })
},
async hideResource (resource, hidden) {
await this.$axios.$put(`/resources/${resource.id}`, { hidden })
@ -140,7 +111,7 @@ export default {
},
async toggleUserBlock (ap_user) {
if (!ap_user.blocked) {
const ret = await this.$root.$confirm('admin.user_block_confirm')
const ret = await this.$root.$confirm('admin.user_block_confirm', { user: get(ap_user, 'object.preferredUsername', ap_user.preferredUsername) })
if (!ret) { return }
}
await this.$axios.post('/instances/toggle_user_block', { ap_id: ap_user.ap_id })
@ -153,6 +124,10 @@ export default {
this.resources = this.resources.filter(r => r.id !== resource.id)
},
async toggleBlock (instance) {
if (!instance.blocked) {
const ret = await this.$root.$confirm('admin.instance_block_confirm', { instance: instance.domain })
if (!ret) { return }
}
await this.$axios.post('/instances/toggle_block', { instance: instance.domain, blocked: !instance.blocked })
instance.blocked = !instance.blocked
}

View file

@ -3,7 +3,7 @@
v-card-title {{$t('common.places')}}
v-card-subtitle(v-html="$t('admin.place_description')")
v-dialog(v-model='dialog' width='600')
v-dialog(v-model='dialog' width='600' :fullscreen='$vuetify.breakpoint.xsOnly')
v-card(color='secondary')
v-card-title {{$t('admin.edit_place')}}
v-card-text

77
components/admin/SMTP.vue Normal file
View file

@ -0,0 +1,77 @@
<template lang="pug">
v-card
v-card-title SMTP Email configuration
v-card-text
p(v-html="$t('admin.smtp_description')")
v-form(v-model='isValid')
v-text-field(v-model='admin_email'
@blur="save('admin_email', admin_email )"
:label="$t('admin.admin_email')"
:rules="$validators.email")
v-text-field(v-model='smtp.host'
:label="$t('admin.smtp_hostname')"
:rules="[$validators.required('admin.smtp_hostname')]")
v-text-field(v-model='smtp.auth.user'
:label="$t('common.user')"
:rules="[$validators.required('common.user')]")
v-text-field(v-model='smtp.auth.pass'
:label="$t('common.password')"
:rules="[$validators.required('common.password')]"
type='password')
v-card-actions
v-spacer
v-btn(color='primary' @click='testSMTP' :loading='loading' :disabled='loading || !isValid') {{$t('admin.smtp_test_button')}}
v-btn(color='warning' @click="done") {{$t("common.ok")}}
</template>
<script>
import { mapActions, mapState } from 'vuex'
export default {
data ({ $store }) {
const smtp = { host: '', auth: { user: '', pass: '' } }
if ($store.state.settings.smtp) {
smtp.host = $store.state.settings.smtp.host
if ($store.state.settings.smtp.auth) {
smtp.auth.user = $store.state.settings.smtp.auth.user
smtp.auth.pass = $store.state.settings.smtp.auth.pass
}
}
return {
isValid: false,
loading: false,
smtp,
admin_email: $store.state.settings.admin_email || ''
}
},
computed: mapState(['settings']),
methods: {
...mapActions(['setSetting']),
async testSMTP () {
this.loading = true
try {
this.setSetting({ key: 'smtp', value: this.smtp })
await this.$axios.$post('/settings/smtp', { smtp: this.smtp })
this.$root.$message(this.$t('admin.smtp_test_success', { admin_email: this.admin_email }), { color: 'success' })
} catch (e) {
console.error(e)
this.$root.$message(e.response && e.response.data, { color: 'error' })
}
this.loading = false
},
save (key, value) {
if (this.settings[key] !== value) {
this.setSetting({ key, value })
}
},
done () {
this.setSetting({ key: 'smtp', value: JSON.parse(JSON.stringify(this.smtp)) })
this.$emit('close')
},
}
}
</script>

View file

@ -3,19 +3,25 @@
v-card-title {{$t('common.settings')}}
v-card-text
v-text-field(v-model='title'
:label="$t('common.title')"
:hint="$t('admin.title_description')"
@blur='save("title", title)'
persistent-hint)
v-text-field.mt-5(v-model='description'
:label="$t('common.description')"
:hint="$t('admin.description_description')"
persistent-hint
@blur='save("description", description)')
//- select timezone
v-autocomplete(v-model='instance_timezone'
v-autocomplete.mt-5(v-model='instance_timezone'
:label="$t('admin.select_instance_timezone')"
:hint="$t('admin.instance_timezone_description')"
:items="filteredTimezones"
persistent-hint
item-text='value'
item-value='value'
placeholder='Timezone, type to search')
template(v-slot:item='{ item }')
v-list-item-content
v-list-item-title {{item.value}}
v-list-item-subtitle {{item.offset}}
v-select.mt-5(
v-model='instance_locale'
@ -25,19 +31,6 @@
:items='locales'
)
v-text-field.mt-5(v-model='title'
:label="$t('common.title')"
:hint="$t('admin.title_description')"
@blur='save("title", title)'
persistent-hint
)
v-text-field.mt-5(v-model='description'
:label="$t('common.description')"
:hint="$t('admin.description_description')"
persistent-hint
@blur='save("description", description)')
v-switch.mt-4(v-model='allow_registration'
inset
:label="$t('admin.allow_registration_description')")
@ -55,24 +48,43 @@
inset
:label="$t('admin.recurrent_event_visible')")
v-dialog(v-model='showSMTP' destroy-on-close max-width='700px' :fullscreen='$vuetify.breakpoint.xsOnly')
SMTP(@close='showSMTP = false')
v-card-actions
v-btn(text @click='showSMTP=true')
<v-icon v-if='showSMTPAlert' color='error'>mdi-alert</v-icon> {{$t('admin.show_smtp_setup')}}
v-btn(text @click='$emit("complete")' color='primary' v-if='setup') {{$t('common.next')}}
v-icon mdi-arrow-right
</template>
<script>
import SMTP from './SMTP.vue'
import { mapActions, mapState } from 'vuex'
import moment from 'moment-timezone'
import _ from 'lodash'
import moment from 'dayjs'
import tzNames from './tz.json'
import locales from '../../locales/esm'
export default {
props: {
setup: { type: Boolean, default: false }
},
components: { SMTP },
name: 'Settings',
data ({ $store }) {
return {
title: $store.state.settings.title,
description: $store.state.settings.description,
locales: Object.keys(locales).map(locale => ({ value: locale, text: locales[locale] }))
locales: Object.keys(locales).map(locale => ({ value: locale, text: locales[locale] })),
showSMTP: false,
}
},
computed: {
...mapState(['settings']),
showSMTPAlert () {
return !this.setup && (!this.settings.admin_email || !this.settings.smtp || !this.settings.smtp.host || !this.settings.smtp.user)
},
instance_locale: {
get () { return this.settings.instance_locale },
set (value) { this.setSetting({ key: 'instance_locale', value }) }
@ -99,11 +111,8 @@ export default {
},
filteredTimezones () {
const current_timezone = moment.tz.guess()
const ret = _(moment.tz.names())
.unshift(current_timezone)
.map(tz => ({ value: tz, offset: moment().tz(tz).format('z Z') }))
.value()
return ret
tzNames.unshift(current_timezone)
return tzNames
}
},
methods: {

View file

@ -9,12 +9,12 @@
accept='image/*')
template(slot='append-outer')
v-btn(color='warning' text @click='resetLogo') <v-icon>mdi-restore</v-icon> {{$t('common.reset')}}
v-img(:src='`${settings.baseurl}/favicon.ico?${logoKey}`'
max-width="100px" max-height="80px" contain)
v-img(:src='`${settings.baseurl}/logo.png?${logoKey}`'
max-width="60px" max-height="60px" contain)
//- v-switch.mt-5(v-model='is_dark'
//- inset
//- :label="$t('admin.is_dark')")
v-switch.mt-5(v-model='is_dark'
inset
:label="$t('admin.is_dark')")
//- TODO choose theme colors
//- v-row
@ -32,7 +32,7 @@
//- v-on='on') {{i}}
//- v-color-picker(light @update:color='c => updateColor(i, c)')
v-dialog(v-model='linkModal' width='500')
v-dialog(v-model='linkModal' width='500' :fullscreen='$vuetify.breakpoint.xsOnly')
v-card
v-card-title {{$t('admin.footer_links')}}
v-card-text
@ -52,17 +52,16 @@
v-card-text
v-btn(color='primary' text @click='openLinkModal') <v-icon>mdi-plus</v-icon> {{$t('admin.add_link')}}
v-btn(color='warning' text @click='reset') <v-icon>mdi-restore</v-icon> {{$t('common.reset')}}
v-list.mt-1(two-line subheader)
v-list-item(v-for='link in settings.footerLinks'
:key='`${link.label}`' @click='editFooterLink(link)')
v-list-item-content
v-list-item-title {{link.label}}
v-list-item-subtitle {{link.href}}
v-list-item-action
//- v-btn.float-right(icon color='accent' @click='editFooterLink(link)')
//- v-icon mdi-pencil
v-btn(icon color='error' @click.stop='removeFooterLink(link)')
v-icon mdi-delete-forever
v-card
v-list.mt-1(two-line subheader)
v-list-item(v-for='link in settings.footerLinks'
:key='`${link.label}`' @click='editFooterLink(link)')
v-list-item-content
v-list-item-title {{link.label}}
v-list-item-subtitle {{link.href}}
v-list-item-action
v-btn(icon color='error' @click.stop='removeFooterLink(link)')
v-icon mdi-delete-forever
</template>
<script>

View file

@ -10,7 +10,7 @@
v-btn(color='primary' text @click='newUserDialog = true') <v-icon>mdi-plus</v-icon> {{$t('common.new_user')}}
//- ADD NEW USER
v-dialog(v-model='newUserDialog' :fullscreen="$vuetify.breakpoint.xsOnly")
v-dialog(v-model='newUserDialog' :fullscreen='$vuetify.breakpoint.xsOnly')
v-card(color='secondary')
v-card-title {{$t('common.new_user')}}
@ -37,6 +37,7 @@
v-icon(v-if='item.is_active' color='success') mdi-check
v-icon(v-else color='warning') mdi-close
template(v-slot:item.actions='{item}')
v-btn(v-if='item.recover_code' text small :to='`/user_confirm/${item.recover_code}`') {{$t('common.confirm')}}
v-btn(text small @click='toggle(item)'
:color='item.is_active?"warning":"success"') {{item.is_active?$t('common.disable'):$t('common.enable')}}
v-btn(text small @click='toggleAdmin(item)'
@ -76,9 +77,16 @@ export default {
async deleteUser (user) {
const ret = await this.$root.$confirm('admin.delete_user_confirm', { user: user.email })
if (!ret) { return }
await this.$axios.delete(`/user/${user.id}`)
this.$root.$message('admin.user_remove_ok')
this.users_ = this.users_.filter(u => u.id !== user.id)
try {
this.loading = true
await this.$axios.$delete(`/user/${user.id}`)
this.$root.$message('admin.user_remove_ok')
this.$emit('update')
} catch (e) {
const err = get(e, 'response.data.errors[0].message', e)
this.$root.$message(this.$t(err), { color: 'error' })
this.loading = false
}
},
async toggle (user) {
if (user.is_active) {

595
components/admin/tz.json Normal file
View file

@ -0,0 +1,595 @@
[
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Timbuktu",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/ComodRivadavia",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Atka",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Ensenada",
"America/Fort_Nelson",
"America/Fort_Wayne",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Knox_IN",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montreal",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Acre",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Rosario",
"America/Santa_Isabel",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Shiprock",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Virgin",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/South_Pole",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Ashkhabad",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Colombo",
"Asia/Dacca",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Harbin",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kashgar",
"Asia/Kathmandu",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macao",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ujung_Pandang",
"Asia/Ulaanbaatar",
"Asia/Ulan_Bator",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Faroe",
"Atlantic/Jan_Mayen",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/ACT",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Canberra",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/LHI",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/NSW",
"Australia/North",
"Australia/Perth",
"Australia/Queensland",
"Australia/South",
"Australia/Sydney",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Brazil/Acre",
"Brazil/DeNoronha",
"Brazil/East",
"Brazil/West",
"CET",
"CST6CDT",
"Canada/Atlantic",
"Canada/Central",
"Canada/Eastern",
"Canada/Mountain",
"Canada/Newfoundland",
"Canada/Pacific",
"Canada/Saskatchewan",
"Canada/Yukon",
"Chile/Continental",
"Chile/EasterIsland",
"Cuba",
"EET",
"EST",
"EST5EDT",
"Egypt",
"Eire",
"Etc/GMT",
"Etc/GMT+0",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT-0",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Etc/GMT0",
"Etc/Greenwich",
"Etc/UCT",
"Etc/UTC",
"Etc/Universal",
"Etc/Zulu",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belfast",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Tiraspol",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"GB",
"GB-Eire",
"GMT",
"GMT+0",
"GMT-0",
"GMT0",
"Greenwich",
"HST",
"Hongkong",
"Iceland",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Iran",
"Israel",
"Jamaica",
"Japan",
"Kwajalein",
"Libya",
"MET",
"MST",
"MST7MDT",
"Mexico/BajaNorte",
"Mexico/BajaSur",
"Mexico/General",
"NZ",
"NZ-CHAT",
"Navajo",
"PRC",
"PST8PDT",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Johnston",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Samoa",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis",
"Pacific/Yap",
"Poland",
"Portugal",
"ROC",
"ROK",
"Singapore",
"Turkey",
"UCT",
"US/Alaska",
"US/Aleutian",
"US/Arizona",
"US/Central",
"US/East-Indiana",
"US/Eastern",
"US/Hawaii",
"US/Indiana-Starke",
"US/Michigan",
"US/Mountain",
"US/Pacific",
"US/Samoa",
"UTC",
"Universal",
"W-SU",
"WET",
"Zulu"
]

View file

@ -1,26 +0,0 @@
{
"title": "Gancio",
"description": "A shared agenda for local communities",
"baseurl": "http://localhost:13120",
"server": {
"host": "localhost",
"port": 13120
},
"log_level": "debug",
"log_path": "./logs",
"db": {
"dialect": "sqlite",
"storage": "./db.sqlite",
"logging": false
},
"upload_path": "./",
"smtp": {
"auth": {
"user": "",
"pass": ""
},
"secure": true,
"host": ""
},
"admin_email": "admin"
}

View file

@ -1,10 +0,0 @@
// DO NOT TOUCH THIS FILE
const fs = require('fs')
const config_path = process.env.config_path
let config = {}
if (fs.existsSync(config_path)) {
config = require(config_path)
}
module.exports = config

View file

@ -34,11 +34,9 @@ plugins:
search_enabled: true
aux_links:
"Blog":
- https://blog.gancio.org
"Source":
- https://framagit.org/les/gancio
"Mastodon":
"@gancio@mastodon.cisti.org":
- https://mastodon.cisti.org/@gancio
gh_edit_link: true # show or hide edit this page link

View file

@ -8,6 +8,104 @@ nav_order: 10
All notable changes to this project will be documented in this file.
### 1.2.2 - 7 dic '21
- shiny new gancio-event\[s\] webcomponents => [docs](https://gancio.org/usage/embed)
- new backend plugin system
- improve media focal point selection
- improve non-js experience (load img, use native lazy loading)
- improve user_confirm / recover code flow
- fix task manager exception
- fix db initialization when a custom setup is used, #131
- remove vue-clipboard2 dependency due to [this](https://github.com/euvl/v-clipboard/issues/18) bug and using a [native with fallback mixin instead](./assets/clipboard.js)
- fix a regression to support old CPU, #130
- makes dialog use fullscreen on mobile
- fix Delete AP Actor Action from fediverse when remote Actor is gone
- add `max` param to /events API
### 1.2.1 - 11 nov '21
- fix `Note` remove from fediverse
- AP Actor is now `Application`, was `Person`
- better handling event AP representations
this release is a step forward to improve AP compatibility with other platforms, thanks @tcit
### 1.2.0 - 9 nov '21
- do not overwrite event slug when title is modified to preserve links
- add public cache to events images
- fix baseurl in initial setup configuration
- fix user removal
- load settings during startup and not for each request
- refactoring user custom locale
- published AP event's type is not `Note` anymore but `Event`
### 1.1.1 - 29 ott '21
- fix issue adding event with dueHour resulting in `bad request`
- fix restart during setup
- do not use @nuxt/vuetify module, manually preload vuetify via plugin
- remove deprecated nuxt-express-module and use serverMiddleware directly
### 1.1.0 - 26 ott '21
- a whole new setup via web! fix #126
- new SMTP configuration dialog, fix #115
- re-order general settings in admin panel
- new dark/light theme setting
- move quite all configuration into db
- fix some email recipients
- fix hidden events when not ended
- update translations
- improve install documentation
- add systemd gancio.service
- allow italic and span tags inside editor
- remove moment-timezone, consola, config, inquirer dependencies
- update deps
### 1.0.6 (alpha)
- fix Dockerfile yarn cache issue on update, #123
- fix overflow on event title @homepage
- better import dialog on mobile
- re-add attachment to AP
- fix max event export
- update deps
### 1.0.5 (alpha)
- fix/improve debian install docs
- fix ics export, use new ics package
- use slug url everywhere (rss feed, embedded list)
- use i18n in event confirmation email
- remove lot of deps warning and remove some unused dependencies
- fix show_recurrent in embedded list
- remove old to-ico dep, use png favicon instead
### 1.0.4 (alpha)
- shows a generic img for events without it
### 1.0.3 (alpha)
- 12 hour clock selection, #119
- improve media management
- add alt-text to featured image, fix #106
- add focalPoint support, fix #116
- improve a11y
- improve node v16 compatibility
- fix #122 ? (downgrade prettier)
### 1.0.2 (alpha)
- improve oauth flow UI
- [WordPress plugin](https://wordpress.org/plugins/wpgancio/)
- fix h-event import
- improve error logging (add stack trace to exception)
- choose start date for recurreing events (#120)
- fix user delete from admin
### 1.0.1 (alpha)
- fix AP resource removal
- improve AP resource UI
- fix Docker setup
- update deps
### 1.0 (alpha)
This release is a complete rewrite of frontend UI and many internals, main changes are:

View file

@ -7,5 +7,8 @@ nav_order: 9
## Contacts
### :elephant: Mastodon ⇒ [@gancio@mastodon.cisti.org](https://mastodon.cisti.org/@gancio)
- :elephant: Mastodon ⇒ [@gancio@mastodon.cisti.org](https://mastodon.cisti.org/@gancio)
- :email: Email ⇒ [info@cisti.org](mailto:info@cisti.org)
- IRC ⇒ #gancio @ irc.autistici.org (sometimes...)

View file

@ -1,2 +1,7 @@
FROM node:buster
RUN yarn global add --silent https://gancio.org/latest.tgz 2> /dev/null
RUN yarn global remove gancio || true
RUN yarn cache clean
RUN yarn global add --latest --production --silent https://gancio.org/latest.tgz 2> /dev/null
ADD entrypoint.sh /
RUN chmod 755 /entrypoint.sh
ENTRYPOINT [ "/bin/sh", "/entrypoint.sh" ]

4
docs/docker/entrypoint.sh Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
chown -R node:node /home/node
su node -c "$*"

View file

@ -19,12 +19,13 @@ services:
build: .
restart: always
image: node:buster
user: node
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
- GANCIO_DATA=/home/node/data
- NODE_ENV=production
command: gancio start --docker
entrypoint: /entrypoint.sh
volumes:
- ./data:/home/node/data
ports:

View file

@ -5,11 +5,12 @@ services:
build: .
restart: always
image: node:buster
user: node
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
- GANCIO_DATA=/home/node/data
- NODE_ENV=production
entrypoint: /entrypoint.sh
command: gancio start --docker
volumes:
- ./data:/home/node/data

33
docs/embed.md Normal file
View file

@ -0,0 +1,33 @@
---
layout: default
title: Embed events
permalink: /usage/embed
nav_order: 1
parent: Usage
---
## Embed event
You can embed a list of filtered events or a specific event card in your webpage using a classic old-school `iframe` or a shiny new webcomponent.
### Webcomponents
The webcomponent require a small js to be loaded in your page:
```javascript
<script src='https://demo.gancio.org/gancio-events.es.js'></script>
```
#### embed a single event
> you can copy the code in **event page > Embed > Copy**
<script src='https://demo.gancio.org/gancio-events.es.js'></script>
<gancio-event id=17 baseurl='https://demo.gancio.org'></gancio-event>
```javascript
<gancio-event id=17 baseurl='https://demo.gancio.org'></gancio-event>
```
#### embed event lists
> you can copy the code in **Export > List > Copy**
<gancio-events baseurl='https://demo.gancio.org'></gancio-events>
```javascript
<gancio-event baseurl='https://demo.gancio.org'></gancio-event>
```

View file

@ -5,12 +5,8 @@ permalink: /federation
nav_order: 9
---
## Federation
## Federation / ActivityPub
Each instance has only one [AP Actor](https://www.w3.org/TR/activitypub/#actors) that publishes each event.
We are considering the introduction of other “Actor” but they will not be linked to users, rather to places or tags/categories.
There are no personal homes with a timeline of people I follow, everyone has a sort of local timeline of the instance, its an anti filter-bubble feature.
Events are not published with the type `Event` but with type `Note` because we wanted to add the possibility to interact with events from mastodon instances (boost / bookmark and “comments” that we call resources because we dont want it to become a place of debate, but more a place where to keep a historical memory of events, e.g. an audio recording of a talk).
When mastodon will support `Event` object type we will change for sure.
Each instance has only one [AP Actor](https://www.w3.org/TR/activitypub/#actors) of type `Application` named `gancio@instance.tld` that publishes each event.
We are considering the introduction of other `Actor` but they will not be linked to users, rather to places or tags/categories.
There are no personal homes with a timeline of people you follow, everyone has a sort of local timeline of the instance, its an anti filter-bubble feature.

13
docs/gancio.service Normal file
View file

@ -0,0 +1,13 @@
[Unit]
Description=Gancio
After=network.target
[Service]
Type=simple
User=gancio
WorkingDirectory=/opt/gancio
ExecStart=gancio
Restart=always
[Install]
WantedBy=multi-user.target

View file

@ -8,16 +8,43 @@ parent: Install
## Backup
The following commands should be valid for every setup (docker/debian/sqlite/postgres) but check your installation directory first.
This includes database, configuration, custom user locales, logs, images and thumbnails.
The following commands should be valid for every setup (docker/debian/sqlite/postgres).
1. Move to gancio path
```bash
cd /opt/gancio/ # or /home/gancio or where your installation is
tar -czf gancio-$(date +%Y-%m-%d-%H%M%S)-backup.tgz \
$(ls -d config.json uploads user_locale db.sqlite postgres data logs 2> /dev/null)
cd /opt/gancio/ # or where your installation is
```
1. Backup PostgreSQL (only required for non-docker PostgreSQL installation)
```bash
sudo -u postgres pg_dump -Fc gancio > gancio.dump
```
1. Archive database, configuration, custom user locales, logs, images and thumbnails
```bash
sudo tar -czf gancio-$(date +%Y-%m-%d-%H%M%S)-backup.tgz \
$(ls -d config.json uploads user_locale db.sqlite gancio.dump postgres data logs 2> /dev/null)
```
> warning "Permission denied"
> `postgres` directory could have different permission or owner, in this case you need to be root or use `sudo` instead.
> info "Automatic backup"
> To periodically backup your data you should probably use something like [restic](https://restic.net) or [borg](https://www.borgbackup.org/)
> To periodically backup your data you should probably use something like [restic](https://restic.net) or [borg](https://www.borgbackup.org/)
## Restore
1. Install a clean gancio
1. Move to gancio path
```bash
cd /opt/gancio/ # or where your installation is
```
1. Extract your backup
```bash
tar xvf gancio-*-backup.tgz
```
1. Restore PostgreSQL database (only required for non-docker PostgreSQL installation)
```
sudo -u postgres createdb gancio
sudo -u postgres pg_restore -d gancio gancio.dump
```

View file

@ -16,22 +16,8 @@ The configuration file shoud be a `.json` or a `.js` file and could be specified
1. TOC
{:toc}
- ### Title
The title will be in rss feed, in html head and in emails:
`"title": "Gancio"`
![title](../assets/title.png)
- ### Description
`"description": "a shared agenda for local communities"`
- ### BaseURL
URL where your site will be accessible (include http or https):
`"baseurl": "https://gancio.cisti.org"`
- ### Server
This probably support unix socket too :D
This probably support unix socket too
```json
"server": {
@ -52,78 +38,28 @@ DB configuration, look [here](https://sequelize.org/master/class/lib/sequelize.j
Where to save images
`"upload_path": "./uploads"`
- ### SMTP
SMTP configuration.
Gancio should send emails at following events:
- the admin should receive emails of anon event (if enabled) to confirm them.
- the admin should receive emails of registration request (if enabled) to confirm them.
- an user should receive an email of registration requested.
- an user should receive an email of confirmed registration.
- an user should receive a confirmation email when subscribed directly by admin.
```json
"smtp": {
"auth": {
"user": "",
"pass": ""
},
"secure": true,
"host": ""
}
```
- ### Admin_email
Email of administrator. Note that email from gancio comes from this email and that
the SMTP configuration above should allow to use this address as from.
- ### User locale
Probably you want to modify some text for your specific community, that's
why we thought the `user_locale` configuration: you can specify your version of
each string of **gancio** making a directory with your locales inside.
For example, let's say you want to modify the text inside the `/about`
page:
`mkdir /opt/gancio/user_locale`
put something like this in `/opt/gancio/user_locale/en.js` to override the about in
For example, let's say you want to modify the text shown during registration:
`mkdir /opt/gancio/user_locale`
put something like this in `/opt/gancio/user_locale/en.json` to override the registration description in
english:
```js
export default {
about: 'A new about'
```json
{
"registrer": {
"description": "My new registration page description"
}
}
```
and then point the `user_locale` configuration to that directory:
and then point the `user_locale` configuration to that directory (in your `config.json`):
```json
"user_locale": "/opt/gancio/user_locale"
```
Watch [here](https://framagit.org/les/gancio/tree/master/locales) for a
list of strings you can override.
<small>:warning: Note that a restart is needed when you change
user_locale's content.</small>
list of strings you can override.
## Default settings
```json
{
"title": "Gancio",
"description": "A shared agenda for local communities",
"baseurl": "http://localhost:13120",
"server": {
"host": "127.0.0.1",
"port": 13120
},
"db": {
"dialect": "sqlite",
"storage": "./db.sqlite"
},
"upload_path": "./",
"smtp": {
"auth": {
"user": "",
"pass": ""
},
"secure": true,
"host": ""
},
"admin_email": "",
}
```
> warning "Restart needed"
> Note that a restart is needed when you change user_locale's content.

View file

@ -7,24 +7,24 @@ parent: Install
## Debian installation
1. Install Node.js & yarn (**from root**)
1. Install dependencies
```bash
curl -sL https://deb.nodesource.com/setup_16.x | bash -
apt-get install -y nodejs
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
apt-get update && apt-get install yarn
sudo apt install curl gcc g++ make wget libpq-dev
```
1. Install Node.js & yarn
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt-get install -y nodejs
sudo npm install -g yarn
```
<small>[source](https://github.com/nodesource/distributions/blob/master/README.md)</small>
1. Install Gancio
```bash
yarn global add --silent {{site.url}}{% link /latest.tgz %} 2> /dev/null
```
1. Setup with postgreSQL __(optional as you can choose sqlite)__
```bash
apt-get install postgresql
sudo apt-get install postgresql
# Create the database
su postgres -c psql
postgres=# create database gancio;
@ -34,36 +34,38 @@ postgres=# grant all privileges on database gancio to gancio;
1. Create a user to run gancio from
```bash
adduser gancio
su gancio
sudo adduser --group --system --shell /bin/false --home /opt/gancio gancio
```
1. Install Gancio
```bash
sudo yarn global add --silent {{site.url}}/latest.tgz 2> /dev/null
```
1. Launch interactive setup
1. Setup systemd service and reload systemd
```bash
gancio setup --config config.json
sudo wget http://gancio.org/gancio.service -O /etc/systemd/system/gancio.service
sudo systemctl daemon-reload
sudo systemctl enable gancio
```
1. Start
1. Start gancio service (this should listen on port 13120)
```bash
gancio start --config config.json
sudo systemctl start gancio
```
1. Point your web browser to [http://localhost:13120](http://localhost:13120) or where you selected during setup.
1. [Setup nginx as a proxy]({% link install/nginx.md %})
1. To deploy gancio in production you should use something like **[pm2](http://pm2.keymetrics.io/)**:
```bash
sudo yarn global add pm2
pm2 start gancio -- --config config.json
# Run this command to run your application as a service and automatically restart after a reboot:
pm2 startup # read the output!
sudo pm2 startup -u gancio
```
1. Point your web browser to your domain :tada:
## Upgrade
> warning "Backup your data"
> Backup your data is generally a good thing to do and this is especially true before upgrading.
> Don't be lazy and [backup]({% link install/backup.md %}) your data!
```bash
sudo yarn global add --silent {{site.url}}{% link /latest.tgz %} 2> /dev/null
sudo service pm2 restart
yarn global remove gancio
yarn cache clean
yarn global add --silent {{site.url}}/latest.tgz 2> /dev/null
sudo service gancio restart
```

View file

@ -13,28 +13,37 @@ nav_order: 2
## Initial setup
> info "Clone not needed"
> You do not need to clone the full repo, a `Dockerfile` and a `docker-compose.yml` are enough.
- __You must have the following dependencies installed: Docker, Docker Compose and Nginx__
```bash
sudo apt install docker docker-compose nginx
```
or
1. [Install docker](https://docs.docker.com/engine/install/)
1. [Install docker-compose](https://docs.docker.com/compose/install/)
1. [Install nginx](https://nginx.org/en/docs/install.html)
- __Create a directory where everything related to gancio is stored__
```bash
mkdir -p /opt/gancio/data
mkdir -p /opt/gancio
cd /opt/gancio
```
## Use sqlite
<div class='code-example bg-grey-lt-100' markdown="1">
1. **Download docker-compose.yml and Dockerfile**
```bash
wget {{site.url}}{% link /docker/Dockerfile %}
wget {{site.url}}{% link /docker/entrypoint.sh %}
wget {{site.url}}{% link /docker/sqlite/docker-compose.yml %}
```
1. Build docker image and launch interactive setup
1. Build docker image
```
docker-compose build
docker-compose run --rm gancio gancio setup --docker --db=sqlite
```
</div>
@ -44,13 +53,13 @@ docker-compose run --rm gancio gancio setup --docker --db=sqlite
1. **Download docker-compose.yml and Dockerfile**
```bash
wget {{site.url}}{% link /docker/Dockerfile %}
wget {{site.url}}{% link /docker/entrypoint.sh %}
wget {{site.url}}{% link /docker/postgres/docker-compose.yml %}
```
1. Build docker image and launch interactive setup
1. Build docker image
```
docker-compose build
docker-compose run --rm gancio gancio setup --docker --db=postgres
```
</div>
@ -67,9 +76,9 @@ docker-compose up -d
tail -f data/logs/gancio.log
```
1. [Setup nginx as a proxy]({% link install/nginx.md %}
1. [Setup nginx as a proxy]({% link install/nginx.md %})
1. Point your web browser to [http://localhost:13120](http://localhost:13120) or where you specified during setup and enjoy :tada:
1. Point your web browser to your domain :tada:
1. Edit `data/config.json` and restart the container on your needs, see [Configuration]({% link install/configuration.md %}) for more details.
@ -86,6 +95,7 @@ tail -f data/logs/gancio.log
> 1. `cd /opt/gancio`
> 1. [Backup your data]({% link install/backup.md %})
> 1. Download new `Dockerfile` <br/> `wget {{site.url}}{% link /docker/Dockerfile %}`
> 1. Download new `entrypoint.sh` <br/> `wget {{site.url}}{% link /docker/entrypoint.sh %}`
> 1. Download new `docker-compose.yml` (substitute `sqlite` with `postgres` in case): <br/>`wget {{site.url}}{% link /docker/sqlite/docker-compose.yml %}`
> 1. Build the new container `docker-compose build`
> 1. Extract your backup into `./data` <br/>`mkdir data; tar xvzf gancio-<yourLastBackup>-backup.tgz -C data`
@ -96,4 +106,4 @@ tail -f data/logs/gancio.log
```bash
cd /opt/gancio
docker-compose up -d --no-deps --build
```
```

View file

@ -6,17 +6,20 @@ has_children: true
nav_order: 3
has_toc: false
---
## Install
## Pre-requisites
- a Linux machine with <strong>root access</strong> (a VPS with 500MB of RAM and a cpu should be enough but do not use docker on a small machine :stuck_out_tongue_winking_eye:)
- a domain name or subdomain (eg. gancio.mydomain.org, subpath are not supported)
- an SMTP server to deliver emails
You can install gancio on a cheap VPS (500mb of ram will be enough)
## Install
- [Install on Debian]({% link install/debian.md %})
- [Install using docker]({% link install/docker.md %})
### Post installation
- [Setup Nginx as a proxy]({% link install/nginx.md %})
- [Configuration]({% link install/configuration.md %})
- [Backup]({% link install/backup.md %})
- [Setup a backup]({% link install/backup.md %})
If you wanna hack or run the current development release take a look at [Hacking & contribute]({% link dev/dev.md %})
> info "Info"
> If you wanna hack or run the current development release take a look at [Hacking & contribute]({% link dev/dev.md %}).
>

View file

@ -8,69 +8,35 @@ parent: Install
## Nginx proxy configuration
This is the default nginx configuration for gancio, please modify at least the **server_name** and **ssl_certificate**'s path.
Note that this does not include a cache configuration and that gancio does
not use a cache control at all, if you can help with this task you're
welcome.
This is the default nginx configuration for gancio, please modify at least «YOUR_DOMAIN». Note that it does not include HTTPS setup but you can easily use [certbot](https://certbot.eff.org/) for that.
- __You should be in the correct directory__
`/etc/nginx/sites-available`
```nginx
server {
listen 80;
listen [::]:80;
server_name gancio.cisti.org;
root /var/www/letsencrypt;
location /.well-known/acme-challenge/ { allow all; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name gancio.cisti.org;
ssl_protocols TLSv1.2;
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
# Uncomment these lines once you acquire a certificate:
# ssl_certificate /etc/letsencrypt/live/gancio.cisti.org/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/gancio.cisti.org/privkey.pem;
server_name <<YOUR_DOMAIN>>;
keepalive_timeout 70;
sendfile on;
client_max_body_size 80m;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
add_header Strict-Transport-Security "max-age=31536000";
location / {
try_files $uri @proxy;
}
location @proxy {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Proxy "";
proxy_pass_header Server;
proxy_pass http://127.0.0.1:13120;
proxy_buffering on;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
tcp_nodelay on;
}
}
```
- __Following this, you should create a link to the file in sites-enabled:__
```bash
ln -s /etc/nginx/sites-available/<your-config> /etc/nginx/sites-enabled/
```

View file

@ -8,7 +8,7 @@ nav_order: 7
- [gancio.cisti.org](https://gancio.cisti.org) (Turin, Italy)
- [lapunta.org](https://lapunta.org) (Florence, Italy)
- [chesefa.org](https://chesefa.org) (Naples, Italy)
- [termine.161.social](https://termine.161.social) (Germany)
<small>Do you want your instance to appear here? [Write us]({% link contact.md %}).</small>
<small>Do you want your instance to appear here? [Write us]({% link contact.md %}).</small>

Binary file not shown.

View file

@ -6,6 +6,6 @@ nav_order: 1
has_children: true
---
# Usage
## Usage
ehmmm, help needed here :smile: feel free to send a PR => [here](https://framagit.org/les/gancio/tree/master/docs)

22
layouts/clean.vue Normal file
View file

@ -0,0 +1,22 @@
<template lang='pug'>
v-app(app)
Snackbar
Confirm
v-main(app)
v-fade-transition(hide-on-leave)
nuxt
</template>
<script>
import Snackbar from '../components/Snackbar'
import Confirm from '../components/Confirm'
export default {
name: 'Default',
components: { Snackbar, Confirm },
created () {
this.$vuetify.theme.dark = false
}
}
</script>

View file

@ -1,10 +1,10 @@
<template lang='pug'>
v-app
v-app(app)
Snackbar
Confirm
Nav
v-main
v-main(app)
v-fade-transition(hide-on-leave)
nuxt
@ -19,13 +19,18 @@ import Confirm from '../components/Confirm'
import { mapState } from 'vuex'
export default {
head () {
return {
htmlAttrs: {
lang: this.locale
}
}
},
name: 'Default',
components: { Nav, Snackbar, Footer, Confirm },
computed: mapState(['settings']),
computed: mapState(['settings', 'locale']),
created () {
this.$vuetify.theme.dark = this.settings['theme.is_dark']
this.$vuetify.theme.themes.dark.primary = this.settings['theme.primary']
this.$vuetify.theme.themes.light.primary = this.settings['theme.primary']
}
}
</script>

View file

@ -2,7 +2,8 @@
v-container.p-4.text-center
v-alert(v-if="error.statusCode === 404") ¯\_()_/¯ {{error.message}}
v-alert(v-else type='error') <v-icon>mdi-warning</v-icon> An error occurred: {{error.message}}
nuxt-link(to='/') Back to home
nuxt-link(to='/')
v-btn Back to home
</template>
<script>

View file

@ -5,7 +5,7 @@
Nav
v-main(app)
v-scroll-y-transition(hide-on-leave)
v-fade-transition(hide-on-leave)
nuxt
Footer

View file

@ -84,7 +84,9 @@
"import": "Importa",
"reset": "Reinicia",
"theme": "Tema",
"tags": "Etiquetes"
"tags": "Etiquetes",
"label": "Etiqueta",
"max_events": "Nre. màx. d'activitats"
},
"login": {
"description": "Amb la sessió iniciada pots afegir activitats noves.",
@ -122,7 +124,7 @@
"media_description": "Pots adjuntar un cartell (opcional)",
"added": "S'ha afegit l'activitat",
"added_anon": "S'ha afegit l'activitat però encara ha de ser confirmada.",
"where_description": "On es farà? Si no està posat, escriu-ho i <b>prem Enter</b>. ",
"where_description": "On es farà? Si no apareix el lloc el pots crear.",
"confirmed": "S'ha confirmat l'activitat",
"not_found": "No s'ha trobat l'activitat",
"remove_confirmation": "Segur que vols esborrar l'activitat?",
@ -153,14 +155,18 @@
"remove_recurrent_confirmation": "Estàs segur/a d'esborrar aquesta activitat periòdica?\nNo s'esborraran les ocurrències antigues, només es deixaran de crear les futures.",
"ics": "ICS",
"import_ICS": "Importa des d'un ICS",
"import_URL": "Importa des d'una URL"
"import_URL": "Importa des d'una URL",
"saved": "S'ha desat l'activitat",
"import_description": "Pots importar activitats des d'altres instàncies o plataformes que facin servir formats estàndards (ics o h-event)",
"edit_recurrent": "Edita l'activitat periòdica:",
"updated": "S'ha actualitzat l'activitat"
},
"admin": {
"place_description": "En el cas que un lloc és incorrecte o l'adreça ha de canviar, pots arreglar-ho.<br/>Tingues en compte que totes les activitats passades i futures associades amb aquest lloc també canviaran d'adreça.",
"event_confirm_description": "Des d'aquí pots confirmar les activitats creades anònimament",
"delete_user": "Esborra",
"remove_admin": "Esborra admin",
"delete_user_confirm": "Segur que vols esborrar aquest compte?",
"delete_user_confirm": "Segur que vols esborrar el compte {user}?",
"user_remove_ok": "S'ha esborrat el compte",
"user_create_ok": "S'ha creat el compte",
"allow_registration_description": "Vols deixar el registre obert?",
@ -189,7 +195,7 @@
"resources": "Recursos",
"user_blocked": "L'usuari/a {user} ja no podrà afegir recursos",
"favicon": "Logo",
"user_block_confirm": "Segur/a que vols bloquejar l'usuària?",
"user_block_confirm": "Segur que vols bloquejar a {user}?",
"delete_announcement_confirm": "Segur/a que vols esborrar l'anunci?",
"announcement_remove_ok": "S'ha esborrat l'anunci",
"announcement_description": "En aquesta secció pots afegir anuncis que romandran a la pàgina principal",
@ -210,7 +216,16 @@
"delete_footer_link_confirm": "Segur que vols esborrar aquest enllaç?",
"footer_links": "Enllaços del peu",
"add_link": "Afegeix un enllaç",
"is_dark": "Tema fosc"
"is_dark": "Tema fosc",
"disable_user_confirm": "Segur que vols deshabilitar a {user}?",
"add_instance": "Afegeix una instància",
"instance_block_confirm": "Segur que vols bloquejar la instància {instance}?",
"show_smtp_setup": "Configuració de correu",
"smtp_hostname": "Amfitrió SMTP (hostname)",
"smtp_description": "<ul><li>L'admin hauria de rebre un correu cada cop que es pengi alguna una activitat anònima (si estan activades).</li><li>L'admin hauria de rebre un correu per cada soŀlicitud de registre (si estan actives).</li><li>La usuària hauria de rebre un correu després de soŀlicitar registrar-se.</li><li>La usuària hauria de rebre un correu quan se li hagi confirmat el registre.</li><li>La usuària hauria de rebre un correu si l'admin la registra directament.</li><li>La usuària hauria de rebre un correu de restabliment de contrasenya si ho demana</li></ul>",
"smtp_test_success": "S'ha enviat un correu de prova a {admin_email}, comprova que hagi arribat bé",
"smtp_test_button": "Envia un correu de prova",
"admin_email": "Correu d'admin"
},
"auth": {
"not_confirmed": "Encara no s'ha confirmat…",
@ -252,5 +267,11 @@
"validators": {
"email": "Escriu una adreça de correu vàlida",
"required": "Cal omplir el camp {fieldName} és"
},
"setup": {
"completed": "S'ha completat la configuració inicial",
"check_db": "Comprova la BD",
"completed_description": "<p>Ara ja pots entrar amb aquesta usuària:<br/><br/>Nom: <b>{email}</b><br/>Contrasenya: <b>{password}<b/></p>",
"start": "Comença"
}
}

View file

@ -1,7 +1,7 @@
{
"register": {
"subject": "Hem rebut una soŀlicitud de registre",
"content": "Hem rebut una soŀlicitud de registre. Hi respondrem tan aviat com ens sigui possible.\nSalut"
"content": "Hem rebut una soŀlicitud de registre. Hi respondrem tan aviat com ens sigui possible."
},
"confirm": {
"subject": "Ja pots publicar activitats",
@ -18,5 +18,12 @@
"admin_register": {
"subject": "Registre nou",
"content": "{{user.email}} ha soŀlicitat regsitrar-se a {{config.title}}: <br/><pre>{{user.description}}</pre><br/> Respon a la soŀlicitud <a href='{{config.baseurl}}/admin'>aquí</a>."
},
"event_confirm": {
"content": "Pots acceptar aquesta activitat a <a href='{{url}}'>la pàgina de confirmació</a>"
},
"test": {
"subject": "La configuració SMTP funciona",
"content": "Aquest és un correu de prova, si llegeixes això és que la configuració funciona."
}
}

View file

@ -18,5 +18,12 @@
"admin_register": {
"subject": "New registration",
"content": "{{user.email}} has requested registration on {{config.title}}: <br/><pre>{{user.description}}</pre><br/> Confirm it <a href='{{config.baseurl}}/admin'>here</a>."
},
"event_confirm": {
"content": "You can confirm this event at <a href='{{url}}'>this page</a>"
},
"test": {
"subject": "Your SMTP configuration is working",
"content": "This is a test email, if you are reading this your configuration is working."
}
}

View file

@ -1,7 +1,7 @@
{
"register": {
"subject": "Solicitud de registro recibida",
"content": "Recibimos la solicitud de registro. Lo confirmaremos tan pronto como podamos.\n Adios"
"content": "Recibimos la solicitud de registro. Lo confirmaremos tan pronto como podamos."
},
"confirm": {
"subject": "Puedes empezar a publicar eventos",
@ -21,5 +21,8 @@
"admin_register": {
"subject": "Nuevo registro",
"content": "{{user.email}} ha pedido registrarse en {{config.title}}: <br/><pre>{{user.description}}</pre><br/> Confírmalo <a href='{{config.baseurl}}/admin'>aquí</a>."
},
"event_confirm": {
"content": "Puede confirmar este evento <a href='{{url}}'>aquí</a>"
}
}

View file

@ -18,5 +18,12 @@
"register": {
"content": "Nous avons reçu la demande d'inscription. Nous la confirmerons au plus vite.",
"subject": "Demande d'inscription reçue"
},
"event_confirm": {
"content": "Vous pouvez confirmer cet événement sur <a href='{{url}}'>cette page</a>"
},
"test": {
"subject": "Votre configuration SMTP est fonctionnelle",
"content": "Ceci est un e-mail de test, si vous pouvez lire ceci c'est que votre configuration est fonctionnelle."
}
}

View file

@ -18,5 +18,8 @@
"admin_register": {
"subject": "Nuova registrazione",
"content": "{{user.email}} si è registratǝ a {{config.title}} scrivendo:<br/><pre>{{user.description}}</pre><br/> Puoi confermarlo <a href='{{config.baseurl}}/admin'>qui</a>."
},
"event_confirm": {
"content": "Puoi confermare questo evento premendo il tasto conferma in <a href='{{url}}'>questa pagina</a>"
}
}

View file

@ -45,8 +45,8 @@
"new_user": "New user",
"ok": "Ok",
"cancel": "Cancel",
"enable": "Turn on",
"disable": "Turn off",
"enable": "Enable",
"disable": "Disable",
"me": "You",
"password_updated": "Password changed.",
"resources": "Resources",
@ -123,6 +123,7 @@
"tag_description": "Tag",
"media_description": "You can add a flyer (optional)",
"added": "Event added",
"saved": "Event saved",
"added_anon": "Event added, but has yet to be confirmed.",
"updated": "Event updated",
"where_description": "Where's the event? If not present you can create it.",
@ -165,7 +166,8 @@
"event_confirm_description": "You can confirm events entered by anonymous users here",
"delete_user": "Remove",
"remove_admin": "Remove admin",
"delete_user_confirm": "Are you sure you want to remove this user?",
"disable_user_confirm": "Are you sure you want to disable {user}?",
"delete_user_confirm": "Are you sure you want to remove {user}?",
"user_remove_ok": "User removed",
"user_create_ok": "User created",
"allow_registration_description": "Allow open registrations?",
@ -195,7 +197,8 @@
"resources": "Resources",
"user_blocked": "User {user} blocked",
"favicon": "Logo",
"user_block_confirm": "Are you sure you want block this user?",
"user_block_confirm": "Are you sure you want to block user {user}?",
"instance_block_confirm": "Are you sure you want block instance {instance}?",
"delete_announcement_confirm": "Are you sure you want to remove the announcement?",
"announcement_remove_ok": "Announce removed",
"announcement_description": "In this section you can insert announcements to remain on the homepage",
@ -216,7 +219,14 @@
"footer_links": "Footer links",
"delete_footer_link_confirm": "Sure to remove this link?",
"edit_place": "Edit place",
"new_announcement": "New announcement"
"new_announcement": "New announcement",
"show_smtp_setup": "Email settings",
"smtp_hostname": "SMTP Hostname",
"smtp_description": "<ul><li>Admin should receive an email when anon event is added (if enabled).</li><li>Admin should receive email of registration request (if enabled).</li><li>User should receive an email of registration request.</li><li>User should receive email of confirmed registration.</li><li>User should receive a confirmation email when subscribed directly by admin.</li><li>Users should receive email to restore password when they forgot it</li></ul>",
"smtp_test_success": "A test email is sent to {admin_email}, please check your inbox",
"smtp_test_button": "Send a test email",
"admin_email": "Admin e-mail"
},
"auth": {
"not_confirmed": "Not confirmed yet…",
@ -258,5 +268,10 @@
"scopes": {
"event:write": "Add and edit your events"
}
},
"setup": {
"completed": "Setup completed",
"completed_description": "<p>You can now login with the following user:<br/><br/>User: <b>{email}</b><br/>Password: <b>{password}<b/></p>",
"start": "Start"
}
}

View file

@ -46,7 +46,7 @@
"ok": "Ok",
"cancel": "Cancelar",
"enable": "Habilitar",
"disable": "Deshabilita",
"disable": "Deshabilitar",
"me": "Tú",
"password_updated": "Contraseña actualizada.",
"comments": "ningún comentario|un comentario|{n} comentarios",
@ -85,7 +85,9 @@
"tags": "Tags",
"import": "Importar",
"reset": "Reset",
"theme": "Tema"
"theme": "Tema",
"label": "Etiqueta",
"max_events": "Número de eventos máximo"
},
"login": {
"description": "Entrando podrás publicar nuevos eventos.",
@ -103,7 +105,7 @@
"intro": "A diferencia de las plataformas del capitalismo, que hacen todo lo posible para mantener datos y usuarios dentro de ellas, creemos las informaciones, así como las personas, deben ser libres. Para ello, puedes mantenerte enterado sobre los eventos que te interesan como mejor te parezca, sin necesariamente tener que pasar por este sitio.",
"email_description": "Puedes recibir por mail los eventos que te interesan.",
"insert_your_address": "Casilla de correo",
"feed_description": "Para seguir las actualizaciones desde un ordenador o teléfono inteligente sin la necesidad de abrir periódicamente el sitio, el método recomendado es usar los feeds RSS.</p>\n\n <p>Con rss feeds, utilizás una aplicación especial para recibir actualizaciones de los sitios que más te interesan, como por ejemplo este. Es una buena manera de seguir muchos sitios muy rápidamente, sin la necesidad de crear una cuenta u otras complicaciones.</p>\n \n <li>Si tienes Android, te sugerimos <a href=\"https://f-droid.org/es/packages/com.nononsenseapps.feeder/\">Feeder</a> o Feeder</li>\n <li>Para iPhone/iPad puedes usar <a href=\"https://itunes.apple.com/ua/app/feeds4u/id1038456442?mt=8\">Feed4U</a></li>\n <li>En el caso de un ordenador aconsejamos Feedbro, se instala como plugin <a href=\"https://addons.mozilla.org/es-ES/firefox/addon/feedbroreader/\">de Firefox </a>o <a href=\"https://chrome.google.com/webstore/detail/feedbro/mefgmmbdailogpfhfblcnnjfmnpnmdfa\">de Chrome</a> y funciona con todos los principales sistemas.</li>\n <br/>\n Agregando este link a tu lector de feed, estarás siempre actualizado/a.",
"feed_description": "Para seguir las actualizaciones desde un ordenador o teléfono inteligente sin la necesidad de abrir periódicamente el sitio, el método recomendado es usar los feeds RSS.</p>\n\n <p>Con rss feeds, utilizas una aplicación especial para recibir actualizaciones de los sitios que más te interesan, como por ejemplo éste. Es una buena manera de seguir muchos sitios muy rápidamente, sin la necesidad de crear una cuenta u otras complicaciones.</p>\n \n <li>Si tienes Android, te sugerimos <a href=\"https://f-droid.org/es/packages/com.nononsenseapps.feeder/\">Feeder</a> o Feeder</li>\n <li>Para iPhone/iPad puedes usar <a href=\"https://itunes.apple.com/ua/app/feeds4u/id1038456442?mt=8\">Feed4U</a></li>\n <li>En el caso de un ordenador aconsejamos Feedbro, se instala como plugin <a href=\"https://addons.mozilla.org/es-ES/firefox/addon/feedbroreader/\">de Firefox </a>o <a href=\"https://chrome.google.com/webstore/detail/feedbro/mefgmmbdailogpfhfblcnnjfmnpnmdfa\">de Chrome</a> y funciona con todos los principales sistemas.</li>\n <br/>\n Agregando este link a tu lector de feed, estarás siempre actualizado/a.",
"ical_description": "Las computadoras y los teléfonos inteligentes suelen estar equipados con una aplicación para administrar un calendario. Estos programas generalmente se pueden usar para importar un calendario remoto.",
"list_description": "Si tienes un sitio web y quieres mostrar una lista de eventos, puedes usar el siguiente código"
},
@ -120,7 +122,7 @@
"what_description": "Nombre evento",
"description_description": "Descripción, puedes copiar y pegar",
"tag_description": "Tag...",
"media_description": "Puedes agregar un panfleto (opcionál)",
"media_description": "Puedes agregar una imagen (opcional)",
"added": "Evento agregado",
"added_anon": "Evento agregado, será confirmado cuanto antes.",
"where_description": "¿Dónde es? Si el lugar no está, escribilo.",
@ -128,8 +130,8 @@
"not_found": "Evento no encontrado",
"remove_confirmation": "¿Estás seguro/a de querér eliminar este evento?",
"recurrent": "Recurrente",
"recurrent_description": "Elegí la frecuencia y selecciona los días.",
"multidate_description": "¿Un festival o más de un día? Elegí cuándo comienza y cuándo termina.",
"recurrent_description": "Elegí la frecuencia y selecciona los días",
"multidate_description": "¿Un festival o más de un día? Elegí cuándo comienza y cuándo termina",
"multidate": "Más días",
"normal": "Normal",
"normal_description": "Selecciona el día.",
@ -154,14 +156,18 @@
"ics": "ICS",
"import_ICS": "Importar desde ICS",
"import_URL": "Importar desde la URL",
"only_future": "solo eventos venideros"
"only_future": "solo eventos venideros",
"import_description": "Puedes importar eventos de otras plataformas y otras instancias mediante formatos estandars (ics y h-event)",
"edit_recurrent": "Editar evento recurrente:",
"updated": "Evento actualizado",
"saved": "Evento guardado"
},
"admin": {
"place_description": "En el caso de que un lugar sea incorrecto o cambie de dirección, puedes cambiarlo. <br/> En este caso hay que tener en cuenta que todos los eventos asociados con ese lugar cambiarán de dirección (¡incluso los pasados!)",
"place_description": "En el caso de que un lugar sea incorrecto o cambie de dirección, puedes cambiarlo. <br/> Todos los eventos presentes y pasados asociados con este lugar cambiarán de dirección.",
"event_confirm_description": "Puedes confirmar aquí los eventos agregados por usuarios anónimos",
"delete_user": "Elimina",
"remove_admin": "Borra admin",
"delete_user_confirm": "¿Estás seguro/a de borrar este usuario?",
"delete_user_confirm": "¿Estás seguro/a de borrar a {user}?",
"user_remove_ok": "Usuario eliminado",
"user_create_ok": "Usuario creado",
"allow_registration_description": "¿Querés habilitar el registro?",
@ -170,7 +176,7 @@
"allow_recurrent_event": "Habilitar eventos fijos",
"recurrent_event_visible": "Eventos fijos visibles por defecto",
"federation": "Federación / ActivityPub",
"enable_federation": "Habilitar la federación!",
"enable_federation": "Habilitar la federación",
"enable_federation_help": "Será posible seguir esta instancia desde el fediverso",
"select_instance_timezone": "Uso horario",
"enable_resources": "Habilitar recursos",
@ -179,7 +185,7 @@
"hide_boost_bookmark_help": "Oculta los pequeños iconos que muestran el número de impulsos y marcadores que vienen del fediverso",
"block": "Bloquear",
"unblock": "Desbloquear",
"user_add_help": "Enviaremos un correo electrónico al nuevo usuario con instrucciones para confirmar la suscripción y elegir una contraseña.",
"user_add_help": "Enviaremos un correo electrónico al nuevo usuario con instrucciones para confirmar la suscripción y elegir una contraseña",
"instance_name": "Nombre de la instancia",
"show_resource": "Mostrar recurso",
"hide_resource": "Ocultar recurso",
@ -191,7 +197,7 @@
"resources": "Recursos",
"user_blocked": "El usuario {usuario} ya no podrá añadir recursos",
"favicon": "Logo",
"user_block_confirm": "¿Estás seguro de que quieres bloquear al usuario?",
"user_block_confirm": "¿Estás seguro de que quieres bloquear a {user}?",
"delete_announcement_confirm": "¿Estás seguro de que quieres borrar el anuncio?",
"announcement_remove_ok": "Anuncio borrado",
"announcement_description": "En esta sección se pueden insertar anuncios que permanecerán en la página de inicio",
@ -212,7 +218,10 @@
"delete_footer_link_confirm": "Seguro que quieres quitar este enlace?",
"footer_links": "Enlaces a pie de página",
"add_link": "Añadir enlace",
"is_dark": "Tema oscuro"
"is_dark": "Tema oscuro",
"instance_block_confirm": "¿Estás seguro/a que quieres bloquear la instancia {instance}?",
"add_instance": "Añadir instancia",
"disable_user_confirm": "Estas seguro de que quieres deshabilitar a {user}?"
},
"auth": {
"not_confirmed": "Todavía no hemos confirmado este email…",
@ -227,7 +236,7 @@
"update_confirm": "¿Estás seguro de que quieres guardar los cambios?"
},
"error": {
"nick_taken": "Este nickname ya está registrado",
"nick_taken": "Este apodo ya está registrado.",
"email_taken": "Este correo electrónico ya está registrado."
},
"ordinal": {
@ -238,11 +247,11 @@
"5": "quinto",
"-1": "último"
},
"about": "\n <p>\n Gancio es un proyecto del <a href='https://autistici.org/underscore'>underscore hacklab</a> y es uno de los\n servicios de <a href='https://cisti.org'>cisti.org</a>.</p>\n\n <h5>¿Que es gancio?</h5>\n <p>Gancio (se pronuncia \"gancho\") es una herramienta para compartir eventos orientado a las comunidades radicales.\n Dentro del gancio pueden encontrar y agregar eventos.\n Gancio, como todo <a href='https://cisti.org'> cisti.org </a> es una herramienta\n antisexista, antirracista, antifascista y anticapitalista, así que piensen en eso cuando\n van a publicar un evento. </p>\n\n <h5>Ok, pero ¿que quiere decir gancio?</h5>\n <p>\n Literalmente sería \"enganche\", pero en realidad viene de una forma de decir que se usa en en Turín (Italia). Ahí si alguien dice: \"ehi, ci diamo un gancio alle 8?\" (\"ehi, ¿nos damos un enganche a las 8?\") quiere decir \"ehí, ¿nos vemos a las 8?\". \"Darsi un gancio\" es juntarse a una hora X en un lugar Y.</p>\n <code>\n <ul>\n <li> ¿A qué hora es el <i>gancio</i> para ir a la marcha?</li>\n <li> No sé, de todos modos no puedo ir, ya tengo un <i>gancio</i> para ir a una reunión.</li>\n </ul>\n </code>\n\n <h5> Contactos</h5>\n <p>\n ¿Escribiste una nueva interfaz para gancio? ¿Quieres abrir un gancio en tu ciudad?\n ¿Hay algo que te gustaría mejorar? Para contribuir el código fuente es libre y disponible \n <a href='https://git.lattuga.net/cisti/gancio'>aquí</a>. Ayuda y sugerencias son siempre bienvenidos, puedes comunicarte con nosotros \n enviando un mail a underscore arroba autistici.org</p>",
"about": "\n <p><a href='https://gancio.org'>Gancio</a> es una agenda compartida para comunidades locales.</p>\n ",
"confirm": {
"title": "Confirmación de usuario",
"not_valid": "Mmmmm algo salió mal.",
"valid": "Su cuenta ha sido confirmada, ahora puede <a href=\"/login\">ingresar</a>."
"valid": "Su cuenta ha sido confirmada, ahora puede <a href=\"/login\">ingresar</a>"
},
"oauth": {
"authorization_request": "La aplicación externa <code>{app}</code> requiere permiso para realizar las siguientes tareas en <code>{instance_name}</code>:",

View file

@ -48,7 +48,7 @@
"enable": "Gaitu",
"disable": "Desgaitu",
"me": "Zu",
"password_updated": "Pasahitza eguneratuta!",
"password_updated": "Pasahitza eguneratuta.",
"activate_user": "Egiaztatuta",
"displayname": "Erakutsitako izena",
"federation": "Federazioa",
@ -80,37 +80,43 @@
"delete": "Ezabatu",
"announcements": "Iragarkiak",
"url": "URL esteka",
"place": "Lekua"
"place": "Lekua",
"label": "Etiketa",
"max_events": "Max zenbakidun gertaerak",
"import": "Inportatu",
"reset": "Zeroan jarri",
"theme": "Gai",
"tags": "Tags"
},
"login": {
"description": "Saioa hasiz gero, ekitaldi berriak sortu ahal izango dituzu",
"check_email": "Begiratu zure postontzi elektronikoan, baita mezu baztergarrietan",
"description": "Saioa hasiz gero, ekitaldi berriak sortu ahal izango dituzu.",
"check_email": "Begiratu zure postontzi elektronikoan, baita mezu baztergarrietan.",
"not_registered": "Ez duzu izena eman?",
"forgot_password": "Pasahitza ahaztu duzu?",
"error": "Ezin da saioa hasi, egiaztatu zure datuok.",
"insert_email": "Sartu zure helbide elektronikoa",
"ok": "Saioa hasi duzu!"
"ok": "Saioa hasi duzu"
},
"recover": {
"not_valid_code": "Mmmmm zerbaitek huts egin du..."
},
"export": {
"intro": "Kapitalismoaren plataformek edozer egingo dute erabiltzaileak eta haien datuak gordetzeko. Guk aldiz, informazioak, pertsonen antzera askeak izan behar dutela sinesten dugu. Horretarako gogoko dituzun ekitaldietaz info eguneratuak jaso ditzakezu webgune honetatik pasatzeko beharrik gabe.",
"email_description": "Interesatzen zaizkizun ekitaldiak jaso ditzakezu posta elektronikoan",
"intro": "Kapitalismoaren plataformek edozer egingo dute erabiltzaileak eta haien datuak gordetzeko. Guk aldiz, informazioak, pertsonen antzera askeak izan behar dutela sinesten dugu. Horretarako gogoko dituzun ekitaldietaz info eguneratuak jaso ditzakezu webgune honetatik pasatzeko beharrik gabe.",
"email_description": "Interesatzen zaizkizun ekitaldiak jaso ditzakezu posta elektronikoan.",
"insert_your_address": "Sartu zure helbide elektronikoa",
"feed_description": "Eguneraketak sakelekoan edo ordenagailuan jaso nahi badituzu webgune hau bisitatu gabe, RSS jarioa erabiltzea gomendatzen dizugu.</p>\n<p>RSS jarioarentzat aplikazio berezi bat erabiliko duzu gogoko dituzun weguneetatik berriak jasotzeko. Oso modu egokia da gune askotako berriak erraz eta azkar jasotzeko eta ez da konturik sortu behar! </p>\n\n<li>Android baldin badaukazu <a href=\"https://play.google.com/store/apps/details?id=net.frju.flym\">Flym</a> edo Feeder gomendatzen dizugu</li>\n<li>iPhone/iPad-erako eskuragarri daukazu <a href=\"https://itunes.apple.com/ua/app/feeds4u/id1038456442?mt=8\">Feed4U</a></li>\n<li>Ordenagailuaren kasuan Feedbro iradokitzen dugu, <a href=\"https://addons.mozilla.org/en-GB/firefox/addon/feedbroreader/\">Firefoxeko</a> edo <a href=\"https://chrome.google.com/webstore/detail/feedbro/mefgmmbdailogpfhfblcnnjfmnpnmdfa\">Chromeko</a> gehigarri gisa instalatzen da eta sistema gehienetan dabil.</li>\n<br/>\nHonako esteka jario irakurgailuan sartuta, eguneraketa guztiak jasoko dituzu.",
"ical_description": "Normalean ordenagailuak eta smartphoneak egutegiak inportatu eta kudeatzeko aplikazioekin etorri ohi dira",
"list_description": "Webgune bat baduzu eta ekitaldien zerrenda erakutsi nahi baduzu, ondorengo kodea erabili dezakezu"
"feed_description": "Eguneraketak sakelekoan edo ordenagailuan jaso nahi badituzu webgune hau bisitatu gabe, RSS jarioa erabiltzea gomendatzen dizugu.</p>\n\n<p>RSS jarioarentzat aplikazio berezi bat erabiliko duzu gogoko dituzun weguneetatik berriak jasotzeko. Oso modu egokia da gune askotako berriak erraz eta azkar jasotzeko eta ez da konturik sortu behar! </p>\n\n<li>Android baldin badaukazu <a href=\"https://play.google.com/store/apps/details?id=net.frju.flym\">Flym</a> edo Feeder gomendatzen dizugu</li>\n<li>iPhone/iPad-erako eskuragarri daukazu <a href=\"https://itunes.apple.com/ua/app/feeds4u/id1038456442?mt=8\">Feed4U</a></li>\n<li>Ordenagailuaren kasuan Feedbro iradokitzen dugu, <a href=\"https://addons.mozilla.org/en-GB/firefox/addon/feedbroreader/\">Firefoxeko</a> edo <a href=\"https://chrome.google.com/webstore/detail/feedbro/mefgmmbdailogpfhfblcnnjfmnpnmdfa\">Chromeko</a> gehigarri gisa instalatzen da eta sistema gehienetan dabil.</li>\n<br/>\nHonako esteka jario irakurgailuan sartuta, eguneraketa guztiak jasoko dituzu.",
"ical_description": "Normalean ordenagailuak eta smartphoneak egutegiak inportatu eta kudeatzeko aplikazioekin etorri ohi dira.",
"list_description": "Webgune bat baduzu eta ekitaldien zerrenda erakutsi nahi baduzu, ondorengo kodea erabili dezakezu"
},
"register": {
"description": "Herri mugimenduek autoantolaketaren bidean diru-iturrien beharrak dauzkatela badakigu.<br/>Honako hauxe oparitxoa da, hortaz erabili ezazue ekitaldi ez-komertzialak iragartzeko, eta esan gabe doa, ekitaldi antifaxistak, antisexistak eta antiarriztetarako :) . \n<br/>Argitaratzen hasi baino lehen<strong> zure kontu berriak onarpena jaso beharko du </strong>beraz, <strong>webgune honen atzean hezur-haragizko pertsonak gaudela jakinda </strong>, (momenutz euskal 'AI'-rik ez daukagu baina adi, agertuko direla) idatzi iezaguzu lerro batzuk argitaratu nahi dituzun ekitaldiei buruz",
"description": "Herri mugimenduek autoantolaketaren bidean diru-iturrien beharrak dauzkatela badakigu.<br/>Honako hauxe oparitxoa da, hortaz erabili ezazue ekitaldi ez-komertzialak iragartzeko, eta esan gabe doa, ekitaldi antifaxistak, antisexistak eta antiarriztetarako :) .\n<br/>Argitaratzen hasi baino lehen<strong> zure kontu berriak onarpena jaso beharko du </strong>beraz, <strong>webgune honen atzean hezur-haragizko pertsonak gaudela jakinda </strong>, (momenutz euskal 'AI'-rik ez daukagu baina adi, agertuko direla) idatzi iezaguzu lerro batzuk argitaratu nahi dituzun ekitaldiei buruz.",
"error": "Hutsa: ",
"complete": "Izen-ematea baieztatu behar dute.",
"first_user": "Administratzailea sortu da"
},
"event": {
"anon": "Ezezaguna",
"anon_description": "Ekitaldia sortu dezakezu <a href='/login'>saioa hasi</a> edo <a href='/register'>izena eman</a> gabe,\nbaina kasu honetan norbaitek egiaztatu beharko du ekitaldia gune honetarako egokia dela eta itxaron beharko duzu. Gainera, behin egiaztatuta hura aldatzea ez da posiblea izango.<br/><br/>\n Dena den, ahalik eta azkarren erantzuten saiatuko gara.",
"anon_description": "Ekitaldia sortu dezakezu <a href='/login'>saioa hasi</a> edo <a href='/register'>izena eman</a> gabe,\nbaina kasu honetan norbaitek egiaztatu beharko du ekitaldia gune honetarako egokia dela eta itxaron beharko duzu. Gainera, behin egiaztatuta hura aldatzea ez da posiblea izango.<br/><br/>\nDena den, ahalik eta azkarren erantzuten saiatuko gara. ",
"same_day": "egun berean",
"what_description": "Ekitaldiaren izena",
"description_description": "Ekitaldiaren azalpena",
@ -118,16 +124,16 @@
"media_description": "Eskuorria edo irudia gehitu dezakezu (aukerakoa)",
"added": "Ekitaldia sortu da",
"added_anon": "Ekitaldia sortu da, baina baieztatzear dago.",
"where_description": "Non da ekitaldia? Lekua ez bada zerrendan agertzen idatzi ezazu eta <b>enter sakatu</b>. ",
"where_description": "Non da ekitaldia? Lekua ez bada zerrendan agertzen idatzi ezazu eta <b>enter sakatu</b>.",
"confirmed": "Ekitaldia egiaztatu da",
"not_found": "Ezin da ekitaldia aurkitu",
"remove_confirmation": "Ziur zaude ekitaldi hau ezabatu nahi duzula?",
"remove_recurrent_confirmation": "Ziur zaude ekitaldi errepikari hau ezabatu nahi duzula??\n\nIragan diren ekitaldiak mantenduko dira, baina ez da ekitaldi berririk sortuko.",
"remove_recurrent_confirmation": "Ziur zaude ekitaldi errepikari hau ezabatu nahi duzula?\nIragan diren ekitaldiak mantenduko dira, baina ez da ekitaldi berririk sortuko.",
"recurrent": "Errepikaria",
"show_recurrent": "Ekitaldi errepikariak",
"show_past": "Erakutsi iraganeko ekitaldiak",
"recurrent_description": "Aukera ezazu maiztasuna eta hautatu egunak",
"multidate_description": "Egun bat baino gehiagoko jaialdia da? Aukeratu noiz hasten den eta noiz amaitzen den.",
"multidate_description": "Egun bat baino gehiagoko jaialdia da? Aukeratu noiz hasten den eta noiz amaitzen den",
"multidate": "Egun gehiagotan",
"normal": "Egunekoa",
"normal_description": "Eguna aukeratu.",
@ -143,27 +149,36 @@
"due": "Amaiera ordua",
"from": "Hasiera ordua",
"image_too_big": "Irudia handiegia omen da (4mb gehienez)",
"interact_with_me": "Elkar gaitezen fedibertsoan: ",
"follow_me_description": " {title}n argitaratutako ekitaldien berri izateko aukeren artean,\n fedibertsoko <u>{account}</u> kontuari jarraitzea daukazu. Horretarako Mastodon erabili dezakezu, eta bertatik baliabideak gehitu ekitaldi baten.<br/><br/>\n Mastodon eta Fedibertsoa zer diren ez badakizu <a href='https://es.wikipedia.org/wiki/Fediverso'>artikulu hau</a> irakurtzea iradokitzen dizugu.<br/><br/> Sartu zure instantzia behean (adibidez mastodon.eus edo mastodon.jalgi.eus)"
"interact_with_me": "Elkar gaitezen fedibertsoan",
"follow_me_description": "{title}n argitaratutako ekitaldien berri izateko aukeren artean,\n fedibertsoko <u>{account}</u> kontuari jarraitzea daukazu. Horretarako Mastodon erabili dezakezu, eta bertatik baliabideak gehitu ekitaldi baten.<br/><br/>\n Mastodon eta Fedibertsoa zer diren ez badakizu <a href='https://es.wikipedia.org/wiki/Fediverso'>artikulu hau</a> irakurtzea iradokitzen dizugu.<br/><br/> Sartu zure instantzia behean (adibidez mastodon.eus edo mastodon.jalgi.eus)",
"import_description": "Beste plataforma eta adibide batzuetako gertaerak formatu estandarren bidez inportatu ditzakezu (ics eta h-event)",
"ics": "ICS",
"import_ICS": "ICS-ko inportazioa",
"import_URL": "URL-ko inportazioa",
"interact_with_me_at": "Hitz egin nirekin fediversoan",
"only_future": "gertakizunak besterik ez",
"edit_recurrent": "Gertaera errepikakorra:",
"updated": "Gertaera eguneratua",
"saved": "Gertaera salbatua"
},
"admin": {
"place_description": "Lekuaren zehaztapenak aldatu ditzakezu, bai gaizki idatzita dagoelako, bai helbidez aldatu delako.<br/> Ondorioz, leku horrekin lotutako ekitaldi guztiak helbidez aldatuko direla kontuan hartu behar da (baita iraganekoak ere!)",
"event_confirm_description": "Erabiltzaile ezezagunek sortutako ekitaldiak hemen egiaztatu ditzakezu",
"delete_user": "Erabiltzailea ezabatu",
"remove_admin": "Administratzailea ezabatu",
"delete_user_confirm": "Ziur zaude erabiltzailea ezabatu nahi duzula?",
"delete_user_confirm": "Ziur zaude {user} ezabatu nahi duzula?",
"user_remove_ok": "Erabiltzailea ezabatu da",
"user_create_ok": "Erabiltzailea sortu da",
"allow_registration_description": "Izen-emateak ahalbidetu nahi dituzu?",
"allow_anon_event": "Ezezagunek ekitaldiak sortzea ahalbidetu nahi duzu? (Beti ere baieztapenarekin) ",
"allow_recurrent_event": "Ekitaldi errepikariak ahalbidetu?",
"allow_anon_event": "Ezezagunek ekitaldiak sortzea ahalbidetu nahi duzu? (Beti ere baieztapenarekin)",
"allow_recurrent_event": "Ekitaldi errepikariak ahalbidetu",
"recurrent_event_visible": "Erakutsi ekitaldi errepikariak modu lehenetsian",
"federation": "Federazioa / ActivityPub",
"enable_federation": "Federatzea gaitu",
"enable_federation_help": "Instantzia hau fedibertsoan jarraitzea gaituko duzu?",
"enable_federation_help": "Instantzia hau fedibertsoan jarraitzea gaituko duzu",
"select_instance_timezone": "Ordu-eremua",
"instance_timezone_description": "Gancio hiri baten moduko lekuen ekitaldiak biltzeko diseinatuta dago. Leku honen ordu-eremua hautatuz gero ekitaldi gutziek ordu-eremu horrekiko adieraziko dira..",
"enable_resources": "Baliabideak gaitu ",
"enable_resources": "Baliabideak gaitu",
"enable_resources_help": "Fedibertsotik ekitaldietan baliabideak gehitzea ahalbidetzen du",
"hide_boost_bookmark": "Bultzadak eta laster-markak ezkutatu",
"hide_boost_bookmark_help": "Fedibertsotik datozen bultzaden eta laster-marken ikonotxoak ezkutatzen ditu",
@ -181,37 +196,46 @@
"filter_users": "Erabiltzaileak iragazi",
"instance_name": "Instantziaren izena",
"favicon": "Iruditxoa",
"user_block_confirm": "Ziur zaude erabiltzailea blokeatu nahi duzula?",
"user_block_confirm": "Ziur zaude {user} blokeatu nahi duzula?",
"delete_announcement_confirm": "Ziur zaude iragarkia ezabatu nahi duzula?",
"announcement_remove_ok": "Iragarkia ezabatu da",
"announcement_description": "Atal honetan iragarkiak txertatu ditzakezu hasiera-orrian ager daitezen",
"instance_locale": "Instantziaren hizkuntza lehenetsia",
"instance_locale_description": "Orriak erakusteko erabilitako hizkuntza erabiltzaileak nahiago duen hizkuntza da. Hala ere, kasu batzuetan mezuak modu berean erakutsi behar ditugu guztiontzat (adibidez ActivityPub-etik argitaratzen dugunean edo posta elektroniko batzuk bidaltzerakoan). Kasu hauetan goian hautatutako hizkuntza erabiliko dugu.",
"instance_place": "Instantziaren kokalekua ",
"title_description": "Orriaren izenburuan, jario eta ics-en esportazioan eta mezu elektronikoen gaian erabiliko da ",
"instance_place": "Instantziaren kokalekua",
"title_description": "Orriaren izenburuan, jario eta ics-en esportazioan eta mezu elektronikoen gaian erabiliko da.",
"description_description": "Orriburuan agertuko da, izenburuarekin batera",
"instance_name_help": "Instantziaren kontua ActivityPub-en ",
"instance_name_help": "Instantziaren kontua ActivityPub-en",
"enable_trusted_instances": "Kideko instantziak gaitu",
"trusted_instances_help": "Kideko instantzien zerrenda orri-buruan agertuko dira",
"add_trusted_instance": "Gehitu kideko instantzia bat",
"instance_place_help": "Beste instantzien zerrendetan agertuko den izena ",
"delete_trusted_instance_confirm": "Ziur zaude kideko instantzia hau zerrendatik ezabatu nahi duzula?"
"instance_place_help": "Beste instantzien zerrendetan agertuko den izena",
"delete_trusted_instance_confirm": "Ziur zaude kideko instantzia hau zerrendatik ezabatu nahi duzula?",
"new_announcement": "Iragarpen berria",
"edit_place": "Leku ederrean",
"delete_footer_link_confirm": "Ziur lotura kenduko duzula?",
"footer_links": "Oinezkoen konexioak",
"add_link": "Gehitu lotura",
"is_dark": "Gai iluna",
"instance_block_confirm": "Ziur al zaude blokearen adibidea {instance} nahi duzula?",
"add_instance": "Gehitu adibidea",
"disable_user_confirm": "Ziur zaude {user} deskonektatu nahi duzula?"
},
"auth": {
"not_confirmed": "Oraindik baieztatu gabe dago...",
"not_confirmed": "Oraindik baieztatu gabe dago",
"fail": "Saioa hasteak huts egin du! Ziur zaude datuok ondo daudela?"
},
"settings": {
"update_confirm": "Aldaketak gorde nahi duzu?",
"change_password": "Pasahitza aldatu",
"password_updated": "Pasahitza eguneratu da",
"password_updated": "Pasahitza eguneratu da.",
"danger_section": "Atal arriskutsua",
"remove_account": "Ondorengo botoia zapalduz gero zure erabiltzailea ezabatuko da. Argitaratutako ekitaldiak ordea, ez dira ezabatuko",
"remove_account": "Ondorengo botoia zapalduz gero zure erabiltzailea ezabatuko da. Argitaratutako ekitaldiak ordea, ez dira ezabatuko.",
"remove_account_confirm": "Zure kontua behin betiko ezabatzear zaude"
},
"error": {
"nick_taken": "Dagoeneko ezizen hau hartuta dago",
"email_taken": "Dagoeneko posta elektroniko hau hartuta dago"
"nick_taken": "Dagoeneko ezizen hau hartuta dago.",
"email_taken": "Dagoeneko posta elektroniko hau hartuta dago."
},
"confirm": {
"title": "Erabiltzaile-baieztapena",
@ -226,12 +250,16 @@
"5": "bostgarrena",
"-1": "azkena"
},
"about": "<div><h1><strong><u>Descarga la agenda semanal en pdf lista para imprimir pinchando </u></strong><a href='https://lubakiagenda.net/agenda.pdf' rel='noopener noreferrer nofollow'><strong><u>aquí.</u></strong></a></h1><p></p><p><strong>¿Quiénes somos?</strong></p><p>Somos un grupo de personas que gestionamos la lubakiagenda digital y creamos y colgamos la agenda semanal en pdf y papel. No generamos contenido, solamente moderamos el contenido de la web y subimos las actividades de las que nos enteramos.</p><p><strong>¿Qué es LubakiAgenda?</strong></p><p>Es la agenda social alternativa de Bilboalde*. Tiene su versión digital, que cada colectivo o espacio autogestionado puede actualizar con su propia programación, y una versión imprimible que se cuelga cada miércoles en la web y en los lugares más frecuentados de Bilbo.</p><p><strong>¿Cuál es el objetivo de LubakiAgenda?</strong></p><p>Una parte de las actividades que se incluyen en la agenda son las organizadas por gaztetxes, ateneos, distribuidoras,… y por el amplio movimiento popular y juvenil de Bilbo y alrededores. Queremos que esta agenda sea el reflejo de lo que organiza este movimiento en su trabajo cotidiano, resaltando que no hacen falta ni instituciones ni subvenciones para mantener en marcha la cultura popular.</p><p>Nuestro objetivo es dar difusión al movimiento popular desde una perspectiva anticapitalista, antifascista, antirracista, feminista e inclusiva.</p><p>Por ello no se publicarán actividades que vayan en contra de nuestros principios ni por regla general, tampoco actividades comerciales o de agentes sociales que consideremos que ya tienen sus propios medios y fuerzas de difusión y organización (como instituciones, partidos políticos o sindicatos mayoritarios)</p><p><strong>¿Cómo puedo participar?</strong></p><p>Si formas parte de un colectivo social, puedes colgar directamente las actividades que realicéis en el apartado superior derecho de la web (+ Nuevo evento) y solicitar que generemos una usuaria para tu colectivo. De este modo vuestra programación quedará colgada automáticamente sin necesidad de moderación.</p><p>También nos puedes escribir a <a href='mailto:agenda@lubakiagenda.net' rel='noopener noreferrer nofollow'><u>agenda@lubakiagenda.net</u></a> y mandarnos la programación de tu colectivo o espacio y nosotras la subiremos.</p><p>Es muy importante que si quieres que tu programación aparezca en la versión imprimible, <strong>nos hagas llegar la información antes del miércoles al mediodía de cada semana</strong> (si son actividades periódicas, no hace falta que nos mandes mail todas las semanas)</p><p>Si no perteneces a ningún colectivo pero quieres colgar actividades, siempre puedes subirlas mediante el apartado superior derecho de la web (+ Nuevo evento), pero debes saber que el contenido subido será sujeto a moderación para evitar duplicados o actividades contrarias a nuestros principios.</p><h5>¿Zer da 'Gancio'?</h5><p>Gancio, <a href='https://autistici.org/underscore'>Underscore hacklabeko</a> proiektua da eta <a href='https://cisti.org'>cisti.org-eko</a> zerbitzuetariko bat.</p> <p>Gancio ( \"gantzio\" ahoskatzen da) ekitaldiak zabaltzeko tresna da eta komunitate erradikalei zuzenduta dago. Bertan ekitaldiak aurkitu eta sortu daitezke. Gainera, <a href='https://cisti.org'>Cisti.org</a> osoak bezala, Ganciok izaera antisexista, antiarrazista, antifaxista eta antikapitalista dauka, beraz, izan hori buruan ekitaldia argitaratzera zoazenean.</p><h5>Ados, baina ¿zer arraio esan nahi du 'gancio' hitzak?</h5><p>Literalki \"kakoa\" litzateke, baina egia esan Turinen (Italia) erabiltzen den esaeratik dator, hau da, norbaitek esaten badu: \n \"- ehi, ci diamo un gancio alle 8?\" (\"aizu, ¿8etan kakoa emango?\") -zera esan nahi du: \n \"-aizu, ¿8retan elkartuko gara?\". \n\"Darsi un gancio\" hitzordu bat lotzea da, X orduan eta Y lekuan.</p><p><ul><li>¿Zein ordutan da <i>gancio</i>-a manira joateko?</li><li>Ez dakit ta, dena den, ezin naiz joan <i>gancio</i>-a baitaukat bilera baterako.</li></ul></p>\n<h5>Kontaktuak</h5><p>Gancio-ko interfaze berria garatu duzula? Gancio-a abiatu nahi duzula zure hirian? Hobetzeko zerbait bururatu zaizu? Ba, jakin iturri-kodea askea dela eta <a href='https://git.lattuga.net/cisti/gancio'>hemen</a> dagoela eskuragarri. \nLaguntza eta iradokizunak beti direnez ongietorriak, gurekin kontaktuan jarri zaitezkete underscore@autistici.org-en. Ondo izan! </p><p>*Bilboaldea Bilbo, Ezkerraldea, Meatzaldea, Hego Uribe, Uribe Kosta eta Txorierri</p>",
"about": "\n <p><a href='https://gancio.org'>Gancio</a> Tokiko komunitateentzako agenda partekatua da.</p>\n ",
"oauth": {
"authorization_request": "<code>{app}</code> aplikazioak baimena eskatu du <code>{instance_name}</code>-n ondorengo lanak egiteko:",
"redirected_to": "Baieztapenaren ondoren <code>{url}</code> helbidera berbideratua izango zara.",
"redirected_to": "Baieztapenaren ondoren <code>{url}</code> helbidera berbideratua izango zara",
"scopes": {
"event:write": "Zure ekitaldiak sortu eta aldatu"
}
},
"validators": {
"email": "Sar ezazu posta elektroniko baliozko bat",
"required": "{fieldName} beharrezkoa da"
}
}

View file

@ -84,7 +84,9 @@
"address": "Adresse",
"where": "Où",
"send": "Envoyer",
"export": "Exporter"
"export": "Exporter",
"label": "Nom",
"max_events": "Nb. max d'événements"
},
"event": {
"follow_me_description": "Une des manières de rester informé sur les évènements publiés ici sur {title}\nest de suivre le compte <u>{account}</u> sur le fediverse, par exemple via Mastodon, et pourquoi pas d'ajouter des ressources à un évènement à partir de là.<br/><br/>\nSi vous n'avez jamais entendu parler de Mastodon and du fediverse, nous vous recommandons de lire <a href='https://www.savjee.be/videos/simply-explained/mastodon-and-fediverse-explained/'>cet article (en anglais)</a>.<br/><br/>Saisissez votre nom d'instance ci-dessous (par ex. mastodon.social)",
@ -129,7 +131,9 @@
"recurrent_2m_days": "|Le {days} un mois sur deux|Les {jours} un mois sur deux",
"recurrent_2w_days": "Un {days} sur deux",
"edit_recurrent": "Modifier lévènement récurrent :",
"updated": "Évènement mis à jour"
"updated": "Évènement mis à jour",
"import_description": "Vous pouvez importer des événements depuis d'autres plateformes ou d'autres instances à travers des formats standards (ics et h-event)",
"saved": "Événement enregistré"
},
"register": {
"description": "Les mouvements sociaux doivent s'organiser et s'autofinancer.<br/>\n<br/>Avant de pouvoir publier, <strong> le compte doit être approuvé</strong>, considérez que <strong> derrière ce site vous trouverez de vraies personnes, à qui vous pouvez écrire en deux lignes pour exprimer les évènements que vous souhaiteriez publier.",
@ -169,7 +173,7 @@
"announcement_description": "Dans cette section vous pouvez insérer des annonces qui resteront affichées sur la page d'accueil",
"announcement_remove_ok": "Annonce supprimée",
"delete_announcement_confirm": "Êtes-vous sûr·e de vouloir supprimer l'annonce ?",
"user_block_confirm": "Êtes-vous sûr·e de vouloir bloquer cet utilisateur ?",
"user_block_confirm": "Êtes-vous sûr·e de vouloir bloquer l'utilisateur {user} ?",
"favicon": "Logo",
"user_blocked": "Utilisateur {user} bloqué",
"resources": "Ressources",
@ -196,11 +200,20 @@
"allow_registration_description": "Autoriser l'ouverture des inscriptions ?",
"user_create_ok": "Utilisateur créé",
"user_remove_ok": "Utilisateur supprimé",
"delete_user_confirm": "Êtes-vous sûr·e de vouloir supprimer cet administrateur ?",
"delete_user_confirm": "Êtes-vous sûr·e de vouloir supprimer {user} ?",
"remove_admin": "Supprimer l'administrateur",
"delete_user": "Supprimer",
"event_confirm_description": "Vous pouvez confirmer les évènements ajoutés par des utilisateurs anonymes ici",
"place_description": "Si vous avez donné le mauvais lieu ou la mauvaise adresse, vous pouvez les modifier.<br/>Tous les évènements courants et passés associés à ce lieu seront mis à jour."
"place_description": "Si vous avez donné le mauvais lieu ou la mauvaise adresse, vous pouvez les modifier.<br/>Tous les évènements courants et passés associés à ce lieu seront mis à jour.",
"add_instance": "Ajouter une instance",
"instance_block_confirm": "Êtes-vous sûr·e de vouloir bloquer l'instance {instance} ?",
"disable_user_confirm": "Êtes-vous sûr·e de vouloir désactiver {user} ?",
"smtp_hostname": "Nom d'hôte SMTP",
"smtp_test_button": "Envoyer un e-mail de test",
"smtp_description": "<ul><li>L'administrateur reçoit un e-mail lorsqu'un événement anonyme est ajouté (si activé).</li><li>L'administrateur reçoit un e-mail pour chaque demande d'inscription (si activé).</li><li>L'utilisateur reçoit un e-mail suite à sa demande d'inscription.</li><li>L'utilisateur reçoit un e-mail lorsque son inscription est confirmée.</li><li>L'utilisateur reçoit un e-mail de confirmation s'il est inscrit directement par l'administrateur.</li><li>Les utilisateurs reçoivent un e-mail pour restaurer leur mot de passe s'ils l'oublient.</li></ul>",
"show_smtp_setup": "Paramètres d'e-mail",
"smtp_test_success": "Un e-mail de test a été envoyé à {admin_email}, veuillez vérifier votre boîte de réception",
"admin_email": "E-mail de l'administrateur"
},
"oauth": {
"scopes": {
@ -254,5 +267,11 @@
"not_registered": "Pas encore inscrit·e ?",
"check_email": "Vérifiez votre boîte de réception et les indésirables.",
"description": "En vous connectant vous pouvez publier de nouveaux évènements."
},
"setup": {
"check_db": "Vérifier la base de données",
"completed": "Configuration terminée",
"completed_description": "<p>Vous pouvez désormais vous connectez avec le compte utilisateur suivant :<br/><br/>Identifiant : <b>{email}</b><br/>Mot de passe : <b>{password}<b/></p>",
"start": "Commencer"
}
}

View file

@ -129,7 +129,7 @@
"where_description": "Dov'è il gancio? Se il posto non è presente potrai crearlo.",
"confirmed": "Evento confermato",
"not_found": "Evento non trovato",
"remove_confirmation": "Sei sicuro/a di voler eliminare questo evento?",
"remove_confirmation": "Vuoi eliminare questo evento?",
"remove_recurrent_confirmation": "Sei sicura di voler eliminare questo evento ricorrente?\nGli eventi passati verranno mantenuti ma non ne verranno creati altri.",
"recurrent": "Ricorrente",
"edit_recurrent": "Modifica evento ricorrente:",
@ -159,7 +159,10 @@
"import_ICS": "Importa da ICS",
"import_URL": "Importa da URL (ics o h-event)",
"ics": "ICS",
"import_description": "Puoi importare eventi da altre piattaforme e da altre istanze attraverso i formati standard (ics e h-event)"
"import_description": "Puoi importare eventi da altre piattaforme e da altre istanze attraverso i formati standard (ics e h-event)",
"alt_text_description": "Descrizione per utenti con disabilità visive",
"choose_focal_point": "Scegli il punto centrale cliccando",
"remove_media_confirmation": "Confermi l'eliminazione dell'immagine?"
},
"admin": {
"place_description": "Nel caso in cui un luogo sia errato o cambi indirizzo, puoi modificarlo.<br/>Considera che tutti gli eventi associati a questo luogo cambieranno indirizzo (anche quelli passati).",
@ -191,15 +194,16 @@
"hide_resource": "Nascondi risorsa",
"show_resource": "Mostra risorsa",
"delete_resource": "Elimina risorsa",
"delete_resource_confirm": "Sei sicuro/a di voler eliminare questa risorsa?",
"delete_resource_confirm": "Sei sicurǝ di voler eliminare questa risorsa?",
"block_user": "Blocca questo utente",
"user_blocked": "L'utente {user} non potrà più aggiungere risorse",
"filter_instances": "Filtra istanze",
"filter_users": "Filtra utenti",
"instance_name": "Nome istanza",
"favicon": "Logo",
"user_block_confirm": "Sei sicuro/a di voler bloccare l'utente?",
"delete_announcement_confirm": "Sei sicuro/a di voler eliminare l'annuncio?",
"user_block_confirm": "Confermi di voler bloccare l'utente {user}?",
"instance_block_confirm": "Confermi di voler bloccare l'istanza {instance}?",
"delete_announcement_confirm": "Vuoi eliminare questo l'annuncio?",
"announcement_remove_ok": "Annuncio rimosso",
"announcement_description": "In questa sezione puoi inserire annunci che rimarranno in homepage",
"instance_locale": "Lingua predefinita",
@ -216,9 +220,10 @@
"is_dark": "Tema scuro",
"add_link": "Aggiungi link",
"footer_links": "Collegamenti del piè di pagina",
"delete_footer_link_confirm": "Sei sicuro/a di eliminare questo collegamento?",
"delete_footer_link_confirm": "Vuoi eliminare questo collegamento?",
"edit_place": "Modifica luogo",
"new_announcement": "Nuovo annuncio"
"new_announcement": "Nuovo annuncio",
"show_smtp_setup": "Impostazioni email"
},
"auth": {
"not_confirmed": "Non ancora confermato…",
@ -260,5 +265,10 @@
"scopes": {
"event:write": "Pubblicare/modificare i tuoi eventi"
}
},
"setup": {
"completed": "Setup completato",
"completed_description": "<p>Puoi entrare con le seguenti credenziali:<br/><br/>Utente: <b>{email}</b><br/>Password: <b>{password}<b/></p>",
"start": "Inizia"
}
}

11
middleware/setup.js Normal file
View file

@ -0,0 +1,11 @@
export default function ({ req, redirect, route }) {
if (process.server) {
if (req.firstrun && route.path !== '/setup') {
return redirect('/setup')
}
if (!req.firstrun && route.path === '/setup') {
return redirect('/')
}
}
}

View file

@ -1,4 +1,4 @@
const conf = require('config')
const config = require('./server/config.js')
module.exports = {
telemetry: false,
@ -11,22 +11,30 @@ module.exports = {
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
script: [{ src: '/gancio-events.es.js' }],
link: [{ rel: 'icon', type: 'image/png', href: '/logo.png' }]
},
dev: (process.env.NODE_ENV !== 'production'),
server: config.server,
server: conf.server,
vue: {
config: {
ignoredElements: ['gancio-events']
}
},
/*
** Customize the progress-bar color
** Customize the progress-bar component
*/
loading: '~/components/Loading.vue',
/*
** Global CSS
*/
css: [
'@/assets/style.less',
'@mdi/font/css/materialdesignicons.css'
'vuetify/dist/vuetify.min.css',
'@mdi/font/css/materialdesignicons.css',
'@/assets/style.less'
],
/*
@ -35,31 +43,25 @@ module.exports = {
plugins: [
'@/plugins/i18n.js',
'@/plugins/filters', // text filters, datetime filters, generic transformation helpers etc.
'@/plugins/vue-clipboard', // vuetify
'@/plugins/vuetify', // vuetify
'@/plugins/axios', // axios baseurl configuration
'@/plugins/validators', // inject validators
'@/plugins/api', // api helpers
{ src: '@/plugins/v-calendar', ssr: false } // v-calendar
],
render: {
compressor: false,
bundleRenderer: {
shouldPreload: (file, type) => {
return ['script', 'style', 'font'].includes(type)
}
}
},
/*
** Nuxt.js modules
*/
modules: [
// Doc: https://axios.nuxtjs.org/usage
'./@nuxtjs/axios',
'./@nuxtjs/auth',
['nuxt-express-module', { expressPath: 'server/', routesPath: 'server/routes' }]
'@nuxtjs/axios',
'@nuxtjs/auth',
'@/server/initialize.server.js'
],
serverMiddleware: ['server/routes'],
/*
** Axios module configuration
* See https://github.com/nuxt-community/axios-module#options
@ -93,28 +95,9 @@ module.exports = {
}
}
},
buildModules: [
'@nuxtjs/vuetify'
],
vuetify: {
defaultAssets: false,
optionsPath: './vuetify.options.js',
treeShake: true
/* module options */
},
/*
** Build configuration
*/
build: {
presets: ['@nuxt/babel-preset-app', {
useBuiltIns: 'usage', // or "entry"
corejs: 3
}],
babel: {
plugins: [['@babel/plugin-proposal-private-methods', { loose: true }]]
},
cache: true
}
corejs: 3,
cache: true,
hardSource: true
},
}

View file

@ -1,19 +1,17 @@
{
"name": "gancio",
"version": "1.0.0-alpha",
"version": "1.2.2",
"description": "A shared agenda for local communities",
"author": "lesion",
"scripts": {
"build": "cross-env nuxt build --modern",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
"dev": "NODE_ENV=development node server/index.js",
"dev:nuxt": "cross-env NODE_ENV=development nuxt dev --modern",
"build": "nuxt build --modern",
"start:inspect": "NODE_ENV=production node --inspect node_modules/.bin/nuxt start --modern",
"dev": "nuxt dev",
"start": "nuxt start --modern",
"doc": "cd docs && bundle exec jekyll b",
"doc:dev": "cd docs && bundle exec jekyll s --drafts",
"migrate": "NODE_ENV=production sequelize db:migrate",
"migrate:dev": "sequelize db:migrate",
"start:debug": "cross-env DEBUG=* NODE_ENV=production node server/cli.js",
"start": "NODE_ENV=production node server/cli.js"
"migrate:dev": "sequelize db:migrate"
},
"files": [
"server/",
@ -24,113 +22,76 @@
"locales/email/",
"locales/",
"store/",
"config/default.json",
"config/production.js",
".nuxt/",
"yarn.lock"
],
"dependencies": {
"@nuxtjs/auth": "^4.9.1",
"@nuxtjs/axios": "^5.13.5",
"@popperjs/core": "2.9.2",
"accept-language": "^3.0.18",
"axios": "^0.21.1",
"axios": "^0.24.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
"bufferutil": "^4.0.1",
"config": "^3.3.6",
"consola": "^2.15.3",
"cookie-parser": "^1.4.5",
"core-js": "3.14.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"date-fns": "^2.21.3",
"dayjs": "^1.10.5",
"dompurify": "^2.2.9",
"email-templates": "^8.0.7",
"dayjs": "^1.10.7",
"dompurify": "^2.3.3",
"email-templates": "^8.0.8",
"express": "^4.17.1",
"express-oauth-server": "^2.0.0",
"express-prom-bundle": "^6.3.4",
"fs": "^0.0.1-security",
"global": "^4.4.0",
"http-signature": "^1.3.5",
"express-oauth-server": "lesion/express-oauth-server#master",
"http-signature": "^1.3.6",
"ical.js": "^1.4.0",
"ics": "^2.27.0",
"inquirer": "^8.1.1",
"jsdom": "^16.6.0",
"ics": "^2.35.0",
"jsdom": "^18.1.1",
"jsonwebtoken": "^8.5.1",
"less": "^4.1.1",
"linkifyjs": "3.0.0-beta.3",
"linkify-html": "^3.0.4",
"linkifyjs": "3.0.4",
"lodash": "^4.17.21",
"microformat-node": "^2.0.1",
"mkdirp": "^1.0.4",
"multer": "^1.4.2",
"nuxt": "^2.15.7",
"nuxt-express-module": "^0.0.11",
"multer": "^1.4.3",
"nuxt-edge": "^2.16.0-27305297.ab1c6cb4",
"pg": "^8.6.0",
"pg-native": "3.0.0",
"prom-client": "^13.1.0",
"sequelize": "^6.6.2",
"sequelize-cli": "^6.2.0",
"sequelize-slugify": "^1.5.0",
"sharp": "^0.28.2",
"sqlite3": "^5.0.2",
"sequelize": "^6.12.0-alpha.1",
"sequelize-slugify": "^1.6.0",
"sharp": "^0.27.2",
"sqlite3": "mapbox/node-sqlite3#918052b",
"tiptap": "^1.32.0",
"tiptap-extensions": "^1.35.0",
"to-ico": "^1.1.5",
"url": "^0.11.0",
"utf-8-validate": "^5.0.5",
"v-calendar": "2.3.0",
"umzug": "^2.3.0",
"v-calendar": "2.3.4",
"vue": "^2.6.14",
"vue-clipboard2": "^0.3.1",
"vue-i18n": "^8.24.4",
"vue-server-renderer": "^2.6.14",
"vue-i18n": "^8.26.7",
"vue-template-compiler": "^2.6.14",
"vuetify": "^2.6.1",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.5",
"yargs": "^17.0.1"
"yargs": "^17.2.0"
},
"devDependencies": {
"@mdi/font": "^5.9.55",
"@nuxtjs/eslint-config": "^6.0.1",
"@nuxtjs/vuetify": "^1.12.1",
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"babel-eslint": "^10.1.0",
"eslint": "^7.27.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-node": ">=11.1.0",
"eslint-plugin-nuxt": "^2.0.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.10.0",
"fibers": "^5.0.0",
"less-loader": "7",
"@mdi/font": "^6.5.95",
"less": "^4.1.1",
"less-loader": "^7",
"prettier": "^2.3.0",
"pug": "^3.0.2",
"pug-plain-loader": "^1.1.0",
"sass": "^1.32.12",
"sass-loader": "10",
"typescript": "^4.3.4",
"vue-cli-plugin-vuetify": "~2.4.0",
"vuetify": "^2.5.4",
"vuetify-loader": "^1.7.1",
"sass": "^1.43.5",
"sequelize-cli": "^6.3.0",
"webpack": "4",
"webpack-cli": "^4.7.2"
},
"resolutions": {
"prosemirror-model": "1.14.1",
"source-map-resolve": "0.6.0",
"lodash": "4.17.21",
"minimist": "1.2.5",
"jimp": "0.16.1",
"resize-img": "2.0.0",
"underscore": "1.13.1",
"@nuxtjs/vuetify/**/sass": "1.32.12"
"@nuxtjs/vuetify/**/sass": "1.32.12",
"postcss": "7.0.36",
"glob-parent": "5.1.2",
"chokidar": "3.5.2",
"core-js": "3.19.0"
},
"bin": {
"gancio": "server/cli.js"

View file

@ -1,19 +1,18 @@
<template lang='pug'>
v-row.mt-5(align='center' justify='center')
v-col(cols='12' md="6" lg="5" xl="4")
v-card(light)
v-card-title {{settings.title}} - {{$t('common.authorize')}}
v-card-text
u {{$auth.user.email}}
div
p(v-html="$t('oauth.authorization_request', { app: client.name, instance_name: settings.title })")
ul
li(v-for="s in scope.split(' ')") {{$t(`oauth.scopes.${scope}`)}}
span(v-html="$t('oauth.redirected_to', {url: $route.query.redirect_uri})")
v-card-actions
v-spacer
v-btn(color='error' to='/') {{$t('common.cancel')}}
v-btn(:href='authorizeURL' color='success') {{$t('common.authorize')}}
.d-flex.justify-space-around
v-card.mt-5(max-width='600px')
v-card-title {{settings.title}} - {{$t('common.authorize')}}
v-card-text
u {{$auth.user.email}}
div
p(v-html="$t('oauth.authorization_request', { app: client.name, instance_name: settings.title })")
ul.mb-2
li(v-for="s in scope.split(' ')") {{$t(`oauth.scopes.${scope}`)}}
span(v-html="$t('oauth.redirected_to', {url: $route.query.redirect_uri})")
v-card-actions
v-spacer
v-btn(color='error' to='/') {{$t('common.cancel')}}
v-btn(:href='authorizeURL' color='success') {{$t('common.authorize')}}
</template>
<script>
@ -71,7 +70,7 @@ export default {
}
}
</script>
<style lang='less'>
<style>
h4 img {
max-height: 40px;
border-radius: 20px;

View file

@ -11,13 +11,13 @@
v-text-field(v-model='email' type='email'
validate-on-blur
:rules='$validators.email' autofocus
:placeholder='$t("common.email")'
:label='$t("common.email")'
ref='email')
v-text-field(v-model='password'
:rules='$validators.password'
type='password'
:placeholder='$t("common.password")')
:label='$t("common.password")')
v-card-actions
v-btn(text

View file

@ -7,8 +7,10 @@ v-col(cols=12)
v-btn(v-if='settings.allow_recurrent_event' value='recurrent' label="recurrent") {{$t('event.recurrent')}}
p {{$t(`event.${type}_description`)}}
v-btn-toggle.v-col-6.flex-column.flex-sm-row(v-if='type === "recurrent"' color='primary' :value='value.recurrent.frequency' @change='fq => change("frequency", fq)')
v-btn(v-for='f in frequencies' :key='f.value' :value='f.value') {{f.text}}
client-only
.datePicker.mt-3
v-input(:value='fromDate'
@ -43,11 +45,6 @@ v-col(cols=12)
:value='dueHour' clearable
:items='hourList' @change='hr => change("dueHour", hr)')
//- div.col-md-12(v-if='isRecurrent')
//- p(v-if='value.recurrent.frequency !== "1m" && value.recurrent.frequency !== "2m"') 🡲 {{whenPatterns}}
//- v-btn-toggle(v-else dense group v-model='value.recurrent.type' color='primary')
//- v-btn(text link v-for='whenPattern in whenPatterns' :value='whenPattern.key' :key='whenPatterns.key') {{whenPattern.label}}
List(v-if='type==="normal" && todayEvents.length' :events='todayEvents' :title='$t("event.same_day")')
</template>
@ -61,17 +58,13 @@ export default {
name: 'DateInput',
components: { List },
props: {
value: { type: Object, default: () => ({ from: null, due: null, recurrent: null }) }
value: { type: Object, default: () => ({ from: null, due: null, recurrent: null }) },
event: { type: Object, default: () => null }
},
data () {
return {
type: 'normal',
time: { start: null, end: null },
fromDateMenu: null,
dueDateMenu: null,
date: null,
page: null,
frequency: '',
events: [],
frequencies: [
{ value: '1w', text: this.$t('event.each_week') },
@ -85,7 +78,7 @@ export default {
todayEvents () {
const start = dayjs(this.value.from).startOf('day').unix()
const end = dayjs(this.value.from).endOf('day').unix()
const events = this.events.filter(e => e.start_datetime >= start && e.start_datetime <= end)
const events = this.events.filter(e => (this.event.id && e.id !== this.event.id) && e.start_datetime >= start && e.start_datetime <= end)
return events
},
attributes () {
@ -106,16 +99,15 @@ export default {
},
hourList () {
const hourList = []
const pad = '00'
const leftPad = h => ('00' + h).slice(-2)
for (let h = 0; h < 24; h++) {
hourList.push(`${(pad + h).slice(-pad.length)}:00`)
hourList.push(`${(pad + h).slice(-pad.length)}:30`)
const textHour = leftPad(h < 13 ? h : h - 12)
hourList.push({ text: textHour + ':00 ' + (h <= 12 ? 'AM' : 'PM'), value: leftPad(h) + ':00' })
hourList.push({ text: textHour + ':30 ' + (h <= 12 ? 'AM' : 'PM'), value: leftPad(h) + ':30' })
}
return hourList
},
isRecurrent () {
return !!this.value.recurrent
},
whenPatterns () {
if (!this.value.from) { return }
const date = dayjs(this.value.from)
@ -181,7 +173,7 @@ export default {
if (what === 'type') {
if (typeof value === 'undefined') { this.type = 'normal' }
if (value === 'recurrent') {
this.$emit('input', { ...this.value, recurrent: {}, multidate: false })
this.$emit('input', { ...this.value, recurrent: { frequency: '1w' }, multidate: false })
} else if (value === 'multidate') {
this.$emit('input', { ...this.value, recurrent: null, multidate: true })
} else {
@ -213,14 +205,14 @@ export default {
const fromHour = dayjs(this.value.from).hour()
// add a day
let due = dayjs(this.value.due)
let due = dayjs(this.value.from)
if (fromHour > Number(hour) && !this.value.multidate) {
due = due.add(1, 'day')
}
due = due.hour(hour).minute(minute)
this.$emit('input', { ...this.value, due, dueHour: true })
} else {
this.$emit('input', { ...this.value, dueHour: false })
this.$emit('input', { ...this.value, due: null, dueHour: false })
}
// change date in calendar (could be a range or a recurrent event...)
} else if (what === 'date') {
@ -240,30 +232,22 @@ export default {
this.$emit('input', { ...this.value, from, due })
} else {
let from = value
let due = value
let due = this.value.due
if (this.value.fromHour) {
from = dayjs(value).hour(dayjs(this.value.from).hour())
}
if (this.value.dueHour) {
if (this.value.dueHour && this.value.due) {
due = dayjs(value).hour(dayjs(this.value.due).hour())
}
this.$emit('input', { ...this.value, from, due })
}
}
},
changeType (type) {
if (type === 'recurrent') {
this.updateRecurrent({})
}
},
selectFrequency (f) {
this.$emit('input', { recurrent: { frequency: f }, from: this.value.from, due: this.value.due })
}
}
}
</script>
<style lang="less">
<style>
.datePicker {
max-width: 500px !important;
margin: 0 auto;

View file

@ -5,14 +5,14 @@
p(v-html="$t('event.import_description')")
v-form(v-model='valid' ref='form' lazy-validation @submit.prevent='importGeneric')
v-row
v-col
.col-xl-5.col-lg-5.col-md-7.col-sm-12.col-xs-12
v-text-field(v-model='URL'
:label="$t('common.url')"
:hint="$t('event.import_URL')"
persistent-hint
:loading='loading' :error='error'
:error-messages='errorMessage')
v-col
.col
v-file-input(
v-model='file'
accept=".ics"
@ -22,8 +22,8 @@
v-card-actions
v-spacer
v-btn(@click='$emit("close")' color='warning') {{$t('common.cancel')}}
v-btn(@click='importGeneric' :loading='loading' :disabled='loading'
v-btn(text @click='$emit("close")' color='warning') {{$t('common.cancel')}}
v-btn(text @click='importGeneric' :loading='loading' :disabled='loading'
color='primary') {{$t('common.import')}}
</template>

175
pages/add/MediaInput.vue Normal file
View file

@ -0,0 +1,175 @@
<template lang="pug">
span
v-dialog(v-model='openMediaDetails' :fullscreen="$vuetify.breakpoint.xsOnly" width='1000px')
v-card
v-card-title {{$t('common.media')}}
v-card-text
v-row.mt-1
v-col#focalPointSelector(
@mousedown='handleStart' @touchstart='handleStart'
@mousemove='handleMove' @touchmove='handleMove'
@mouseup='handleStop' @touchend='handleStop'
)
div.focalPoint(:style="{ top, left }")
img(v-if='mediaPreview' :src='mediaPreview')
v-col.col-12.col-sm-4
p {{$t('event.choose_focal_point')}}
img.img.d-none.d-sm-block(v-if='mediaPreview'
:src='mediaPreview' :style="{ 'object-position': position }")
v-textarea.mt-4(type='text'
label='Alternative text'
persistent-hint
@input='v => name=v'
:value='value.name' filled
:hint='$t("event.alt_text_description")')
br
v-card-actions.justify-space-between
v-btn(text @click='openMediaDetails=false' color='warning') Cancel
v-btn(text color='primary' @click='save') Save
h3.mb-3.font-weight-regular(v-if='mediaPreview') {{$t('common.media')}}
v-card-actions(v-if='mediaPreview')
v-spacer
v-btn(text color='primary' @click='openMediaDetails = true') {{$t('common.edit')}}
v-btn(text color='error' @click='remove') {{$t('common.remove')}}
div(v-if='mediaPreview')
img.img.col-12.ml-3(:src='mediaPreview' :style="{ 'object-position': savedPosition }")
span.float-right {{event.media[0].name}}
v-file-input(
v-else
:label="$t('common.media')"
:hint="$t('event.media_description')"
prepend-icon="mdi-camera"
:value='value.image'
@change="selectMedia"
persistent-hint
accept='image/*')
</template>
<script>
export default {
name: 'MediaInput',
props: {
value: { type: Object, default: () => ({ image: null }) },
event: { type: Object, default: () => {} }
},
data () {
return {
openMediaDetails: false,
name: this.value.name || '',
focalpoint: this.value.focalpoint || [0, 0],
dragging: false
}
},
computed: {
mediaPreview () {
if (!this.value.url && !this.value.image) {
return false
}
const url = this.value.image ? URL.createObjectURL(this.value.image) : /^https?:\/\//.test(this.value.url) ? this.value.url : `/media/thumb/${this.value.url}`
return url
},
top () {
return ((this.focalpoint[1] + 1) * 50) + '%'
},
left () {
return ((this.focalpoint[0] + 1) * 50) + '%'
},
savedPosition () {
const focalpoint = this.value.focalpoint || [0, 0]
return `${(focalpoint[0] + 1) * 50}% ${(focalpoint[1] + 1) * 50}%`
},
position () {
const focalpoint = this.focalpoint || [0, 0]
return `${(focalpoint[0] + 1) * 50}% ${(focalpoint[1] + 1) * 50}%`
}
},
methods: {
save () {
this.$emit('input', { url: this.value.url, image: this.value.image, name: this.name || this.value.image.name || '', focalpoint: [...this.focalpoint] })
this.openMediaDetails = false
},
async remove () {
const ret = await this.$root.$confirm('event.remove_media_confirmation')
if (!ret) { return }
this.$emit('remove')
},
selectMedia (v) {
this.$emit('input', { image: v, name: v.name, focalpoint: [0, 0] })
},
handleStart (ev) {
ev.preventDefault()
this.dragging = true
this.handleMove(ev, true)
return false
},
handleStop (ev) {
this.dragging = false
},
handleMove (ev, manual = false) {
if (!this.dragging && !manual) return
ev.stopPropagation()
const boundingClientRect = document.getElementById('focalPointSelector').getBoundingClientRect()
const clientX = ev.changedTouches ? ev.changedTouches[0].clientX : ev.clientX
const clientY = ev.changedTouches ? ev.changedTouches[0].clientY : ev.clientY
// get relative coordinate
let x = Math.ceil(clientX - boundingClientRect.left)
let y = Math.ceil(clientY - boundingClientRect.top)
// snap to border
x = x < 30 ? 0 : x > boundingClientRect.width - 30 ? boundingClientRect.width : x
y = y < 30 ? 0 : y > boundingClientRect.height - 30 ? boundingClientRect.height : y
// this.relativeFocalpoint = [x + 'px', y + 'px']
// map to real image coordinate
const posY = -1 + (y / boundingClientRect.height) * 2
const posX = -1 + (x / boundingClientRect.width) * 2
this.focalpoint = [posX, posY]
return false
}
}
}
</script>
<style>
.cursorPointer {
cursor: crosshair;
}
.img {
width: 100%;
object-fit: cover;
object-position: top;
aspect-ratio: 1.7778;
}
#focalPointSelector {
position: relative;
cursor: move;
overflow: hidden;
display: flex;
height: 100%;
justify-self: center;
align-items: center;
}
#focalPointSelector img {
width: 100%;
}
.focalPoint {
position: absolute;
width: 50px;
height: 50px;
top: 100px;
left: 100px;
transform: translate(-25px, -25px);
border-radius: 50%;
border: 1px solid #ff6d408e;
box-shadow: 0 0 0 9999em rgba(0, 0, 0, .65);
}
</style>

View file

@ -6,7 +6,7 @@
v-spacer
v-btn(link text color='primary' @click='openImportDialog=true')
<v-icon>mdi-file-import</v-icon> {{$t('common.import')}}
v-dialog(v-model='openImportDialog')
v-dialog(v-model='openImportDialog' :fullscreen='$vuetify.breakpoint.xsOnly')
ImportDialog(@close='openImportDialog=false' @imported='eventImported')
v-card-text.px-0.px-xs-2
@ -33,8 +33,7 @@
WhereInput(ref='where' v-model='event.place')
//- When
DateInput(v-model='date')
DateInput(v-model='date' :event='event')
//- Description
v-col.px-0(cols='12')
Editor.px-3.ma-0(
@ -45,14 +44,7 @@
//- MEDIA / FLYER / POSTER
v-col(cols=12 md=6)
v-file-input(
:label="$t('common.media')"
:hint="$t('event.media_description')"
prepend-icon="mdi-camera"
v-model='event.image'
persistent-hint
accept='image/*')
v-img.col-12.col-sm-2.ml-3(v-if='mediaPreview' :src='mediaPreview')
MediaInput(v-model='event.media[0]' :event='event' @remove='event.media=[]')
//- tags
v-col(cols=12 md=6)
@ -66,7 +58,7 @@
v-card-actions
v-spacer
v-btn(@click='done' :loading='loading' :disabled='!valid || loading'
color='primary') {{edit?$t('common.edit'):$t('common.send')}}
color='primary') {{edit?$t('common.save'):$t('common.send')}}
</template>
<script>
@ -77,16 +69,17 @@ import List from '@/components/List'
import ImportDialog from './ImportDialog'
import DateInput from './DateInput'
import WhereInput from './WhereInput'
import MediaInput from './MediaInput'
export default {
name: 'NewEvent',
components: { List, Editor, ImportDialog, WhereInput, DateInput },
components: { List, Editor, ImportDialog, MediaInput, WhereInput, DateInput },
validate ({ store }) {
return (store.state.auth.loggedIn || store.state.settings.allow_anon_event)
},
async asyncData ({ params, $axios, error, store }) {
if (params.edit) {
const data = { event: { place: {} } }
const data = { event: { place: {}, media: [] } }
data.id = params.edit
data.edit = true
let event
@ -112,7 +105,7 @@ export default {
data.event.description = event.description
data.event.id = event.id
data.event.tags = event.tags
data.event.image_path = event.image_path
data.event.media = event.media || []
return data
}
return {}
@ -128,15 +121,14 @@ export default {
title: '',
description: '',
tags: [],
image: null
media: []
},
page: { month, year },
fileList: [],
id: null,
date: { from: 0, due: 0, recurrent: null },
date: { from: null, due: null, recurrent: null },
edit: false,
loading: false,
mediaUrl: '',
disableAddress: false
}
},
@ -145,16 +137,7 @@ export default {
title: `${this.settings.title} - ${this.$t('common.add_event')}`
}
},
computed: {
...mapState(['tags', 'places', 'settings']),
mediaPreview () {
if (!this.event.image && !this.event.image_path) {
return false
}
const url = this.event.image ? URL.createObjectURL(this.event.image) : `/media/thumb/${this.event.image_path}`
return url
}
},
computed: mapState(['tags', 'places', 'settings']),
methods: {
...mapActions(['updateMeta']),
eventImported (event) {
@ -170,9 +153,6 @@ export default {
}
this.openImportDialog = false
},
cleanFile () {
this.event.image = {}
},
async done () {
if (!this.$refs.form.validate()) {
this.$nextTick(() => {
@ -187,16 +167,20 @@ export default {
formData.append('recurrent', JSON.stringify(this.date.recurrent))
if (this.event.image) {
formData.append('image', this.event.image)
if (this.event.media.length) {
formData.append('image', this.event.media[0].image)
formData.append('image_url', this.event.media[0].url)
formData.append('image_name', this.event.media[0].name)
formData.append('image_focalpoint', this.event.media[0].focalpoint)
}
formData.append('title', this.event.title)
formData.append('place_name', this.event.place.name)
formData.append('place_address', this.event.place.address)
formData.append('description', this.event.description)
formData.append('multidate', !!this.date.multidate)
formData.append('start_datetime', dayjs(this.date.from).unix())
formData.append('end_datetime', this.date.due && dayjs(this.date.due).unix())
formData.append('end_datetime', this.date.due ? dayjs(this.date.due).unix() : this.date.from.add(2, 'hour').unix())
if (this.edit) {
formData.append('id', this.event.id)
@ -219,7 +203,7 @@ export default {
this.$root.$message('event.image_too_big', { color: 'error' })
break
default:
this.$root.$message(e.response.data, { color: 'error' })
this.$root.$message(e.response ? e.response.data : e, { color: 'error' })
}
this.loading = false
}

View file

@ -1,8 +1,8 @@
<template lang="pug">
nuxt-link.embed_event(:to='`/event/${event.slug || event.id}`' target='_blank' :class='{ withImg: event.image_path }')
nuxt-link.embed_event(:to='`/event/${event.slug || event.id}`' target='_blank' :class='{ withImg: event.media }')
//- image
img.float-left(:src='`/media/thumb/${event.image_path || "logo.png"}`')
img.float-left(:src='event | mediaURL("thumb")')
.event-info
//- title
.date {{event|when}}<br/>
@ -37,7 +37,7 @@ export default {
.embed_event {
display: flex;
transition: margin .1s;
background: url('/favicon.ico') no-repeat right 5px bottom 5px;
background: url('/logo.png') no-repeat right 5px bottom 5px;
background-size: 32px;
background-color: #1f1f1f;
text-decoration: none;

View file

@ -11,10 +11,12 @@ export default {
const title = query.title
const tags = query.tags
const places = query.places
const show_recurrent = !!query.show_recurrent
let params = []
if (places) { params.push(`places=${places}`) }
if (tags) { params.push(`tags=${tags}`) }
if (show_recurrent) { params.push('show_recurrent=1') }
params = params.length ? `?${params.join('&')}` : ''
const events = await $axios.$get(`/export/json${params}`)

View file

@ -11,13 +11,14 @@ v-container#event.pa-0.pa-sm-2
v-row
v-col.col-12.col-lg-8
//- fake image to use u-featured in h-event microformat
img.u-featured(v-show='false' :src='`${settings.baseurl}${imgPath}`')
img.u-featured(v-show='false' v-if='hasMedia' :src='event | mediaURL')
v-img.main_image.mb-3(
contain
:src='imgPath'
:lazy-src='thumbImgPath'
v-if='event.image_path')
.p-description.text-body-1.pa-3.grey.darken-4.rounded(v-else v-html='event.description')
:alt='event | mediaURL("alt")'
:src='event | mediaURL'
:lazy-src='event | mediaURL("thumb")'
v-if='hasMedia')
.p-description.text-body-1.pa-3.rounded(v-if='!hasMedia && event.description' v-html='event.description')
v-col.col-12.col-lg-4
v-card
@ -34,21 +35,20 @@ v-container#event.pa-0.pa-sm-2
.text-h6.p-location
v-icon mdi-map-marker
b.vcard.ml-2 {{event.place.name}}
.text-subtitle-1.adr {{event.place.address}}
b.vcard.ml-2 {{event.place && event.place.name}}
.text-subtitle-1.adr {{event.place && event.place.address}}
//- tags, hashtags
v-card-text(v-if='event.tags.length')
v-chip.p-category.ml-1.mt-3(v-for='tag in event.tags' color='primary'
outlined :key='tag' v-text='tag')
outlined :key='tag')
span(v-text='tag')
//- info & actions
v-toolbar
v-tooltip(bottom) {{$t('common.copy_link')}}
template(v-slot:activator="{on, attrs} ")
v-btn.ml-2(large icon v-on='on' color='primary'
v-clipboard:success='copyLink'
v-clipboard:copy='`${settings.baseurl}/event/${event.slug || event.id}`')
v-btn.ml-2(large icon v-on='on' color='primary' @click='clipboard(`${settings.baseurl}/event/${event.slug || event.id}`)')
v-icon mdi-content-copy
v-tooltip(bottom) {{$t('common.embed')}}
template(v-slot:activator="{on, attrs} ")
@ -60,35 +60,43 @@ v-container#event.pa-0.pa-sm-2
:href='`/api/event/${event.slug || event.id}.ics`')
v-icon mdi-calendar-export
.p-description.text-body-1.pa-3.grey.darken-4.rounded(v-if='event.image_path && event.description' v-html='event.description')
.p-description.text-body-1.pa-3.rounded(v-if='hasMedia && event.description' v-html='event.description')
//- resources from fediverse
#resources.mt-1(v-if='settings.enable_federation')
div.float-right(v-if='!settings.hide_boosts')
small.mr-3 🔖 {{event.likes.length}}
small {{event.boost.length}}<br/>
//- div.float-right(v-if='settings.hide_boosts')
//- small.mr-3 🔖 {{event.likes.length}}
//- small {{event.boost.length}}<br/>
v-dialog.showResource#resourceDialog(v-model='showResources' fullscreen
width='95vw'
v-dialog(v-model='showResources'
fullscreen
destroy-on-close
@keydown.native.right='$refs.carousel.next()'
@keydown.native.left='$refs.carousel.prev()')
v-carousel(:interval='10000' ref='carousel' arrow='always')
v-carousel-item(v-for='attachment in selectedResource.data.attachment' :key='attachment.url')
v-img(:src='attachment.url')
v-list.mb-1(v-if='settings.enable_resources' v-for='resource in event.resources' dark
:key='resource.id' :class='{disabled: resource.hidden}')
v-list-item
v-list-title
scrollable
transition='dialog-bottom-transition')
v-card
v-btn.ma-2(icon dark @click='showResources = false')
v-icon mdi-close
v-carousel.pa-5(:interval='10000' ref='carousel' hide-delimiters v-model='currentAttachment'
height='100%' show-arrows-on-over)
v-carousel-item(v-for='attachment in selectedResource.data.attachment'
v-if='isImg(attachment)'
:key='attachment.url')
v-img(:src='attachment.url' contain max-width='100%' max-height='100%')
v-card-actions.align-center.justify-center
span {{currentAttachmentLabel}}
v-card.grey.darken-4.mb-3#resources(v-if='settings.enable_resources' v-for='resource in event.resources'
:key='resource.id' :class='{disabled: resource.hidden}' elevation='10' outlined)
v-card-title
v-menu(v-if='$auth.user && $auth.user.is_admin' offset-y)
template(v-slot:activator="{ on, attrs }")
v-btn.mr-2(v-on='on' v-attrs='attrs' color='primary' small icon outlined)
template(v-slot:activator="{ on }")
v-btn.mr-2(v-on='on' color='primary' small icon)
v-icon mdi-dots-vertical
v-list
v-list-item(v-if='!resource.hidden' @click='hideResource(resource, true)')
v-list-item-title <v-icon left>mdi-eye-off</v-icon> {{$t('admin.hide_resource')}}
v-list-item(v-else @click='hideResource(resource, false)')
v-list-item-title <v-icon left>mdi-eye-on</v-icon> {{$t('admin.show_resource')}}
v-list-item-title <v-icon left>mdi-eye</v-icon> {{$t('admin.show_resource')}}
v-list-item(@click='deleteResource(resource)')
v-list-item-title <v-icon left>mdi-delete</v-icon> {{$t('admin.delete_resource')}}
v-list-item(@click='blockUser(resource)')
@ -97,9 +105,16 @@ v-container#event.pa-0.pa-sm-2
a(:href='resource.data.url || resource.data.context')
small {{resource.data.published|dateFormat('ddd, D MMMM HH:mm')}}
v-card-text
div.mt-1(v-html='resource_filter(resource.data.content)')
span.previewImage(@click='showResource(resource)')
img(v-for='img in resource.data.attachment' :src='img.url')
span(v-for='attachment in resource.data.attachment' :key='attachment.url')
audio(v-if='isAudio(attachment)' controls)
source(:src='attachment.url')
v-img.cursorPointer(v-if='isImg(attachment)' :src='attachment.url' @click='showResource(resource)'
max-height="250px"
max-width="250px"
contain :alt='attachment.name')
//- Next/prev arrow
.text-center.mt-5.mb-5
@ -110,7 +125,7 @@ v-container#event.pa-0.pa-sm-2
:to='`/event/${event.next}`' :disabled='!event.next')
v-icon mdi-arrow-right
v-dialog(v-model='showEmbed' width='1000px')
v-dialog(v-model='showEmbed' width='700px' :fullscreen='$vuetify.breakpoint.xsOnly')
EmbedEvent(:event='event' @close='showEmbed=false')
</template>
@ -118,22 +133,27 @@ v-container#event.pa-0.pa-sm-2
import { mapState } from 'vuex'
import EventAdmin from './eventAdmin'
import EmbedEvent from './embedEvent'
import get from 'lodash/get'
import moment from 'dayjs'
import clipboard from '../../assets/clipboard'
const htmlToText = require('html-to-text')
export default {
name: 'Event',
mixins: [clipboard],
components: { EventAdmin, EmbedEvent },
async asyncData ({ $axios, params, error, store }) {
try {
const event = await $axios.$get(`/event/${params.id}`)
return { event, id: Number(params.id) }
const event = await $axios.$get(`/event/${params.slug}`)
return { event }
} catch (e) {
error({ statusCode: 404, message: 'Event not found' })
}
},
data () {
return {
currentAttachment: 0,
event: {},
showEmbed: false,
showResources: false,
@ -153,8 +173,8 @@ export default {
const place_feed = {
rel: 'alternate',
type: 'application/rss+xml',
title: `${this.settings.title} events @${this.event.place.name}`,
href: this.settings.baseurl + `/feed/rss?places=${this.event.place.id}`
title: `${this.settings.title} events @${this.event.place && this.event.place.name}`,
href: this.settings.baseurl + `/feed/rss?places=${this.event.place && this.event.place.id}`
}
return {
@ -180,7 +200,7 @@ export default {
{ property: 'og:type', content: 'event' },
{
property: 'og:image',
content: this.thumbImgPath
content: this.$options.filters.mediaURL(this.event)
},
{ property: 'og:site_name', content: this.settings.title },
{
@ -196,7 +216,7 @@ export default {
{ property: 'twitter:title', content: this.event.title },
{
property: 'twitter:image',
content: this.thumbImgPath
content: this.$options.filters.mediaURL(this.event, 'thumb')
},
{
property: 'twitter:description',
@ -204,7 +224,7 @@ export default {
}
],
link: [
{ rel: 'image_src', href: this.thumbImgPath },
{ rel: 'image_src', href: this.$options.filters.mediaURL(this.event, 'thumb') },
{
rel: 'alternate',
type: 'application/rss+xml',
@ -218,14 +238,14 @@ export default {
},
computed: {
...mapState(['settings']),
hasMedia () {
return this.event.media && this.event.media.length
},
plainDescription () {
return htmlToText.fromString(this.event.description.replace('\n', '').slice(0, 1000))
},
imgPath () {
return '/media/' + this.event.image_path
},
thumbImgPath () {
return this.settings.baseurl + '/media/thumb/' + this.event.image_path
currentAttachmentLabel () {
return get(this.selectedResource, `data.attachment[${this.currentAttachment}].name`, '')
},
is_mine () {
if (!this.$auth.user) {
@ -243,6 +263,14 @@ export default {
window.removeEventListener('keydown', this.keyDown)
},
methods: {
isImg (attachment) {
const type = attachment.mediaType.split('/')[0]
return type === 'image'
},
isAudio (attachment) {
const type = attachment.mediaType.split('/')[0]
return type === 'audio'
},
keyDown (ev) {
if (ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) { return }
if (ev.key === 'ArrowRight' && this.event.next) {
@ -255,7 +283,7 @@ export default {
showResource (resource) {
this.showResources = true
this.selectedResource = resource
document.getElementById('resourceDialog').focus()
// document.getElementById('resourceDialog').focus()
},
async hideResource (resource, hidden) {
await this.$axios.$put(`/resources/${resource.id}`, { hidden })
@ -298,16 +326,9 @@ export default {
}
}
</script>
<style lang='less'>
.title {
margin-bottom: 25px;
color: yellow;
font-weight: 300 !important;
}
<style scoped>
.main_image {
// width: 100%;
margin: 0 auto;
// max-height: 120vh;
border-radius: 5px;
transition: max-height 0.2s;
}

View file

@ -2,41 +2,32 @@
v-card
v-card-title(v-text="$t('common.embed_title')")
v-card-text
v-row
v-col.col-12
v-alert.mb-1.mt-1(type='info' show-icon) {{$t('common.embed_help')}}
v-text-field(v-model='code')
v-btn(slot='prepend' text color='primary'
v-clipboard:copy='code'
v-clipboard:success='copyLink') {{$t("common.copy")}}
v-alert.mb-3.mt-1(type='info' show-icon) {{$t('common.embed_help')}}
v-alert.pa-5.my-4.blue-grey.darken-4.text-body-1.lime--text.text--lighten-3 <pre>{{code}}</pre>
v-btn.float-end(text color='primary' @click='clipboard(code)') {{$t("common.copy")}}
v-icon.ml-1 mdi-content-copy
v-col.mt-2(v-html='code')
p.mx-auto
.mx-auto
gancio-event(:id='event.id' :baseurl='settings.baseurl')
v-card-actions
v-spacer
v-btn(color='warning' @click="$emit('close')") {{$t("common.cancel")}}
v-btn(v-clipboard:copy='code' v-clipboard:success='copyLink' color="primary") {{$t("common.copy")}}
v-btn(text color='warning' @click="$emit('close')") {{$t("common.cancel")}}
v-btn(text @click='clipboard(code)' color="primary") {{$t("common.copy")}}
</template>
<script>
import { mapState } from 'vuex'
import clipboard from '../../assets/clipboard'
export default {
name: 'EmbedEvent',
mixins: [clipboard],
props: {
event: { type: Object, default: () => ({}) }
},
computed: {
...mapState(['settings']),
code () {
const style = "style='border: 0; width: 100%; height: 215px;'"
const src = `${this.settings.baseurl}/embed/${this.event.slug || this.event.id}`
const code = `<iframe ${style} src="${src}"></iframe>`
return code
}
},
methods: {
copyLink () {
this.$root.$message('common.copied', { color: 'success' })
return `<script src='${this.settings.baseurl}\/gancio-events.es.js'><\/script>\n<gancio-event baseurl='${this.settings.baseurl}' id=${this.event.id}></gancio-event>\n\n`
}
}
}

View file

@ -10,7 +10,7 @@
v-col
Search(
:filters='filters'
@update='updateFilters')
@update='f => filters = f')
v-tabs(v-model='type')
//- TOFIX
@ -30,9 +30,7 @@
v-card-text
p(v-html='$t(`export.feed_description`)')
v-text-field(v-model='link' readonly)
v-btn(slot='prepend' text color='primary'
v-clipboard:copy='link'
v-clipboard:success='copyLink.bind(this, "feed")') {{$t("common.copy")}}
v-btn(slot='prepend' text color='primary' @click='clipboard(link)') {{$t("common.copy")}}
v-icon.ml-1 mdi-content-copy
v-tab ics/ical
@ -41,8 +39,7 @@
v-card-text
p(v-html='$t(`export.ical_description`)')
v-text-field(v-model='link')
v-btn(slot='prepend' text color='primary'
v-clipboard:copy='link' v-clipboard:success='copyLink.bind(this, "ical")') {{$t("common.copy")}}
v-btn(slot='prepend' text color='primary' @click='clipboard(link)') {{$t("common.copy")}}
v-icon.ml-1 mdi-content-copy
v-tab List
@ -54,16 +51,17 @@
v-row
v-col.mr-2(:span='11')
v-text-field(v-model='list.title' :label='$t("common.title")')
v-text-field(v-model='list.maxEvents' type='number' :label='$t("common.max_events")')
v-text-field(v-model='list.maxEvents' type='number' min='1' :label='$t("common.max_events")')
v-col.float-right(:span='12')
List(
span {{filters.places.join(',')}}
gancio-events(:baseurl='settings.baseurl'
:maxlength='list.maxEvents && Number(list.maxEvents)'
:title='list.title'
:maxEvents='list.maxEvents'
:events='events')
v-text-field.mb-1(type='textarea' v-model='listScript' readonly )
v-btn(slot='prepend' text
color='primary' v-clipboard:copy='listScript' v-clipboard:success='copyLink.bind(this,"list")') {{$t('common.copy')}}
v-icon.ml-1 mdi-content-copy
:places='filters.places.join(",")'
:tags='filters.tags.join(",")')
v-alert.pa-5.my-4.blue-grey.darken-4.text-body-1.lime--text.text--lighten-3 <pre>{{code}}</pre>
v-btn.float-end(text color='primary' @click='clipboard(code)') {{$t("common.copy")}}
v-icon.ml-1 mdi-content-copy
v-tab(v-if='settings.enable_federation') {{$t('common.fediverse')}}
v-tab-item(v-if='settings.enable_federation')
@ -84,10 +82,12 @@ import { mapState } from 'vuex'
import List from '@/components/List'
import FollowMe from '../components/FollowMe'
import Search from '@/components/Search'
import clipboard from '../assets/clipboard'
export default {
name: 'Exports',
components: { List, FollowMe, Search },
mixins: [clipboard],
async asyncData ({ $axios, params, store, $api }) {
const events = await $api.getEvents({
start: dayjs().unix(),
@ -99,7 +99,7 @@ export default {
return {
type: 'rss',
notification: { email: '' },
list: { title: 'Gancio', maxEvents: 3 },
list: { title: 'Gancio', maxEvents: null },
filters: { tags: [], places: [], show_recurrent: false },
events: []
}
@ -111,28 +111,32 @@ export default {
},
computed: {
...mapState(['settings']),
domain () {
const URL = url.parse(this.settings.baseurl)
return URL.hostname
},
listScript () {
const params = []
code () {
const params = [`baseurl="${this.settings.baseurl}"`]
if (this.list.title) {
params.push(`title=${this.list.title}`)
params.push(`title="${this.list.title}"`)
}
if (this.filters.places.length) {
params.push(`places=${this.filters.places.map(p => p.id)}`)
params.push(`places="${this.filters.places.join(',')}"`)
}
if (this.filters.tags.length) {
params.push(`tags=${this.filters.tags.join(',')}`)
params.push(`tags="${this.filters.tags.join(',')}"`)
}
if (this.filters.show_recurrent) {
params.push('show_recurrent=true')
params.push('show_recurrent')
}
return `<iframe style='border: 0px; width: 100%;' src="${this.settings.baseurl}/embed/list?${params.join('&')}"></iframe>`
if (this.list.maxEvents) {
params.push('maxlength=' + this.list.maxEvents)
}
return `<script src="${this.settings.baseurl}\/gancio-events.es.js'><\/script>\n<gancio-events ${params.join(' ')}></gancio-events>\n\n`
},
link () {
const typeMap = ['rss', 'ics', 'list']
@ -157,22 +161,6 @@ export default {
}
},
methods: {
async updateFilters (filters) {
this.filters = filters
this.events = await this.$api.getEvents({
start: dayjs().unix(),
places: this.filters.places,
tags: this.filters.tags,
show_recurrent: !!this.filters.show_recurrent
})
},
copyLink (type) {
if (type === 'feed') {
this.$root.$message('common.feed_url_copied')
} else {
this.$root.$message('common.copied')
}
},
async add_notification () {
// validate()
// if (!this.notification.email) {
@ -184,7 +172,7 @@ export default {
// Message({ message: this.$t('email_notification_activated'), showClose: true, type: 'success' })
},
imgPath (event) {
return event.image_path && event.image_path
return event.media && event.media[0].url
}
}
}

View file

@ -10,7 +10,7 @@
.col-xl-5.col-lg-5.col-md-7.col-sm-12.col-xs-12.pa-4.pa-sm-3
//- this is needed as v-calendar does not support SSR
//- https://github.com/nathanreyes/v-calendar/issues/336
client-only
client-only(placeholder='Calendar unavailable without js')
Calendar(@dayclick='dayChange' @monthchange='monthChange' :events='filteredEvents')
.col.pt-0.pt-md-2
@ -36,6 +36,7 @@ import Calendar from '@/components/Calendar'
export default {
name: 'Index',
components: { Event, Search, Announcement, Calendar },
middleware: 'setup',
async asyncData ({ params, $api, store }) {
const events = await $api.getEvents({
start: dayjs().startOf('month').unix(),
@ -65,7 +66,7 @@ export default {
{ hid: 'og-description', name: 'og:description', content: this.settings.description },
{ hid: 'og-title', property: 'og:title', content: this.settings.title },
{ hid: 'og-url', property: 'og:url', content: this.settings.baseurl },
{ property: 'og:image', content: this.settings.baseurl + '/favicon.ico' }
{ property: 'og:image', content: this.settings.baseurl + '/logo.png' }
],
link: [
{ rel: 'alternate', type: 'application/rss+xml', title: this.settings.title, href: this.settings.baseurl + '/feed/rss' }

View file

@ -1,33 +1,34 @@
<template lang='pug'>
v-container
v-row.mt-5(align='center' justify='center')
v-col(cols='12' md="6" lg="5" xl="4")
v-card
v-card-title {{settings.title}} - {{$t('common.recover_password')}}
v-card-text
div(v-if='valid')
v-card-title {{$t('common.recover_password')}}
template(v-if='user')
v-card-subtitle {{user.email}}
v-card-text
v-text-field(type='password'
:rules="$validators.password"
autofocus :placeholder='$t("common.new_password")'
v-model='new_password')
div(v-else) {{$t('recover.not_valid_code')}}
div(v-else) {{$t('recover.not_valid_code')}}
v-card-actions
v-spacer
v-btn(v-if='valid' color='primary' @click='change_password') {{$t('common.send')}}
v-btn(v-if='user' text color='primary' @click='change_password') {{$t('common.send')}}
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'Recover',
layout: 'modal',
async asyncData ({ params, $axios }) {
const code = params.code
try {
const valid = await $axios.$post('/user/check_recover_code', { recover_code: code })
return { valid, code }
const user = await $axios.$post('/user/check_recover_code', { recover_code: code })
return { user, code }
} catch (e) {
return { valid: false }
return { user: false }
}
},
data () {
@ -50,7 +51,7 @@ export default {
}
}
</script>
<style lang='less'>
<style>
h4 img {
max-height: 40px;
border-radius: 20px;

View file

@ -8,7 +8,6 @@
</template>
<script>
import { mapState } from 'vuex'
import url from 'url'
export default {
name: 'Settings',
@ -19,12 +18,7 @@ export default {
user: { }
}
},
computed: {
...mapState(['settings']),
baseurl () {
return url.parse(this.settings.baseurl).host
}
},
computed: mapState(['settings']),
methods: {
// async change_password () {
// if (!this.password) { return }

38
pages/setup/Completed.vue Normal file
View file

@ -0,0 +1,38 @@
<template lang="pug">
v-container
v-card-title.d-block.text-h5.text-center(v-text="$t('setup.completed')")
v-card-text(v-html="$t('setup.completed_description', user)")
v-card-actions
v-btn(text @click='next' color='primary' :loading='loading' :disabled='loading') {{$t('setup.start')}}
v-icon mdi-arrow-right
</template>
<script>
export default {
data () {
return {
loading: false,
user: {
email: 'admin',
password: ''
}
}
},
methods: {
next () {
window.location='/admin'
},
async start (user) {
this.user = { ...user }
this.loading = true
try {
await this.$axios.$get('/ping')
this.loading = false
} catch (e) {
setTimeout(() => this.start(user), 1000)
}
}
}
}
</script>

47
pages/setup/DbStep.vue Normal file
View file

@ -0,0 +1,47 @@
<template lang="pug">
v-container
v-card-title.text-h5 Database
v-card-text
v-form
v-btn-toggle(text color='primary' v-model='db.dialect')
v-btn(value='sqlite' text) sqlite
v-btn(value='postgres' text) postgres
template(v-if='db.dialect === "sqlite"')
v-text-field(v-model='db.storage' label='Path')
template(v-if='db.dialect === "postgres"')
v-text-field(v-model='db.hostname' label='Hostname' :rules="[$validators.required('hostname')]")
v-text-field(v-model='db.database' label='Database' :rules="[$validators.required('database')]")
v-text-field(v-model='db.username' label='Username' :rules="[$validators.required('username')]")
v-text-field(type='password' v-model='db.password' label='Password' :rules="[$validators.required('password')]")
v-card-actions
v-btn(text @click='checkDb' color='primary' :loading='loading' :disabled='loading') {{$t('common.next')}}
v-icon mdi-arrow-right
</template>
<script>
export default {
data () {
return {
db: {
storage: './gancio.sqlite',
hostname: 'localhost',
database: 'gancio'
},
loading: false
}
},
methods: {
async checkDb () {
this.loading = true
try {
await this.$axios.$post('/setup/db', { db: this.db })
this.$root.$message('DB Connection OK!', { color: 'success' })
this.$emit('complete', this.db)
} catch (e) {
this.$root.$message(e.response.data, { color: 'error' })
}
this.loading = false
}
}
}
</script>

64
pages/setup/index.vue Normal file
View file

@ -0,0 +1,64 @@
<template lang="pug">
v-container.pa-6
h2.mb-2.text-center Gancio Setup
v-stepper.grey.lighten-5(v-model='step')
v-stepper-header
v-stepper-step(:complete='step > 1' step='1') Database
v-divider
v-stepper-step(:complete='step > 2' step='2') Configuration
v-divider
v-stepper-step(:complete='step > 3' step='3') Finish
v-stepper-items
v-stepper-content(step='1')
DbStep(@complete='dbCompleted')
v-stepper-content(step='2')
Settings(setup, @complete='configCompleted')
v-stepper-content(step='3')
Completed(ref='completed')
</template>
<script>
import DbStep from './DbStep'
import Settings from '../../components/admin/Settings'
import Completed from './Completed'
export default {
components: { DbStep, Settings, Completed },
middleware: 'setup',
layout: 'clean',
head: {
title: 'Setup',
},
auth: false,
data () {
return {
config: {
db: {
dialect: ''
}
},
step: 1
}
},
methods: {
dbCompleted (db) {
this.step = this.step + 1
},
async configCompleted () {
try {
const user = await this.$axios.$post('/setup/restart')
this.step = this.step + 1
this.$refs.completed.start(user)
} catch (e) {
this.$root.$message(e.response ? e.response.data : e, { color: 'error' })
}
}
}
}
</script>

View file

@ -1,17 +1,21 @@
<template lang="pug">
v-container
v-row.mt-5(align='center' justify='center')
v-col(cols='12' md="6" lg="5" xl="4")
v-card
v-card-title <nuxt-link to='/'><img src='/favicon.ico'/></nuxt-link> {{$t('common.set_password')}}
template(v-if='valid')
v-card-text(v-if='valid')
v-form(v-if='valid')
v-text-field(type='password' v-model='new_password' :label="$t('common.new_password')")
v-card-title {{$t('common.set_password')}}
template(v-if='user')
v-card-subtitle {{user.email}}
v-card-text
v-form
v-text-field(type='password' v-model='new_password' :label="$t('common.new_password')" :rules='$validators.password' autofocus)
v-card-actions
v-btn(color="success" :disabled='!new_password' @click='change_password') {{$t('common.send')}}
v-spacer
v-btn(text color="primary" :disabled='!new_password' @click='change_password') {{$t('common.send')}}
v-card-text(v-else) {{$t('recover.not_valid_code')}}
v-card-text(v-else)
v-alert.ma-5(type='error') {{$t('recover.not_valid_code')}}
</template>
<script>
@ -21,10 +25,10 @@ export default {
async asyncData ({ params, $axios }) {
const code = params.code
try {
const valid = await $axios.$post('/user/check_recover_code', { recover_code: code })
return { valid, code }
const user = await $axios.$post('/user/check_recover_code', { recover_code: code })
return { user, code }
} catch (e) {
return { valid: false }
return { user: false }
}
},
data () {

View file

@ -1,44 +0,0 @@
<template lang="pug">
el-card
nuxt-link.float-right(to='/')
el-button(circle icon='el-icon-close' type='danger' size='small' plain)
h5 <img src='/favicon.ico'/> {{$t('common.set_password')}}
div(v-if='valid')
el-form
el-form-item {{$t('common.new_password')}}
el-input(type='password', v-model='new_password')
el-button(plain type="success" icon='el-icon-send', @click='change_password') {{$t('common.send')}}
div(v-else) {{$t('recover.not_valid_code')}}
</template>
<script>
export default {
name: 'Recover',
async asyncData ({ params, $axios }) {
const code = params.code
try {
const valid = await $axios.$post('/user/check_recover_code', { recover_code: code })
return { valid, code }
} catch (e) {
return { valid: false }
}
},
data () {
return { new_password: '' }
},
methods: {
async change_password () {
try {
await this.$axios.$post('/user/recover_password', { recover_code: this.code, password: this.new_password })
this.$root.$message('common.password_updated', { color: 'succes' })
this.$router.replace('/login')
} catch (e) {
this.$root.$message(e, { color: 'warning' })
}
}
}
}
</script>

View file

@ -1,5 +1,5 @@
export default ({ $axios, store }, inject) => {
export default ({ $axios }, inject) => {
const api = {
/**

View file

@ -33,6 +33,18 @@ export default ({ app, store }) => {
// shown in mobile homepage
Vue.filter('day', value => dayjs.unix(value).locale(store.state.locale).format('dddd, D MMM'))
Vue.filter('mediaURL', (event, type) => {
if (event.media && event.media.length) {
if (type === 'alt') {
return event.media[0].name
} else {
return store.state.settings.baseurl + '/media/' + (type === 'thumb' ? 'thumb/' : '') + event.media[0].url
}
} else if (type !== 'alt') {
return store.state.settings.baseurl + '/media/' + (type === 'thumb' ? 'thumb/' : '') + 'logo.svg'
}
return ''
})
Vue.filter('from', timestamp => dayjs.unix(timestamp).fromNow())

View file

@ -0,0 +1,28 @@
const plugin = {
gancio: null,
load (gancio) {
console.error('Plugin GancioPluginExample loaded!')
plugin.gancio = gancio
},
onEventCreate (event) {
const eventLink = `${plugin.gancio.settings.baseurl}/event/${event.slug}`
if (!event.is_visible) {
console.error(`Unconfirmed event created: ${event.title} / ${eventLink}`)
} else {
console.error(`Event created: ${event.title} / ${eventLink}`)
}
},
onEventUpdate (event) {
console.error(`Event "${event.title}" updated`)
},
onEventDelete (event) {
console.error(`Event "${event.title}" deleted`)
}
}
module.exports = plugin

View file

@ -4,18 +4,20 @@ import merge from 'lodash/merge'
Vue.use(VueI18n)
export default ({ app, store, req }) => {
export default async ({ app, store, req }) => {
const messages = {}
if (process.server) {
store.commit('setLocale', req.settings.locale)
if (req.settings.user_locale) { store.commit('setUserLocale', req.settings.user_locale) }
store.commit('setLocale', req.acceptedLocale)
if (req.user_locale) {
store.commit('setUserLocale', req.user_locale)
}
}
const messages = {}
messages[store.state.locale] = require(`../locales/${store.state.locale}.json`)
messages[store.state.locale] = await import(/* webpackChunkName: "lang-[request]" */`../locales/${store.state.locale}.json`)
// always include en fallback locale
if (store.state.locale !== 'en') {
messages.en = require('../locales/en.json')
messages.en = await import('../locales/en.json')
}
if (store.state.user_locale) {

View file

@ -8,7 +8,7 @@ export default ({ app }, inject) => {
},
email: [
v => !!v || $t('validators.required', { fieldName: $t('common.email') }),
v => (v && !!linkify.test(v, 'email')) || $t('validators.email')
v => (v && (v === 'admin' || !!linkify.test(v, 'email')) || $t('validators.email'))
],
password: [
v => !!v || $t('validators.required', { fieldName: $t('common.password') })

View file

@ -1,6 +0,0 @@
import Vue from 'vue'
import VueClipboard from 'vue-clipboard2'
export default () => {
Vue.use(VueClipboard)
}

34
plugins/vuetify.js Normal file
View file

@ -0,0 +1,34 @@
import Vue from 'vue'
import Vuetify from 'vuetify'
// import it from 'vuetify/lib/locale/it.js'
// import en from 'vuetify/lib/locale/en.js'
// import es from 'vuetify/lib/locale/es'
// import no from 'vuetify/lib/locale/no'
// import fr from 'vuetify/lib/locale/fr'
// import ca from 'vuetify/lib/locale/ca'
export default ({ app }) => {
Vue.use(Vuetify)
app.vuetify = new Vuetify({
// lang: {
// locales: { en, it }, //, es, fr, no, ca },
// current: 'en'
// },
icons: {
iconfont: 'mdi'
},
theme: {
dark: true,
themes: {
dark: {
primary: '#FF6E40'
},
light: {
primary: '#FF4500'
}
}
}
})
}

View file

@ -17,7 +17,7 @@ const announceController = {
announcement: req.body.announcement,
visible: true
}
log.info('Create announcement: "%s" ', req.body.title)
log.info('Create announcement: ' + req.body.title)
const announce = await Announcement.create(announcementDetail)
res.json(announce)
},
@ -34,20 +34,20 @@ const announceController = {
announce = await announce.update(announceDetails)
res.json(announce)
} catch (e) {
log.error('Toggle announcement failed: %s ', e)
log.error('Toggle announcement failed', e)
res.sendStatus(404)
}
},
async remove (req, res) {
log.info('Remove announcement "%d"', req.params.announce_id)
log.info('Remove announcement', req.params.announce_id)
const announce_id = req.params.announce_id
try {
const announce = await Announcement.findByPk(announce_id)
await announce.destroy()
res.sendStatus(200)
} catch (e) {
log.error('Remove announcement failed: "%s" ', e)
log.error('Remove announcement failed:', e)
res.sendStatus(404)
}
}

View file

@ -1,10 +1,10 @@
const crypto = require('crypto')
const path = require('path')
const config = require('config')
const config = require('../../config')
const fs = require('fs')
const { Op } = require('sequelize')
const intersection = require('lodash/intersection')
const linkifyHtml = require('linkifyjs/html')
const linkifyHtml = require('linkify-html')
const Sequelize = require('sequelize')
const dayjs = require('dayjs')
const helpers = require('../../helpers')
@ -85,11 +85,26 @@ const eventController = {
res.json(place)
},
async _get(slug) {
// retrocompatibility, old events URL does not use slug, use id as fallback
const id = Number(slug) || -1
return Event.findOne({
where: {
[Op.or]: {
slug,
id
}
}
})
},
async get (req, res) {
const format = req.params.format || 'json'
const is_admin = req.user && req.user.is_admin
const slug = req.params.event_id
const id = Number(req.params.event_id) || -1
const slug = req.params.event_slug
// retrocompatibility, old events URL does not use slug, use id as fallback
const id = Number(slug) || -1
let event
try {
@ -118,7 +133,7 @@ const eventController = {
order: [[Resource, 'id', 'DESC']]
})
} catch (e) {
log.error(e)
log.error('[EVENT]', e)
return res.sendStatus(400)
}
@ -194,7 +209,7 @@ const eventController = {
const notifier = require('../../notifier')
notifier.notifyEvent('Create', event.id)
} catch (e) {
log.error(e)
log.error('[EVENT]', e)
res.sendStatus(404)
}
},
@ -264,7 +279,7 @@ const eventController = {
async add (req, res) {
// req.err comes from multer streaming error
if (req.err) {
log.info(req.err)
log.warn(req.err)
return res.status(400).json(req.err.toString())
}
@ -272,6 +287,11 @@ const eventController = {
const body = req.body
const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null
if (!body.place_name) {
log.warn('Place is required')
return res.status(400).send('Place is required')
}
const eventDetails = {
title: body.title,
// remove html tags
@ -284,10 +304,23 @@ const eventController = {
is_visible: !!req.user
}
if (req.file) {
eventDetails.image_path = req.file.filename
} else if (body.image_url) {
eventDetails.image_path = await helpers.getImageFromURL(body.image_url)
if (req.file || body.image_url) {
let url
if (req.file) {
url = req.file.filename
} else {
url = await helpers.getImageFromURL(body.image_url)
}
let focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0']
focalpoint = [parseFloat(focalpoint[0]).toFixed(2), parseFloat(focalpoint[1]).toFixed(2)]
eventDetails.media = [{
url,
name: body.image_name || body.title || '',
focalpoint: [parseFloat(focalpoint[0]), parseFloat(focalpoint[1])]
}]
} else {
eventDetails.media = []
}
const event = await Event.create(eventDetails)
@ -324,12 +357,12 @@ const eventController = {
if (event.recurrent) {
eventController._createRecurrent()
} else {
// send notifications (mastodon / email)
// send notifications
const notifier = require('../../notifier')
notifier.notifyEvent('Create', event.id)
}
} catch (e) {
log.error(e)
log.error('[EVENT ADD]', e)
res.sendStatus(400)
}
},
@ -338,28 +371,29 @@ const eventController = {
if (req.err) {
return res.status(400).json(req.err.toString())
}
const body = req.body
const event = await Event.findByPk(body.id)
if (!event) { return res.sendStatus(404) }
if (!req.user.is_admin && event.userId !== req.user.id) {
return res.sendStatus(403)
}
const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null
const eventDetails = {
title: body.title,
// remove html tags
description: helpers.sanitizeHTML(linkifyHtml(body.description, { target: '_blank' })),
multidate: body.multidate,
start_datetime: body.start_datetime,
end_datetime: body.end_datetime,
recurrent
}
try {
const body = req.body
const event = await Event.findByPk(body.id)
if (!event) { return res.sendStatus(404) }
if (!req.user.is_admin && event.userId !== req.user.id) {
return res.sendStatus(403)
}
if (req.file) {
if (event.image_path && !event.recurrent) {
const old_path = path.resolve(config.upload_path, event.image_path)
const old_thumb_path = path.resolve(config.upload_path, 'thumb', event.image_path)
const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null
const eventDetails = {
title: body.title,
// remove html tags
description: helpers.sanitizeHTML(linkifyHtml(body.description, { target: '_blank' })),
multidate: body.multidate,
start_datetime: body.start_datetime,
end_datetime: body.end_datetime,
recurrent
}
if ((req.file || /^https?:\/\//.test(body.image_url)) && !event.recurrent && event.media && event.media.length) {
const old_path = path.resolve(config.upload_path, event.media[0].url)
const old_thumb_path = path.resolve(config.upload_path, 'thumb', event.media[0].url)
try {
fs.unlinkSync(old_path)
fs.unlinkSync(old_thumb_path)
@ -367,34 +401,55 @@ const eventController = {
log.info(e.toString())
}
}
eventDetails.image_path = req.file.filename
} else if (body.image_url) {
eventDetails.image_path = await helpers.getImageFromURL(body.image_url)
}
let url
if (req.file) {
url = req.file.filename
} else if (body.image_url) {
if (/^https?:\/\//.test(body.image_url)) {
url = await helpers.getImageFromURL(body.image_url)
} else {
url = body.image_url
}
}
await event.update(eventDetails)
const [place] = await Place.findOrCreate({
where: { name: body.place_name },
defaults: { address: body.place_address }
})
if (url && !event.recurrent) {
const focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0']
eventDetails.media = [{
url,
name: body.image_name || '',
focalpoint: [parseFloat(focalpoint[0].slice(0, 6)), parseFloat(focalpoint[1].slice(0, 6))]
}]
} else {
eventDetails.media = []
}
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: [Tag, Place] })
res.json(newEvent)
await event.update(eventDetails)
const [place] = await Place.findOrCreate({
where: { name: body.place_name },
defaults: { address: body.place_address }
})
// create recurrent instances of event if needed
// without waiting for the task manager
if (event.recurrent) {
eventController._createRecurrent()
} else {
const notifier = require('../../notifier')
notifier.notifyEvent('Update', event.id)
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: [Tag, Place] })
res.json(newEvent)
// create recurrent instances of event if needed
// without waiting for the task manager
if (event.recurrent) {
eventController._createRecurrent()
} else {
const notifier = require('../../notifier')
notifier.notifyEvent('Update', event.id)
}
} catch (e) {
log.error('[EVENT UPDATE]', e)
res.sendStatus(400)
}
},
@ -402,9 +457,9 @@ const eventController = {
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 && !event.recurrent) {
const old_path = path.join(config.upload_path, event.image_path)
const old_thumb_path = path.join(config.upload_path, 'thumb', event.image_path)
if (event.media && event.media.length && !event.recurrent) {
const old_path = path.join(config.upload_path, event.media[0].url)
const old_thumb_path = path.join(config.upload_path, 'thumb', event.media[0].url)
try {
fs.unlinkSync(old_thumb_path)
fs.unlinkSync(old_path)
@ -414,6 +469,12 @@ const eventController = {
}
const notifier = require('../../notifier')
await notifier.notifyEvent('Delete', event.id)
// unassociate child events
if (event.recurrent) {
await Event.update({ parentId: null }, { where: { parentId: event.id } })
}
log.debug('[EVENT REMOVED] ' + event.title)
await event.destroy()
res.sendStatus(200)
} else {
@ -421,7 +482,8 @@ const eventController = {
}
},
async _select ({ start, end, tags, places, show_recurrent }) {
async _select ({ start, end, tags, places, show_recurrent, max }) {
const where = {
// do not include parent recurrent event
recurrent: null,
@ -448,7 +510,7 @@ const eventController = {
let where_tags = {}
if (tags) {
where_tags = { where: { tag: tags.split(',') } }
where_tags = { where: { [Op.or]: { tag: tags.split(',') } } }
}
const events = await Event.findAll({
@ -456,20 +518,23 @@ const eventController = {
attributes: {
exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'updatedAt', 'description', 'resources']
},
order: ['start_datetime', Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE "tagTag" = tag) DESC')],
order: ['start_datetime'],
include: [
{ model: Resource, required: false, attributes: ['id'] },
{
model: Tag,
order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')],
attributes: ['tag'],
required: !!tags,
...where_tags,
through: { attributes: [] }
},
{ model: Place, required: true, attributes: ['id', 'name', 'address'] }
]
],
limit: max
}).catch(e => {
log.error(e)
log.error('[EVENT]', e)
return []
})
return events.map(e => {
@ -483,20 +548,22 @@ const eventController = {
* Select events based on params
*/
async select (req, res) {
const start = req.query.start
const start = req.query.start || dayjs().unix()
const end = req.query.end
const tags = req.query.tags
const places = req.query.places
const max = req.query.max
const show_recurrent = settingsController.settings.allow_recurrent_event &&
(typeof req.query.show_recurrent !== 'undefined' ? req.query.show_recurrent === 'true' : settingsController.settings.recurrent_event_visible)
res.json(await eventController._select({
start, end, places, tags, show_recurrent
start, end, places, tags, show_recurrent, max
}))
},
/**
* Ensure we have the next instance of a recurrent event
* TODO: create a future instance if the next one is skipped
*/
_createRecurrentOccurrence (e) {
log.debug(`Create recurrent event [${e.id}] ${e.title}"`)
@ -504,15 +571,16 @@ const eventController = {
parentId: e.id,
title: e.title,
description: e.description,
image_path: e.image_path,
media: e.media,
is_visible: true,
userId: e.userId,
placeId: e.placeId
}
const recurrent = e.recurrent
let cursor = dayjs()
const start_date = dayjs.unix(e.start_datetime)
const now = dayjs()
let cursor = start_date > now ? start_date : now
const duration = dayjs.unix(e.end_datetime).diff(start_date, 's')
const frequency = recurrent.frequency
const type = recurrent.type

View file

@ -12,6 +12,7 @@ const exportController = {
const type = req.params.type
const tags = req.query.tags
const places = req.query.places
const show_recurrent = !!req.query.show_recurrent
const where = {}
const yesterday = moment().subtract('1', 'day').unix()
@ -25,9 +26,13 @@ const exportController = {
where.placeId = places.split(',')
}
if (!show_recurrent) {
where.parentId = null
}
const events = await Event.findAll({
order: ['start_datetime'],
attributes: { exclude: ['is_visible', 'recurrent', 'createdAt', 'updatedAt', 'likes', 'boost', 'slug', 'userId', 'placeId'] },
attributes: { exclude: ['is_visible', 'recurrent', 'createdAt', 'likes', 'boost', 'userId', 'placeId'] },
where: {
is_visible: true,
recurrent: { [Op.eq]: null },
@ -62,8 +67,8 @@ const exportController = {
const eventsMap = events.map(e => {
const tmpStart = moment.unix(e.start_datetime)
const tmpEnd = moment.unix(e.end_datetime)
const start = tmpStart.utc(true).format('YYYY-M-D-H-m').split('-')
const end = tmpEnd.utc(true).format('YYYY-M-D-H-m').split('-')
const start = tmpStart.utc(true).format('YYYY-M-D-H-m').split('-').map(Number)
const end = tmpEnd.utc(true).format('YYYY-M-D-H-m').split('-').map(Number)
return {
start,
// startOutputType: 'utc',
@ -77,8 +82,12 @@ const exportController = {
}
})
res.type('text/calendar; charset=UTF-8')
const ret = ics.createEvents(eventsMap)
res.send(ret.value)
ics.createEvents(eventsMap, (err, value) => {
if (err) {
return res.status(401).send(err)
}
return res.send(value)
})
}
}

View file

@ -42,7 +42,7 @@ const oauthController = {
delete client.id
res.json(client)
} catch (e) {
log.error(e)
log.error('[OAUTH CLIENT]', e)
res.status(400).json(e)
}
},

View file

@ -1,4 +1,7 @@
const Resource = require('../models/resource')
const APUser = require('../models/ap_user')
const Event = require('../models/event')
const get = require('lodash/get')
const resourceController = {
async hide (req, res) {
@ -17,12 +20,28 @@ const resourceController = {
},
async getAll (req, res) {
const limit = req.body.limit || 100
const limit = req.body.limit || 1000
// const where = {}
// if (req.params.instanceId) {
// where =
//
const resources = await Resource.findAll({ limit })
let resources = await Resource.findAll({ limit, include: [APUser, Event], order: [['createdAt', 'DESC']] })
resources = resources.map(r => ({
id: r.id,
hidden: r.hidden,
created: r.createdAt,
data: {
content: r.data.content
},
event: {
id: r.event.id,
title: r.event.title
},
ap_user: {
ap_id: get(r, 'ap_user.ap_id', ''),
preferredUsername: get(r, 'ap_user.object.preferredUsername', '')
}
}))
res.json(resources)
}
}

View file

@ -1,22 +1,29 @@
const Setting = require('../models/setting')
const config = require('config')
const consola = require('consola')
const path = require('path')
const URL = require('url')
const fs = require('fs')
const pkg = require('../../../package.json')
const crypto = require('crypto')
const util = require('util')
const toIco = require('to-ico')
const generateKeyPair = util.promisify(crypto.generateKeyPair)
const readFile = util.promisify(fs.readFile)
const writeFile = util.promisify(fs.writeFile)
const { promisify } = require('util')
const sharp = require('sharp')
const config = require('../../config')
const pkg = require('../../../package.json')
const generateKeyPair = promisify(crypto.generateKeyPair)
const log = require('../../log')
const locales = require('../../../locales/index')
let defaultHostname
try {
defaultHostname = new URL.URL(config.baseurl).hostname
} catch (e) {}
const defaultSettings = {
title: config.title || 'Gancio',
description: config.description || 'A shared agenda for local communities',
baseurl: config.baseurl || '',
hostname: defaultHostname,
instance_timezone: 'Europe/Rome',
instance_locale: 'en',
instance_name: config.title.toLowerCase().replace(/ /g, ''),
instance_name: 'gancio',
instance_place: '',
allow_registration: true,
allow_anon_event: true,
@ -32,7 +39,9 @@ const defaultSettings = {
footerLinks: [
{ href: '/', label: 'home' },
{ href: '/about', label: 'about' }
]
],
admin_email: config.admin_email || '',
smtp: config.smtp || false
}
/**
@ -45,54 +54,88 @@ const settingsController = {
secretSettings: {},
async load () {
if (!settingsController.settings.initialized) {
// initialize instance settings from db
// note that this is done only once when the server starts
// and not for each request (it's a kind of cache)!
const settings = await Setting.findAll()
settingsController.settings.initialized = true
if (config.firstrun) {
settingsController.settings = defaultSettings
settings.forEach(s => {
if (s.is_secret) {
settingsController.secretSettings[s.key] = s.value
} else {
settingsController.settings[s.key] = s.value
return
}
if (settingsController.settings.initialized) return
settingsController.settings.initialized = true
// initialize instance settings from db
// note that this is done only once when the server starts
// and not for each request
const Setting = require('../models/setting')
const settings = await Setting.findAll()
settingsController.settings = defaultSettings
settings.forEach(s => {
if (s.is_secret) {
settingsController.secretSettings[s.key] = s.value
} else {
settingsController.settings[s.key] = s.value
}
})
// add pub/priv instance key if needed
if (!settingsController.settings.publicKey) {
log.info('Instance priv/pub key not found, generating....')
const { publicKey, privateKey } = await generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
})
// add pub/priv instance key if needed
if (!settingsController.settings.publicKey) {
log.info('Instance priv/pub key not found, generating....')
const { publicKey, privateKey } = await generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
await settingsController.set('publicKey', publicKey)
await settingsController.set('privateKey', privateKey, true)
}
// initialize user_locale
if (config.user_locale && fs.existsSync(path.resolve(config.user_locale))) {
const user_locales_files = fs.readdirSync(path.resolve(config.user_locale))
user_locales_files.forEach( f => {
const locale = path.basename(f ,'.json')
if (locales[locale]) {
log.info(`Adding custom locale ${locale}`)
settingsController.user_locale[locale] = require(path.resolve(config.user_locale, f)).default
} else {
log.warning(`Unknown custom user locale: ${locale} [valid locales are ${locales}]`)
}
})
}
// load custom plugins
const plugins_path = path.resolve(process.env.cwd || '', 'plugins')
if (fs.existsSync(plugins_path)) {
const notifier = require('../../notifier')
const pluginsFile = fs.readdirSync(plugins_path).filter(e => path.extname(e).toLowerCase() === '.js')
pluginsFile.forEach( pluginFile => {
try {
const plugin = require(path.resolve(plugins_path, pluginFile))
if (typeof plugin.load !== 'function') return
plugin.load({ settings: settingsController.settings })
log.info(`Plugin ${pluginFile} loaded!`)
if (typeof plugin.onEventCreate === 'function') {
notifier.emitter.on('Create', plugin.onEventCreate)
}
})
await settingsController.set('publicKey', publicKey)
await settingsController.set('privateKey', privateKey, true)
}
// initialize user_locale
if (config.user_locale && fs.existsSync(path.resolve(config.user_locale))) {
const user_locale = fs.readdirSync(path.resolve(config.user_locale))
user_locale.forEach(async f => {
consola.info(`Loading user locale ${f}`)
const locale = path.basename(f, '.js')
settingsController.user_locale[locale] =
(await require(path.resolve(config.user_locale, f))).default
})
}
if (typeof plugin.onEventDelete === 'function') {
notifier.emitter.on('Delete', plugin.onEventDelete)
}
if (typeof plugin.onEventUpdate === 'function') {
notifier.emitter.on('Update', plugin.onEventUpdate)
}
} catch (e) {
log.warn(`Unable to load plugin ${pluginFile}: ${String(e)}`)
}
})
}
},
async set (key, value, is_secret = false) {
const Setting = require('../models/setting')
log.info(`SET ${key} ${is_secret ? '*****' : value}`)
try {
const [setting, created] = await Setting.findOrCreate({
@ -103,7 +146,7 @@ const settingsController = {
settingsController[is_secret ? 'secretSettings' : 'settings'][key] = value
return true
} catch (e) {
log.error(e)
log.error('[SETTING SET]', e)
return false
}
},
@ -114,6 +157,19 @@ const settingsController = {
if (ret) { res.sendStatus(200) } else { res.sendStatus(400) }
},
async testSMTP (req, res) {
const smtp = req.body
await settingsController.set('smtp', smtp.smtp)
const mail = require('../mail')
try {
await mail._send(settingsController.settings.admin_email, 'test', null, 'en')
return res.sendStatus(200)
} catch (e) {
console.error(e)
return res.status(400).send(String(e))
}
},
setLogo (req, res) {
if (!req.file) {
settingsController.set('logo', false)
@ -124,16 +180,13 @@ const settingsController = {
const baseImgPath = path.resolve(config.upload_path, 'logo')
// convert and resize to png
sharp(uploadedPath)
return sharp(uploadedPath)
.resize(400)
.png({ quality: 90 })
.toFile(baseImgPath + '.png', async (err, info) => {
.toFile(baseImgPath + '.png', (err, info) => {
if (err) {
log.error(err)
log.error('[LOGO] ' + err)
}
const image = await readFile(baseImgPath + '.png')
const favicon = await toIco([image], { sizes: [64], resize: true })
writeFile(baseImgPath + '.ico', favicon)
settingsController.set('logo', baseImgPath)
res.sendStatus(200)
})
@ -141,14 +194,7 @@ const settingsController = {
getAllRequest (req, res) {
// get public settings and public configuration
const settings = {
...settingsController.settings,
baseurl: config.baseurl,
title: config.title,
description: config.description,
version: pkg.version
}
res.json(settings)
res.json({ ...settingsController.settings, version: pkg.version })
}
}

View file

@ -0,0 +1,86 @@
const URL = require('url')
const helpers = require('../../helpers.js')
const log = require('../../log')
const db = require('../models/index.js')
const config = require('../../config')
const settingsController = require('./settings')
const path = require('path')
const setupController = {
async setupDb (req, res, next) {
log.debug('[SETUP] Check db')
const dbConf = req.body.db
if (!dbConf) {
return res.sendStatus(400)
}
if (dbConf.storage) {
dbConf.storage = path.resolve(process.env.cwd || '', dbConf.storage)
}
try {
// try to connect
dbConf.logging = false
await db.connect(dbConf)
// is empty ?
const isEmpty = await db.isEmpty()
if (!isEmpty) {
log.warn(' ⚠ Non empty db! Please move your current db elsewhere than retry.')
return res.status(400).send(' ⚠ Non empty db! Please move your current db elsewhere than retry.')
}
await db.runMigrations()
config.db = dbConf
config.firstrun = false
config.db.logging = false
config.baseurl = req.protocol + '://' + req.headers.host
config.hostname = new URL.URL(config.baseurl).hostname
const settingsController = require('./settings')
await settingsController.load()
return res.sendStatus(200)
} catch (e) {
return res.status(400).send(String(e))
}
},
async restart (req, res) {
try {
// write configuration
config.write()
// calculate default settings values
await settingsController.set('theme.is_dark', true)
await settingsController.set('instance_name', settingsController.settings.title.toLowerCase().replace(/ /g, ''))
// create admin
const password = helpers.randomString()
const email = `admin`
const User = require('../models/user')
await User.create({
email,
password,
is_admin: true,
is_active: true
})
res.json({ password, email })
log.info('Restart needed')
res.end()
// exit process so pm2 || docker could restart me || service
process.kill(process.pid)
} catch (e) {
log.error(String(e))
return res.status(400).send(String(e))
}
}
}
module.exports = setupController

View file

@ -1,6 +1,6 @@
const crypto = require('crypto')
const { Op } = require('sequelize')
const config = require('config')
const config = require('../../config')
const mail = require('../mail')
const User = require('../models/user')
const settingsController = require('./settings')
@ -26,7 +26,7 @@ const userController = {
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.sendStatus(200)
res.json({ email: user.email })
},
async updatePasswordWithRecoverCode (req, res) {
@ -50,7 +50,7 @@ const userController = {
},
async getAll (req, res) {
const users = await User.scope('withoutPassword').findAll({
const users = await User.scope(req.user.is_admin ? 'withRecover' : 'withoutPassword').findAll({
order: [['is_admin', 'DESC'], ['createdAt', 'DESC']]
})
res.json(users)
@ -100,10 +100,10 @@ const userController = {
const user = await User.create(req.body)
log.info(`Sending registration email to ${user.email}`)
mail.send(user.email, 'register', { user, config }, req.settings.locale)
mail.send(config.admin_email, 'admin_register', { user, config })
mail.send(settingsController.settings.admin_email, 'admin_register', { user, config })
res.sendStatus(200)
} catch (e) {
log.error('Registration error: "%s"', e)
log.error('Registration error:', e)
res.status(404).json(e)
}
},
@ -112,11 +112,11 @@ const userController = {
try {
req.body.is_active = true
req.body.recover_code = crypto.randomBytes(16).toString('hex')
const user = await User.create(req.body)
const user = await User.scope('withRecover').create(req.body)
mail.send(user.email, 'user_confirm', { user, config }, req.settings.locale)
res.json(user)
} catch (e) {
log.error('User creation error: %s', e)
log.error('User creation error:', e)
res.status(404).json(e)
}
},
@ -124,10 +124,11 @@ const userController = {
async remove (req, res) {
try {
const user = await User.findByPk(req.params.id)
user.destroy()
await user.destroy()
log.warn(`User ${user.email} removed!`)
res.sendStatus(200)
} catch (e) {
log.error('User removal error: "%s"', e)
log.error('User removal error:"', e)
res.status(404).json(e)
}
}

View file

@ -2,150 +2,166 @@ const express = require('express')
const multer = require('multer')
const cors = require('cors')()
const { 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 instanceController = require('./controller/instance')
const apUserController = require('./controller/ap_user')
const resourceController = require('./controller/resource')
const oauthController = require('./controller/oauth')
const announceController = require('./controller/announce')
const helpers = require('../helpers')
const storage = require('./storage')
const upload = multer({ storage })
const config = require('config')
const config = require('../config')
const log = require('../log')
const api = express.Router()
api.use(express.urlencoded({ extended: false }))
api.use(express.json())
/**
* Get current authenticated user
* @category User
* @name /api/user
* @type GET
* @example **Response**
* ```json
{
"description" : null,
"recover_code" : "",
"id" : 1,
"createdAt" : "2020-01-29T18:10:16.630Z",
"updatedAt" : "2020-01-30T22:42:14.789Z",
"is_active" : true,
"settings" : "{}",
"email" : "eventi@cisti.org",
"is_admin" : true
if (config.firstrun) {
const setupController = require('./controller/setup')
const settingsController = require('./controller/settings')
api.post('/settings', settingsController.setRequest)
api.post('/setup/db', setupController.setupDb)
api.post('/setup/restart', setupController.restart)
api.post('/settings/smtp', settingsController.testSMTP)
} else {
const { isAuth, isAdmin } = require('./auth')
const eventController = require('./controller/event')
const settingsController = require('./controller/settings')
const exportController = require('./controller/export')
const userController = require('./controller/user')
const instanceController = require('./controller/instance')
const apUserController = require('./controller/ap_user')
const resourceController = require('./controller/resource')
const oauthController = require('./controller/oauth')
const announceController = require('./controller/announce')
const helpers = require('../helpers')
const storage = require('./storage')
const upload = multer({ storage })
/**
* Get current authenticated user
* @category User
* @name /api/user
* @type GET
* @example **Response**
* ```json
{
"description" : null,
"recover_code" : "",
"id" : 1,
"createdAt" : "2020-01-29T18:10:16.630Z",
"updatedAt" : "2020-01-30T22:42:14.789Z",
"is_active" : true,
"settings" : "{}",
"email" : "eventi@cisti.org",
"is_admin" : true
}
```
*/
api.get('/ping', (req, res) => res.sendStatus(200))
api.get('/user', isAuth, (req, res) => res.json(req.user))
api.post('/user/recover', userController.forgotPassword)
api.post('/user/check_recover_code', userController.checkRecoverCode)
api.post('/user/recover_password', userController.updatePasswordWithRecoverCode)
// register and add users
api.post('/user/register', userController.register)
api.post('/user', isAdmin, userController.create)
// update user
api.put('/user', isAuth, userController.update)
// delete user
api.delete('/user/:id', isAdmin, userController.remove)
api.delete('/user', isAdmin, userController.remove)
// get all users
api.get('/users', isAdmin, userController.getAll)
// update a place (modify address..)
api.put('/place', isAdmin, eventController.updatePlace)
/**
* Add a new event
* @category Event
* @name /event
* @type POST
* @info `Content-Type` has to be `multipart/form-data` to support image upload
* @param {string} title - event's title
* @param {string} description - event's description (html accepted and sanitized)
* @param {string} place_name - the name of the place
* @param {string} [place_address] - the address of the place
* @param {integer} start_datetime - start timestamp
* @param {integer} multidate - is a multidate event?
* @param {array} tags - List of tags
* @param {object} [recurrent] - Recurrent event details
* @param {string} [recurrent.frequency] - could be `1w` or `2w`
* @param {string} [recurrent.type] - not used
* @param {array} [recurrent.days] - array of days
* @param {image} [image] - Image
*/
// allow anyone to add an event (anon event has to be confirmed, TODO: flood protection)
api.post('/event', upload.single('image'), eventController.add)
api.put('/event', isAuth, upload.single('image'), eventController.update)
api.get('/event/import', isAuth, helpers.importURL)
// remove event
api.delete('/event/:id', isAuth, eventController.remove)
// get tags/places
api.get('/event/meta', eventController.getMeta)
// get unconfirmed events
api.get('/event/unconfirmed', isAdmin, eventController.getUnconfirmed)
// add event notification TODO
api.post('/event/notification', eventController.addNotification)
api.delete('/event/notification/:code', eventController.delNotification)
api.get('/settings', settingsController.getAllRequest)
api.post('/settings', isAdmin, settingsController.setRequest)
api.post('/settings/logo', isAdmin, multer({ dest: config.upload_path }).single('logo'), settingsController.setLogo)
api.post('/settings/smtp', isAdmin, settingsController.testSMTP)
// confirm event
api.put('/event/confirm/:event_id', isAuth, eventController.confirm)
api.put('/event/unconfirm/:event_id', isAuth, eventController.unconfirm)
// get event
api.get('/event/:event_slug.:format?', cors, eventController.get)
// export events (rss/ics)
api.get('/export/:type', cors, exportController.export)
// get events in this range
api.get('/events', cors, eventController.select)
api.get('/instances', isAdmin, instanceController.getAll)
api.get('/instances/:instance_domain', isAdmin, instanceController.get)
api.post('/instances/toggle_block', isAdmin, instanceController.toggleBlock)
api.post('/instances/toggle_user_block', isAdmin, apUserController.toggleBlock)
api.put('/resources/:resource_id', isAdmin, resourceController.hide)
api.delete('/resources/:resource_id', isAdmin, resourceController.remove)
api.get('/resources', isAdmin, resourceController.getAll)
// - ADMIN ANNOUNCEMENTS
api.get('/announcements', isAdmin, announceController.getAll)
api.post('/announcements', isAdmin, announceController.add)
api.put('/announcements/:announce_id', isAdmin, announceController.update)
api.delete('/announcements/:announce_id', isAdmin, announceController.remove)
// OAUTH
api.get('/clients', isAuth, oauthController.getClients)
api.get('/client/:client_id', isAuth, oauthController.getClient)
api.post('/client', oauthController.createClient)
}
```
*/
api.get('/user', isAuth, (req, res) => res.json(req.user))
api.post('/user/recover', userController.forgotPassword)
api.post('/user/check_recover_code', userController.checkRecoverCode)
api.post('/user/recover_password', userController.updatePasswordWithRecoverCode)
// register and add users
api.post('/user/register', userController.register)
api.post('/user', isAdmin, userController.create)
// update user
api.put('/user', isAuth, userController.update)
// delete user
api.delete('/user/:id', isAdmin, userController.remove)
api.delete('/user', isAdmin, userController.remove)
// get all users
api.get('/users', isAdmin, userController.getAll)
// update a place (modify address..)
api.put('/place', isAdmin, eventController.updatePlace)
/**
* Add a new event
* @category Event
* @name /event
* @type POST
* @info `Content-Type` has to be `multipart/form-data` to support image upload
* @param {string} title - event's title
* @param {string} description - event's description (html accepted and sanitized)
* @param {string} place_name - the name of the place
* @param {string} [place_address] - the address of the place
* @param {integer} start_datetime - start timestamp
* @param {integer} multidate - is a multidate event?
* @param {array} tags - List of tags
* @param {object} [recurrent] - Recurrent event details
* @param {string} [recurrent.frequency] - could be `1w` or `2w`
* @param {string} [recurrent.type] - not used
* @param {array} [recurrent.days] - array of days
* @param {image} [image] - Image
*/
// allow anyone to add an event (anon event has to be confirmed, TODO: flood protection)
api.post('/event', upload.single('image'), eventController.add)
api.put('/event', isAuth, upload.single('image'), eventController.update)
api.get('/event/import', isAuth, helpers.importURL)
// remove event
api.delete('/event/:id', isAuth, eventController.remove)
// get tags/places
api.get('/event/meta', eventController.getMeta)
// get unconfirmed events
api.get('/event/unconfirmed', isAdmin, eventController.getUnconfirmed)
// add event notification TODO
api.post('/event/notification', eventController.addNotification)
api.delete('/event/notification/:code', eventController.delNotification)
api.get('/settings', settingsController.getAllRequest)
api.post('/settings', isAdmin, settingsController.setRequest)
api.post('/settings/logo', isAdmin, multer({ dest: config.upload_path }).single('logo'), settingsController.setLogo)
// confirm event
api.put('/event/confirm/:event_id', isAuth, eventController.confirm)
api.put('/event/unconfirm/:event_id', isAuth, eventController.unconfirm)
// get event
api.get('/event/:event_id.:format?', cors, eventController.get)
// export events (rss/ics)
api.get('/export/:type', cors, exportController.export)
// get events in this range
api.get('/events', cors, eventController.select)
api.get('/instances', isAdmin, instanceController.getAll)
api.get('/instances/:instance_domain', isAdmin, instanceController.get)
api.post('/instances/toggle_block', isAdmin, instanceController.toggleBlock)
api.post('/instances/toggle_user_block', isAdmin, apUserController.toggleBlock)
api.put('/resources/:resource_id', isAdmin, resourceController.hide)
api.delete('/resources/:resource_id', isAdmin, resourceController.remove)
api.get('/resources', isAdmin, resourceController.getAll)
// - ADMIN ANNOUNCEMENTS
api.get('/announcements', isAdmin, announceController.getAll)
api.post('/announcements', isAdmin, announceController.add)
api.put('/announcements/:announce_id', isAdmin, announceController.update)
api.delete('/announcements/:announce_id', isAdmin, announceController.remove)
// OAUTH
api.get('/clients', isAuth, oauthController.getClients)
api.get('/client/:client_id', isAuth, oauthController.getClient)
api.post('/client', oauthController.createClient)
api.use((req, res) => res.sendStatus(404))
// Handle 500
api.use((error, req, res, next) => {
log.error(error)
log.error('[API ERROR]', error)
res.status(500).send('500: Internal Server Error')
})

View file

@ -1,7 +1,6 @@
const Email = require('email-templates')
const path = require('path')
const moment = require('dayjs')
const config = require('config')
const settingsController = require('./controller/settings')
const log = require('../log')
const { Task, TaskManager } = require('../taskManager')
@ -9,7 +8,11 @@ const locales = require('../../locales')
const mail = {
send (addresses, template, locals, locale = settingsController.settings.instance_locale) {
log.debug('Enqueue new email ', template, locale)
if (process.env.NODE_ENV === 'production' && (!settingsController.settings.admin_email || !settingsController.settings.smtp)) {
log.error(`Cannot send any email: SMTP Email configuration not completed!`)
return
}
log.debug(`Enqueue new email ${template} ${locale}`)
const task = new Task({
name: 'MAIL',
method: mail._send,
@ -18,7 +21,8 @@ const mail = {
TaskManager.add(task)
},
_send (addresses, template, locals, locale) {
_send (addresses, template, locals, locale = settingsController.settings.instance_locale) {
const settings = settingsController.settings
log.info(`Send ${template} email to ${addresses} with locale ${locale}`)
const email = new Email({
views: { root: path.join(__dirname, '..', 'emails') },
@ -31,7 +35,7 @@ const mail = {
}
},
message: {
from: `📅 ${config.title} <${config.admin_email}>`
from: `📅 ${settings.title} <${settings.admin_email}>`
},
send: true,
i18n: {
@ -39,29 +43,29 @@ const mail = {
objectNotation: true,
syncFiles: false,
updateFiles: false,
defaultLocale: settingsController.settings.instance_locale || 'en',
defaultLocale: settings.instance_locale || 'en',
locale,
locales: Object.keys(locales)
},
transport: config.smtp
transport: settings.smtp || {}
})
const msg = {
template,
message: {
to: addresses,
bcc: config.admin_email
to: addresses
},
locals: {
...locals,
locale,
config: { title: config.title, baseurl: config.baseurl, description: config.description, admin_email: config.admin_email },
config: { title: settings.title, baseurl: settings.baseurl, description: settings.description, admin_email: settings.admin_email },
datetime: datetime => moment.unix(datetime).locale(locale).format('ddd, D MMMM HH:mm')
}
}
return email.send(msg)
.catch(e => {
log.error('Error sending email => %s', e)
log.error('[MAIL]', e)
throw e
})
}
}

View file

@ -1,4 +1,4 @@
const sequelize = require('./index')
const sequelize = require('./index').sequelize
const { Model, DataTypes } = require('sequelize')
class Announcement extends Model {}

Some files were not shown because too many files have changed in this diff Show more