Merge branch 'master' into gh

This commit is contained in:
lesion 2022-06-06 16:57:05 +02:00
commit 616c54229a
No known key found for this signature in database
GPG key ID: 352918250B012177
110 changed files with 5909 additions and 2199 deletions

2
.gitignore vendored
View file

@ -1,6 +1,8 @@
# Created by .ignore support plugin (hsz.mobi)
### Gancio dev configuration
tests/seeds/testdb.sqlite
preso.md
gancio.sqlite
db.sqlite
releases

View file

@ -1,7 +1,37 @@
All notable changes to this project will be documented in this file.
### UNRELEASED
- add CLI support to manage accounts (list / modify / add accounts)
### 1.5.0 - UNRELEASED
- new Tag page!
- new Place page!
- new search flow
- new meta-tag-place / group / cohort page!
- allow footer links reordering
- new Docker image
- add GANCIO_DB_PORT environment
- merge old duplicated tags, trim
- add dynamic sitemap.xml !
- calendar attributes refactoring (a dot each day, colors represents n. events)
- fix event mime type response
### 1.4.4 - 10 may '22
- better img rendering, make it easier to download flyer #153
- avoid place and tags duplication (remove white space, match case insensitive)
- show date and place to unconfirmed events
- add warning when visiting from different hostname or protocol #149
- add tags and fix html description in ics export
- add git dependency in Dockerfile #148
- add external_style param to gancio-events webcomponent
- add GANCIO_HOST and GANCIO_PORT environment vars
- fix place and address when importing from url #147
- fix user account removal
- fix timezone issue #151
- fix scrolling behavior
- fix adding event on disabled anon posting
- fix plain description meta
- fix recurrent events always shown #150
- remove `less` and `less-loader` dependency
### 1.4.3 - 10 mar '22
- fix [#140](https://framagit.org/les/gancio/-/issues/140) - Invalid date
- fix [#141](https://framagit.org/les/gancio/-/issues/141) - Cannot change logo

View file

@ -0,0 +1,6 @@
/**
* https://nuxtjs.org/docs/configuration-glossary/configuration-router/#scrollbehavior
*/
export default function (to, _from, savedPosition) {
return { x: 0, y: 0 }
}

1262
assets/gancio-events.es.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,56 +1,39 @@
import take from 'lodash/take'
import get from 'lodash/get'
import dayjs from 'dayjs'
export function attributesFromEvents (_events, _tags) {
const colors = ['blue', 'orange', 'yellow', 'teal', 'indigo', 'green', 'red', 'purple', 'pink', 'gray']
const tags = take(_tags, 10).map(t => t.tag)
let attributes = []
attributes.push({ key: 'today', dates: new Date(), bar: { color: 'green', fillMode: 'outline' } })
const now = dayjs().unix()
export function attributesFromEvents (_events) {
function getColor (event, where) {
const color = { class: 'vc-rounded-full', color: 'blue', fillMode: where === 'base' ? 'light' : 'solid' }
const tag = get(event, 'tags[0]')
if (event.start_datetime < now) {
if (event.multidate) {
color.fillMode = where === 'base' ? 'light' : 'outline'
if (where === 'base') {
color.class += ' vc-past'
}
} else {
color.class += ' vc-past'
}
// const colors = ['teal', 'green', 'yellow', 'teal', 'indigo', 'green', 'red', 'purple', 'pink', 'gray']
// merge events with same date
let attributes = []
const now = dayjs().unix()
for(let e of _events) {
const key = dayjs.unix(e.start_datetime).format('YYYYMMDD')
const c = e.start_datetime < now ? 'vc-past' : ''
const i = attributes.find(a => a.day === key)
if (!i) {
attributes.push({ day: key, key: e.id, n: 1, dates: new Date(e.start_datetime * 1000),
dot: { color: 'teal', class: c } })
continue
}
if (!tag) { return color }
const idx = tags.indexOf(tag)
if (idx < 0) { return color }
color.color = colors[idx]
// if (event.start_datetime < now) { color.class += ' vc-past' }
return color
i.n++
if (i.n >= 20 ) {
i.dot = { color: 'purple', class: c }
} else if ( i.n >= 10 ) {
i.dot = { color: 'red', class: c}
} else if ( i.n >= 5 ) {
i.dot = { color: 'orange', class: c}
} else if ( i.n >= 3 ) {
i.dot = { color: 'yellow', class: c}
} else {
i.dot = { color: 'teal', class: c }
}
}
attributes = attributes.concat(_events
.filter(e => !e.multidate)
.map(e => {
return {
key: e.id,
dot: getColor(e),
dates: new Date(e.start_datetime * 1000)
}
}))
attributes = attributes.concat(_events
.filter(e => e.multidate)
.map(e => ({
key: e.id,
highlight: {
start: getColor(e),
base: getColor(e, 'base'),
end: getColor(e)
},
dates: { start: new Date(e.start_datetime * 1000), end: new Date(e.end_datetime * 1000) }
})))
// add a bar to highlight today
attributes.push({ key: 'today', dates: new Date(), highlight: { color: 'green', fillMode: 'outline' } })
return attributes
}

View file

@ -31,7 +31,8 @@ li {
}
#calh {
height: 292px;
/* this is to avoid content shift layout as v-calendar does not support SSR */
height: 268px;
}
.container {
@ -55,7 +56,6 @@ li {
scrollbar-color: #FF4511 #111;
}
// EVENT
.event {
display: flex;
position: relative;
@ -67,42 +67,32 @@ li {
margin-right: .4em;
transition: all .5s;
overflow: hidden;
}
.title {
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: 1.2em !important;
}
.event .title {
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: 1.2em !important;
text-decoration: none;
}
.body {
flex: 1 1 auto;
}
.event .body {
flex: 1 1 auto;
}
.img {
width: 100%;
max-height: 250px;
min-height: 160px;
object-fit: cover;
object-position: top;
aspect-ratio: 1.7778;
}
.event .place span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.place {
max-width: 100%;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
a {
text-decoration: none;
}
.event a {
text-decoration: none;
}
.vc-past {

View file

@ -1,8 +0,0 @@
// assets/variables.scss
// Variables you want to modify
// $btn-border-radius: 0px;
// If you need to extend Vuetify SASS lists
// $material-light: ( cards: blue );
@import '~vuetify/src/styles/styles.sass';

View file

@ -1,7 +1,7 @@
<template lang="pug">
nuxt-link(:to='`/announcement/${announcement.id}`')
v-alert.mb-1(border='left' type='info' color="primary" :icon='mdiInformation') {{announcement.title}}
<template>
<nuxt-link :to='`/announcement/${announcement.id}`'>
<v-alert class='mb-1' outlined type='info' color="primary" :icon='mdiInformation'>{{announcement.title}}</v-alert>
</nuxt-link>
</template>
<script>
import { mdiInformation } from '@mdi/js'

View file

@ -16,7 +16,7 @@
</template>
<script>
import { mapState, mapActions } from 'vuex'
import { mapState } from 'vuex'
import dayjs from 'dayjs'
import { attributesFromEvents } from '../assets/helper'
@ -26,25 +26,22 @@ export default {
events: { type: Array, default: () => [] }
},
data () {
const month = dayjs().month() + 1
const year = dayjs().year()
const month = dayjs.tz().month() + 1
const year = dayjs.tz().year()
return {
selectedDate: null,
page: { month, year }
}
},
computed: {
...mapState(['tags', 'filters', 'in_past', 'settings']),
...mapState(['settings']),
attributes () {
return attributesFromEvents(this.events, this.tags)
return attributesFromEvents(this.events)
}
},
methods: {
...mapActions(['updateEvents', 'showPastEvents']),
updatePage (page) {
return new Promise((resolve, reject) => {
this.$emit('monthchange', page)
})
},
click (day) {
this.$emit('dayclick', day)

45
components/Completed.vue Normal file
View file

@ -0,0 +1,45 @@
<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-alert.mb-3.mt-1(v-if='isHttp' outlined type='warning' color='red' show-icon :icon='mdiAlert') {{$t('setup.https_warning')}}
v-alert.mb-3.mt-1(outlined type='warning' color='red' show-icon :icon='mdiAlert') {{$t('setup.copy_password_dialog')}}
v-card-actions
v-btn(text @click='next' color='primary' :loading='loading' :disabled='loading') {{$t('setup.start')}}
v-icon(v-text='mdiArrowRight')
</template>
<script>
import { mdiArrowRight, mdiAlert } from '@mdi/js'
export default {
props: {
isHttp: { type: Boolean, default: false },
},
data () {
return {
mdiArrowRight, mdiAlert,
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>

View file

@ -34,16 +34,54 @@ v-col(cols=12)
v-row.mt-3.col-md-6.mx-auto
v-col.col-12.col-sm-6
v-select(dense :label="$t('event.from')" :value='fromHour' clearable
:disabled='!value.from'
:rules="[$validators.required('event.from')]"
:items='hourList' @change='hr => change("fromHour", hr)')
v-menu(
v-model="menuFromHour"
:close-on-content-click="false"
offset-y
:value="fromHour"
transition="scale-transition")
template(v-slot:activator="{ on, attrs }")
v-text-field(
:label="$t('event.from')"
:value="fromHour"
:disabled='!value.from'
:prepend-icon="mdiClockTimeFourOutline"
:rules="[$validators.required('event.from')]"
readonly
v-bind="attrs"
v-on="on")
v-time-picker(
v-if="menuFromHour"
:value="fromHour"
:allowedMinutes='allowedMinutes'
format='24hr'
@click:minute='menuFromHour=false'
@change='hr => change("fromHour", hr)')
v-col.col-12.col-sm-6
v-select(dense :label="$t('event.due')"
:disabled='!fromHour'
:value='dueHour' clearable
:items='hourList' @change='hr => change("dueHour", hr)')
v-menu(
v-model="menuDueHour"
:close-on-content-click="false"
offset-y
:value="dueHour"
transition="scale-transition")
template(v-slot:activator="{ on, attrs }")
v-text-field(
:label="$t('event.due')"
:value="dueHour"
:disabled='!fromHour'
:prepend-icon="mdiClockTimeEightOutline"
readonly
v-bind="attrs"
v-on="on")
v-time-picker(
v-if="menuDueHour"
:value="dueHour"
:allowedMinutes='allowedMinutes'
format='24hr'
@click:minute='menuDueHour=false'
@change='hr => change("dueHour", hr)')
List(v-if='type==="normal" && todayEvents.length' :events='todayEvents' :title='$t("event.same_day")')
@ -52,7 +90,8 @@ v-col(cols=12)
import dayjs from 'dayjs'
import { mapState } from 'vuex'
import List from '@/components/List'
import { attributesFromEvents } from '../../assets/helper'
import { attributesFromEvents } from '../assets/helper'
import { mdiClockTimeFourOutline, mdiClockTimeEightOutline } from '@mdi/js'
export default {
name: 'DateInput',
@ -63,6 +102,10 @@ export default {
},
data () {
return {
mdiClockTimeFourOutline, mdiClockTimeEightOutline,
allowedMinutes: [0, 15, 30, 45],
menuFromHour: false,
menuDueHour: false,
type: 'normal',
page: null,
events: [],
@ -74,15 +117,14 @@ export default {
}
},
computed: {
...mapState(['settings', 'tags']),
...mapState(['settings']),
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)
return events
return this.events.filter(e => e.start_datetime >= start && e.start_datetime <= end)
},
attributes () {
return attributesFromEvents(this.events, this.tags)
return attributesFromEvents(this.events)
},
fromDate () {
if (this.value.multidate) {
@ -92,21 +134,10 @@ export default {
},
fromHour () {
return this.value.from && this.value.fromHour ? dayjs(this.value.from).format('HH:mm') : null
return this.value.from && this.value.fromHour ? dayjs.tz(this.value.from).format('HH:mm') : null
},
dueHour () {
return this.value.due && this.value.dueHour ? dayjs(this.value.due).format('HH:mm') : null
},
hourList () {
const hourList = []
const leftPad = h => ('00' + h).slice(-2)
for (let h = 0; h < 24; h++) {
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
return this.value.due && this.value.dueHour ? dayjs.tz(this.value.due).format('HH:mm') : null
},
whenPatterns () {
if (!this.value.from) { return }
@ -196,7 +227,7 @@ export default {
} else if (what === 'fromHour') {
if (value) {
const [hour, minute] = value.split(':')
const from = dayjs(this.value.from).hour(hour).minute(minute).second(0)
const from = dayjs.tz(this.value.from).hour(hour).minute(minute).second(0)
this.$emit('input', { ...this.value, from, fromHour: true })
} else {
this.$emit('input', { ...this.value, fromHour: false })
@ -204,7 +235,7 @@ export default {
} else if (what === 'dueHour') {
if (value) {
const [hour, minute] = value.split(':')
const fromHour = dayjs(this.value.from).hour()
const fromHour = dayjs.tz(this.value.from).hour()
// add a day
let due = dayjs(this.value.from)
@ -226,20 +257,20 @@ export default {
let from = value.start
let due = value.end
if (this.value.fromHour) {
from = dayjs(value.start).hour(dayjs(this.value.from).hour())
from = dayjs.tz(value.start).hour(dayjs.tz(this.value.from).hour())
}
if (this.value.dueHour) {
due = dayjs(value.end).hour(dayjs(this.value.due).hour())
due = dayjs.tz(value.end).hour(dayjs.tz(this.value.due).hour())
}
this.$emit('input', { ...this.value, from, due })
} else {
let from = value
let due = this.value.due
if (this.value.fromHour) {
from = dayjs(value).hour(dayjs(this.value.from).hour())
from = dayjs.tz(value).hour(dayjs.tz(this.value.from).hour())
}
if (this.value.dueHour && this.value.due) {
due = dayjs(value).hour(dayjs(this.value.due).hour())
due = dayjs.tz(value).hour(dayjs.tz(this.value.due).hour())
}
this.$emit('input', { ...this.value, from, due })
}

View file

@ -176,7 +176,7 @@ export default {
}
}
</script>
<style lang='less'>
<style lang='scss'>
.editor {
margin-top: 4px;

View file

@ -1,23 +1,23 @@
<template lang="pug">
v-card.h-event.event.d-flex(itemscope itemtype="https://schema.org/Event")
nuxt-link(:to='`/event/${event.slug || event.id}`' itemprop="url")
img.img.u-featured(:src='thumbnail' :alt='alt' :loading='this.lazy?"lazy":"eager"' itemprop="image" :style="{ 'object-position': thumbnailPosition }")
MyPicture(:event='event' thumb :lazy='lazy')
v-icon.float-right.mr-1(v-if='event.parentId' color='success' v-text='mdiRepeat')
.title.p-name(itemprop="name") {{event.title}}
v-card-text.body.pt-0.pb-0
time.dt-start.subtitle-1(:datetime='event.start_datetime|unixFormat("YYYY-MM-DD HH:mm")' itemprop="startDate" :content="event.start_datetime|unixFormat('YYYY-MM-DDTHH:mm')") <v-icon v-text='mdiCalendar'></v-icon> {{ event|when }}
.d-none.dt-end(itemprop="endDate" :content="event.end_datetime|unixFormat('YYYY-MM-DDTHH:mm')") {{event.end_datetime|unixFormat('YYYY-MM-DD HH:mm')}}
a.place.d-block.p-location.pl-0(text color='primary' @click="$emit('placeclick', event.place.id)" itemprop="location" :content="event.place.name") <v-icon v-text='mdiMapMarker'></v-icon> {{event.place.name}}
nuxt-link.place.d-block.p-location.pl-0(text color='primary' :to='`/p/${event.place.name}`' itemprop="location" :content="event.place.name") <v-icon v-text='mdiMapMarker'></v-icon> {{event.place.name}}
.d-none(itemprop='location.address') {{event.place.address}}
v-card-actions.pt-0.actions.justify-space-between
.tags
v-chip.ml-1.mt-1(v-for='tag in event.tags.slice(0,6)' small
:key='tag' outlined color='primary' @click="$emit('tagclick', tag)") {{tag}}
v-chip.ml-1.mt-1(v-for='tag in event.tags.slice(0,6)' small :to='`/tag/${tag}`'
:key='tag' outlined color='primary') {{tag}}
client-only
v-menu(offset-y)
v-menu(offset-y eager)
template(v-slot:activator="{on}")
v-btn.align-self-end(icon v-on='on' color='primary' title='more' aria-label='more')
v-icon(v-text='mdiDotsVertical')
@ -50,6 +50,7 @@
<script>
import { mapState } from 'vuex'
import clipboard from '../assets/clipboard'
import MyPicture from '~/components/MyPicture'
import { mdiRepeat, mdiPencil, mdiDotsVertical, mdiContentCopy,
mdiCalendarExport, mdiDeleteForever, mdiCalendar, mdiMapMarker } from '@mdi/js'
@ -58,6 +59,9 @@ export default {
return { mdiRepeat, mdiPencil, mdiDotsVertical, mdiContentCopy, mdiCalendarExport,
mdiDeleteForever, mdiMapMarker, mdiCalendar }
},
components: {
MyPicture
},
props: {
event: { type: Object, default: () => ({}) },
lazy: Boolean
@ -65,25 +69,6 @@ export 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
@ -99,6 +84,8 @@ export default {
if (!ret) { return }
await this.$axios.delete(`/event/${this.event.id}`)
this.$emit('destroy', this.event.id)
this.$root.$message('admin.event_remove_ok')
}
}
}

View file

@ -45,7 +45,6 @@ export default {
event: {}
}
},
computed: mapState(['places']),
methods: {
importGeneric () {
if (this.file) {

View file

@ -61,12 +61,12 @@ export default {
}
}
</script>
<style lang='less'>
#list {
max-width: 500px;
margin: 0 auto;
.v-list-item__title {
white-space: normal !important;
}
}
<style>
#list {
max-width: 500px;
margin: 0 auto;
}
#list .v-list-item__title {
white-space: normal !important;
}
</style>

View file

@ -15,7 +15,7 @@
v-col.col-12.col-sm-4
p {{$t('event.choose_focal_point')}}
img.img.d-none.d-sm-block(v-if='mediaPreview'
img.mediaPreview.d-none.d-sm-block(v-if='mediaPreview'
:src='mediaPreview' :style="{ 'object-position': position }")
v-textarea.mt-4(type='text'
@ -35,7 +35,7 @@
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 }")
img.mediaPreview.col-12.ml-3(:src='mediaPreview' :style="{ 'object-position': savedPosition }")
span.float-right {{event.media[0].name}}
v-file-input(
v-else
@ -53,7 +53,7 @@ export default {
name: 'MediaInput',
props: {
value: { type: Object, default: () => ({ image: null }) },
event: { type: Object, default: () => {} }
event: { type: Object, default: () => ({}) }
},
data () {
return {
@ -142,7 +142,7 @@ export default {
cursor: crosshair;
}
.img {
.mediaPreview {
width: 100%;
object-fit: cover;
object-position: top;

97
components/MyPicture.vue Normal file
View file

@ -0,0 +1,97 @@
<template>
<div :class='{ img: true, thumb }'>
<img
v-if='media'
:class='{ "u-featured": true }'
:alt='media.name' :loading='lazy?"lazy":"eager"'
:src="src"
:srcset="srcset"
itemprop="image"
:height="height" :width="width"
:style="{ 'object-position': thumbnailPosition }">
<img v-else-if='!media && thumb' class='thumb' src="/noimg.svg" alt=''>
</div>
</template>
<script>
export default {
props: {
event: { type: Object, default: () => ({}) },
thumb: { type: Boolean, default: false },
lazy: { type: Boolean, default: false },
showPreview: { type: Boolean, default: true }
},
computed: {
backgroundPreview () {
if (this.media && this.media.preview) {
return {
backgroundPosition: this.thumbnailPosition,
backgroundImage: "url('data:image/png;base64," + this.media.preview + "')" }
}
},
srcset () {
if (this.thumb) return ''
return `/media/thumb/${this.media.url} 500w, /media/${this.media.url} 1200w`
},
media () {
return this.event.media && this.event.media[0]
},
height () {
return this.media ? this.media.height : 'auto'
},
width () {
return this.media ? this.media.width : 'auto'
},
src () {
if (this.media) {
return '/media/thumb/' + this.media.url
}
if (this.thumb) {
return '/noimg.svg'
}
},
thumbnailPosition () {
if (this.media.focalpoint) {
const focalpoint = this.media.focalpoint
return `${(focalpoint[0] + 1) * 50}% ${(focalpoint[1] + 1) * 50}%`
}
return 'center center'
},
}
}
</script>
<style>
.img {
width: 100%;
height: auto;
position: relative;
overflow: hidden;
display: flex;
background-size: contain;
}
.img img {
object-fit: contain;
max-height: 125vh;
display: flex;
width: 100%;
max-width: 100%;
height: auto;
overflow: hidden;
transition: opacity .5s;
opacity: 1;
background-size: 100%;
}
.img.thumb img {
display: flex;
max-height: 250px;
min-height: 160px;
object-fit: cover;
object-position: top;
aspect-ratio: 1.7778;
}
</style>

View file

@ -2,13 +2,13 @@
v-app-bar(app aria-label='Menu' height=64)
//- logo, title and description
v-list-item(:to='$route.name==="index"?"/about":"/"')
v-list-item-avatar(tile)
v-img(src='/logo.png' alt='home')
v-list-item-content.d-none.d-sm-flex
v-list-item-title
v-list-item.pa-0(:to='$route.name==="index"?"/about":"/"')
v-list-item-avatar.ma-xs-1(tile)
img(src='/logo.png' height='40')
v-list-item-content.d-flex
v-list-item-title.d-flex
h2 {{settings.title}}
v-list-item-subtitle {{settings.description}}
v-list-item-subtitle.d-none.d-sm-flex {{settings.description}}
v-spacer
v-btn(v-if='$auth.loggedIn || settings.allow_anon_event' icon nuxt to='/add' :aria-label='$t("common.add_event")' :title='$t("common.add_event")')
@ -21,7 +21,7 @@
v-icon(v-text='mdiLogin')
client-only
v-menu(v-if='$auth.loggedIn' offset-y)
v-menu(v-if='$auth.loggedIn' offset-y eager)
template(v-slot:activator="{ on, attrs }")
v-btn(icon v-bind='attrs' v-on='on' title='Menu' aria-label='Menu')
v-icon(v-text='mdiDotsVertical')
@ -48,7 +48,7 @@
v-icon(v-text='mdiDotsVertical')
v-btn(icon target='_blank' :href='feedLink' title='RSS' aria-label='RSS')
v-btn(icon target='_blank' :href='`${settings.baseurl}/feed/rss`' title='RSS' aria-label='RSS')
v-icon(color='orange' v-text='mdiRss')
</template>
@ -64,25 +64,7 @@ export default {
return { mdiPlus, mdiShareVariant, mdiLogout, mdiLogin, mdiDotsVertical, mdiAccount, mdiCog, mdiRss }
},
mixins: [clipboard],
computed: {
...mapState(['filters', 'settings']),
feedLink () {
const tags = this.filters.tags && this.filters.tags.map(encodeURIComponent).join(',')
const places = this.filters.places && this.filters.places.join(',')
let query = ''
if (tags || places) {
query = '?'
if (tags) {
query += 'tags=' + tags
if (places) { query += '&places=' + places }
} else {
query += 'places=' + places
}
}
return `${this.settings.baseurl}/feed/rss${query}`
},
},
computed: mapState(['settings']),
methods: {
logout () {
this.$root.$message('common.logout_ok')

View file

@ -1,98 +1,89 @@
<template lang="pug">
v-container.pt-0.pt-md-2
v-switch.mt-0(
v-if='recurrentFilter && settings.allow_recurrent_event'
v-model='showRecurrent'
inset color='primary'
hide-details
:label="$t('event.show_recurrent')")
v-autocomplete(
:label='$t("common.search")'
:items='keywords'
hide-details
@change='change'
:value='selectedFilters'
clearable
:search-input.sync='search'
item-text='label'
return-object
chips single-line
multiple)
template(v-slot:selection="data")
v-chip(v-bind="data.attrs"
close
:close-icon='mdiCloseCircle'
@click:close='remove(data.item)'
:input-value="data.selected")
v-avatar(left)
v-icon(v-text="data.item.type === 'place' ? mdiMapMarker : mdiTag")
span {{ data.item.label }}
template(v-slot:item='{ item }')
v-list-item-avatar
v-icon(v-text="item.type === 'place' ? mdiMapMarker : mdiTag")
v-list-item-content
v-list-item-title(v-text='item.label')
v-container.pt-0.pt-md-2
v-switch.mt-0(
v-if='settings.allow_recurrent_event'
v-model='showRecurrent'
inset color='primary'
hide-details
:label="$t('event.show_recurrent')")
v-autocomplete(
v-model='meta'
:label='$t("common.search")'
:filter='filter'
cache-items
hide-details
color='primary'
hide-selected
small-chips
:items='items'
@change='change'
hide-no-data
@input.native='search'
item-text='label'
return-object
chips
multiple)
template(v-slot:selection="{ attrs, item }")
v-chip(v-bind="attrs"
close
@click:close='remove(item)'
:close-icon='mdiCloseCircle')
v-avatar(left)
v-icon(v-text="item.type === 'place' ? mdiMapMarker : mdiTag")
span {{ item.label }}
template(v-slot:item='{ item }')
v-list-item-avatar
v-icon(v-text="item.type === 'place' ? mdiMapMarker : mdiTag")
v-list-item-content
v-list-item-title(v-text='item.label')
v-list-item-subtitle(v-if='item.type ==="place"' v-text='item.address')
</template>
<script>
import { mapState } from 'vuex'
import { mdiMapMarker, mdiTag, mdiCloseCircle } from '@mdi/js'
import debounce from 'lodash/debounce'
export default {
name: 'Search',
props: {
recurrentFilter: { type: Boolean, default: true },
filters: { type: Object, default: () => {} }
filters: { type: Object, default: () => ({}) }
},
data () {
return {
mdiTag, mdiMapMarker, mdiCloseCircle,
tmpfilter: null,
search: ''
meta: [],
items: [],
}
},
computed: {
...mapState(['tags', 'places', 'settings']),
...mapState(['settings']),
showRecurrent: {
get () {
return this.filters.show_recurrent
},
set (v) {
const filters = {
tags: this.filters.tags,
places: this.filters.places,
show_recurrent: v
}
this.$emit('update', filters)
this.change(v)
}
},
selectedFilters () {
const tags = this.tags.filter(t => this.filters.tags.includes(t.tag)).map(t => ({ type: 'tag', label: t.tag, weigth: t.weigth, id: t.tag }))
const places = this.places.filter(p => this.filters.places.includes(p.id))
.map(p => ({ type: 'place', label: p.name, weigth: p.weigth, id: p.id }))
const keywords = tags.concat(places).sort((a, b) => b.weigth - a.weigth)
return keywords
},
keywords () {
const tags = this.tags.map(t => ({ type: 'tag', label: t.tag, weigth: t.weigth, id: t.tag }))
const places = this.places.map(p => ({ type: 'place', label: p.name, weigth: p.weigth, id: p.id }))
const keywords = tags.concat(places).sort((a, b) => b.weigth - a.weigth)
return keywords
}
},
methods: {
remove (item) {
const filters = {
tags: item.type === 'tag' ? this.filters.tags.filter(f => f !== item.id) : this.filters.tags,
places: item.type === 'place' ? this.filters.places.filter(f => f !== item.id) : this.filters.places,
show_recurrent: this.filters.show_recurrent
}
this.$emit('update', filters)
filter (item, queryText, itemText) {
return itemText.toLocaleLowerCase().indexOf(queryText.toLocaleLowerCase()) > -1 ||
item.address && item.address.toLocaleLowerCase().indexOf(queryText.toLocaleLowerCase()) > -1
},
change (filters) {
filters = {
tags: filters.filter(t => t.type === 'tag').map(t => t.id),
places: filters.filter(p => p.type === 'place').map(p => p.id),
show_recurrent: this.filters.show_recurrent
search: debounce(async function(search) {
this.items = await this.$axios.$get(`/event/meta?search=${search.target.value}`)
}, 100),
remove (item) {
this.meta = this.meta.filter(m => m.type !== item.type || m.type === 'place' ? m.id !== item.id : m.tag !== item.tag)
this.change()
},
change (show_recurrent) {
const filters = {
tags: this.meta.filter(t => t.type === 'tag').map(t => t.label),
places: this.meta.filter(p => p.type === 'place').map(p => p.id),
show_recurrent: typeof show_recurrent !== 'undefined' ? show_recurrent : this.filters.show_recurrent
}
this.$emit('update', filters)
}

115
components/WhereInput.vue Normal file
View file

@ -0,0 +1,115 @@
<template lang="pug">
v-row
v-col(cols=12 md=6)
v-combobox(ref='place'
:rules="[$validators.required('common.where')]"
:label="$t('common.where')"
:hint="$t('event.where_description')"
:prepend-icon='mdiMapMarker'
no-filter
:value='value.name'
hide-no-data
@input.native='search'
persistent-hint
:items="places"
@change='selectPlace')
template(v-slot:item="{ item, attrs, on }")
v-list-item(v-bind='attrs' v-on='on')
v-list-item-content(two-line v-if='item.create')
v-list-item-title <v-icon color='primary' v-text='mdiPlus' :aria-label='$t("common.add")'></v-icon> {{item.name}}
v-list-item-content(two-line v-else)
v-list-item-title(v-text='item.name')
v-list-item-subtitle(v-text='item.address')
v-col(cols=12 md=6)
v-text-field(ref='address'
:prepend-icon='mdiMap'
:disabled='disableAddress'
:rules="[ v => disableAddress ? true : $validators.required('common.address')(v)]"
:label="$t('common.address')"
@change="changeAddress"
:value="value.address")
</template>
<script>
import { mdiMap, mdiMapMarker, mdiPlus } from '@mdi/js'
import debounce from 'lodash/debounce'
export default {
name: 'WhereInput',
props: {
value: { type: Object, default: () => ({}) }
},
data () {
return {
mdiMap, mdiMapMarker, mdiPlus,
place: { },
placeName: '',
places: [],
disableAddress: true
}
},
computed: {
filteredPlaces () {
if (!this.placeName) { return this.places }
const placeName = this.placeName.trim().toLowerCase()
let nameMatch = false
const matches = this.places.filter(p => {
const tmpName = p.name.toLowerCase()
const tmpAddress = p.address.toLowerCase()
if (tmpName.includes(placeName)) {
if (tmpName === placeName) { nameMatch = true }
return true
}
return tmpAddress.includes(placeName)
})
if (!nameMatch) {
matches.unshift({ create: true, name: this.placeName })
}
return matches
}
},
methods: {
search: debounce(async function(ev) {
const search = ev.target.value.trim().toLowerCase()
this.places = await this.$axios.$get(`place?search=${search}`)
if (!search) { return this.places }
const matches = this.places.find(p => search === p.name.toLocaleLowerCase())
if (!matches) {
this.places.unshift({ create: true, name: ev.target.value.trim() })
}
}, 100),
selectPlace (p) {
if (!p) { return }
if (typeof p === 'object' && !p.create) {
this.place.name = p.name.trim()
this.place.address = p.address
this.place.id = p.id
this.disableAddress = true
} else { // this is a new place
this.place.name = p.name || p
const tmpPlace = this.place.name.trim().toLocaleLowerCase()
// search for a place with the same name
const place = this.places.find(p => !p.create && p.name.trim().toLocaleLowerCase() === tmpPlace)
if (place) {
this.place.name = place.name
this.place.id = place.id
this.place.address = place.address
this.disableAddress = true
} else {
delete this.place.id
this.place.address = ''
this.disableAddress = false
this.$refs.place.blur()
this.$refs.address.focus()
}
}
this.$emit('input', { ...this.place })
},
changeAddress (v) {
this.place.address = v
this.$emit('input', { ...this.place })
}
}
}
</script>

View file

@ -0,0 +1,207 @@
<template lang='pug'>
v-container
v-card-title {{$t('common.cohort')}}
v-spacer
v-text-field(v-model='search'
:append-icon='mdiMagnify' outlined rounded
label='Search'
single-line hide-details)
v-card-subtitle(v-html="$t('admin.cohort_description')")
v-btn(color='primary' text @click='newCohort') <v-icon v-text='mdiPlus'></v-icon> {{$t('common.new')}}
v-dialog(v-model='dialog' width='800' destroy-on-close :fullscreen='$vuetify.breakpoint.xsOnly')
v-card(color='secondary')
v-card-title {{$t('admin.edit_cohort')}}
v-card-text
v-form(v-model='valid' ref='form')
v-text-field(
v-if='!cohort.id'
:rules="[$validators.required('common.name')]"
:label="$t('common.name')"
v-model='cohort.name'
:placeholder='$t("common.name")')
template(v-slot:append-outer v-if='!cohort.id')
v-btn(text @click='saveCohort' color='primary' :loading='loading'
:disabled='!valid || loading || !!cohort.id') {{$t('common.save')}}
h3(v-else class='text-h5' v-text='cohort.name')
v-row
v-col(cols=5)
v-autocomplete(v-model='filterTags'
cache-items
:prepend-icon="mdiTagMultiple"
chips small-chips multiple deletable-chips hide-no-data hide-selected persistent-hint
:disabled="!cohort.id"
placeholder='Tutte'
@input.native='searchTags'
:delimiters="[',', ';']"
:items="tags"
:label="$t('common.tags')")
v-col(cols=5)
v-autocomplete(v-model='filterPlaces'
cache-items
:prepend-icon="mdiMapMarker"
chips small-chips multiple deletable-chips hide-no-data hide-selected persistent-hint
auto-select-first
clearable
return-object
item-text='name'
:disabled="!cohort.id"
@input.native="searchPlaces"
:delimiters="[',', ';']"
:items="places"
:label="$t('common.places')")
//- template(v-slot:item="{ item, attrs, on }")
//- v-list-item(v-bind='attrs' v-on='on')
//- v-list-item-content(two-line)
//- v-list-item-title(v-text='item.name')
//- v-list-item-subtitle(v-text='item.address')
v-col(cols=2)
v-btn(color='primary' text @click='addFilter' :disabled='!cohort.id || !filterPlaces.length && !filterTags.length') add <v-icon v-text='mdiPlus'></v-icon>
v-data-table(
:headers='filterHeaders'
:items='filters'
:hide-default-footer='filters.length<5'
:footer-props='{ prevIcon: mdiChevronLeft, nextIcon: mdiChevronRight }')
template(v-slot:item.actions='{item}')
v-btn(@click='removeFilter(item)' color='error' icon)
v-icon(v-text='mdiDeleteForever')
template(v-slot:item.tags='{item}')
v-chip.ma-1(small v-for='tag in item.tags' v-text='tag' :key='tag')
template(v-slot:item.places='{item}')
v-chip.ma-1(small v-for='place in item.places' v-text='place.name' :key='place.id' )
v-card-actions
v-spacer
v-btn(text @click='dialog=false' color='warning') {{$t('common.close')}}
//- v-btn(text @click='saveCohort' color='primary' :loading='loading'
//- :disable='!valid || loading') {{$t('common.save')}}
v-card-text
v-data-table(
:headers='cohortHeaders'
:items='cohorts'
:hide-default-footer='cohorts.length<5'
:footer-props='{ prevIcon: mdiChevronLeft, nextIcon: mdiChevronRight }'
:search='search')
template(v-slot:item.filters='{item}')
span {{cohortFilters(item)}}
template(v-slot:item.actions='{item}')
v-btn(@click='editCohort(item)' color='primary' icon)
v-icon(v-text='mdiPencil')
v-btn(@click='removeCohort(item)' color='error' icon)
v-icon(v-text='mdiDeleteForever')
</template>
<script>
import get from 'lodash/get'
import debounce from 'lodash/debounce'
import { mdiPencil, mdiChevronLeft, mdiChevronRight, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, mdiCloseCircle } from '@mdi/js'
export default {
data () {
return {
mdiPencil, mdiChevronRight, mdiChevronLeft, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, mdiCloseCircle,
loading: false,
dialog: false,
valid: false,
search: '',
cohort: { name: '', id: null },
filterTags: [],
filterPlaces: [],
tags: [],
places: [],
cohorts: [],
filters: [],
tagName: '',
placeName: '',
cohortHeaders: [
{ value: 'name', text: 'Name' },
{ value: 'filters', text: 'Filters' },
{ value: 'actions', text: 'Actions', align: 'right' }
],
filterHeaders: [
{ value: 'tags', text: 'Tags' },
{ value: 'places', text: 'Places' },
{ value: 'actions', text: 'Actions', align: 'right' }
]
}
},
async fetch () {
this.cohorts = await this.$axios.$get('/cohorts?withFilters=true')
},
methods: {
searchTags: debounce(async function (ev) {
this.tags = await this.$axios.$get(`/tag?search=${ev.target.value}`)
}, 100),
searchPlaces: debounce(async function (ev) {
this.places = await this.$axios.$get(`/place?search=${ev.target.value}`)
}, 100),
cohortFilters (cohort) {
return cohort.filters.map(f => {
return '(' + f.tags?.join(', ') + f.places?.map(p => p.name).join(', ') + ')'
}).join(' - ')
},
async addFilter () {
this.loading = true
const tags = this.filterTags
const places = this.filterPlaces.map(p => ({ id: p.id, name: p.name }))
const filter = await this.$axios.$post('/filter', { cohortId: this.cohort.id, tags, places })
this.$fetch()
this.filters.push(filter)
this.filterTags = []
this.filterPlaces = []
this.loading = false
},
async editCohort (cohort) {
this.cohort = { ...cohort }
this.filters = await this.$axios.$get(`/filter/${cohort.id}`)
this.dialog = true
},
newCohort () {
this.cohort = { name: '', id: null },
this.filters = []
this.dialog = true
},
async saveCohort () {
if (!this.$refs.form.validate()) return
this.loading = true
this.cohort = await this.$axios.$post('/cohorts', this.cohort)
this.$fetch()
this.loading = false
},
async removeFilter(filter) {
try {
await this.$axios.$delete(`/filter/${filter.id}`)
this.filters = this.filters.filter(f => f.id !== filter.id)
this.$fetch()
} catch (e) {
const err = get(e, 'response.data.errors[0].message', e)
this.$root.$message(this.$t(err), { color: 'error' })
this.loading = false
}
},
async removeCohort (cohort) {
const ret = await this.$root.$confirm('admin.delete_cohort_confirm', { cohort: cohort.name })
if (!ret) { return }
try {
await this.$axios.$delete(`/cohort/${cohort.id}`)
this.cohorts = this.cohorts.filter(c => c.id !== cohort.id)
} catch (e) {
const err = get(e, 'response.data.errors[0].message', e)
this.$root.$message(this.$t(err), { color: 'error' })
this.loading = false
}
}
}
}
</script>

View file

@ -8,6 +8,7 @@
:footer-props='{ prevIcon: mdiChevronLeft, nextIcon: mdiChevronRight }'
:items='unconfirmedEvents'
:headers='headers')
template(v-slot:item.when='{ item }') {{item|when}}
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.slug || item.id}`' color='success') {{$t('common.preview')}}
@ -31,6 +32,8 @@ export default {
editing: false,
headers: [
{ value: 'title', text: 'Title' },
{ value: 'place.name', text: 'Place' },
{ value: 'when', text: 'When' },
{ value: 'actions', text: 'Actions', align: 'right' }
]
}

View file

@ -126,6 +126,7 @@ export default {
if (!this.instance_url.startsWith('http')) {
this.instance_url = `https://${this.instance_url}`
}
this.instance_url = this.instance_url.replace(/\/$/, '')
const instance = await axios.get(`${this.instance_url}/.well-known/nodeinfo/2.1`)
this.setSetting({
key: 'trusted_instances',

View file

@ -41,18 +41,21 @@
template(v-slot:item.actions='{item}')
v-btn(@click='editPlace(item)' color='primary' icon)
v-icon(v-text='mdiPencil')
nuxt-link(:to='`/p/${item.name}`')
v-icon(v-text='mdiEye')
</template>
<script>
import { mapState, mapActions } from 'vuex'
import { mdiPencil, mdiChevronLeft, mdiChevronRight } from '@mdi/js'
import { mdiPencil, mdiChevronLeft, mdiChevronRight, mdiMagnify, mdiEye } from '@mdi/js'
export default {
data () {
return {
mdiPencil, mdiChevronRight, mdiChevronLeft,
mdiPencil, mdiChevronRight, mdiChevronLeft, mdiMagnify, mdiEye,
loading: false,
dialog: false,
valid: false,
places: [],
search: '',
place: { name: '', address: '', id: null },
headers: [
@ -62,9 +65,10 @@ export default {
]
}
},
computed: mapState(['places']),
async fetch () {
this.places = await this.$axios.$get('/place/all')
},
methods: {
...mapActions(['updateMeta']),
editPlace (item) {
this.place.name = item.name
this.place.address = item.address

View file

@ -9,7 +9,7 @@
accept='image/*')
template(slot='append-outer')
v-btn(color='warning' text @click='resetLogo') <v-icon v-text='mdiRestore'></v-icon> {{$t('common.reset')}}
v-img(:src='`${settings.baseurl}/logo.png?${logoKey}`'
v-img(:src='`/logo.png?${logoKey}`'
max-width="60px" max-height="60px" contain)
v-switch.mt-5(v-model='is_dark'
@ -54,25 +54,27 @@
v-btn(color='warning' text @click='reset') <v-icon v-text='mdiRestore'></v-icon> {{$t('common.reset')}}
v-card
v-list.mt-1(two-line subheader)
v-list-item(v-for='link in settings.footerLinks'
v-list-item(v-for='(link, idx) 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-btn.left(v-if='idx !== 0' icon color='warn' @click.stop='moveUpFooterLink(link, idx)')
v-icon(v-text='mdiChevronUp')
v-btn.float-right(icon color='error' @click.stop='removeFooterLink(link)')
v-icon(v-text='mdiDeleteForever')
</template>
<script>
import { mapActions, mapState } from 'vuex'
import { mdiDeleteForever, mdiRestore, mdiPlus } from '@mdi/js'
import { mdiDeleteForever, mdiRestore, mdiPlus, mdiChevronUp } from '@mdi/js'
export default {
name: 'Theme',
data () {
return {
mdiDeleteForever, mdiRestore, mdiPlus,
mdiDeleteForever, mdiRestore, mdiPlus, mdiChevronUp,
valid: false,
logoKey: 0,
link: { href: '', label: '' },
@ -152,6 +154,12 @@ export default {
const footerLinks = this.settings.footerLinks.filter(l => l.label !== item.label)
this.setSetting({ key: 'footerLinks', value: footerLinks })
},
async moveUpFooterLink (item, idx) {
const footerLinks = [...this.settings.footerLinks]
footerLinks[idx] = footerLinks[idx-1]
footerLinks[idx-1] = this.settings.footerLinks[idx]
this.setSetting({ key: 'footerLinks', value: footerLinks })
},
editFooterLink (item) {
this.link = { href: item.href, label: item.label }
this.linkModal = true

View file

@ -16,7 +16,7 @@ v-card
</template>
<script>
import { mapState } from 'vuex'
import clipboard from '../../assets/clipboard'
import clipboard from '../assets/clipboard'
import { mdiContentCopy, mdiInformation } from '@mdi/js'
export default {

View file

@ -37,3 +37,4 @@ end
# Performance-booster for watching directories on Windows
gem "wdm", "~> 0.1.0", :install_if => Gem.win_platform?
gem "webrick", "~> 1.7"

View file

@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (6.0.4.7)
activesupport (6.0.4.8)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -10,7 +10,7 @@ GEM
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
colorator (1.1.0)
concurrent-ruby (1.1.9)
concurrent-ruby (1.1.10)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
@ -18,7 +18,7 @@ GEM
ffi (1.15.5)
forwardable-extended (2.6.0)
gemoji (3.0.1)
html-pipeline (2.14.0)
html-pipeline (2.14.1)
activesupport (>= 2)
nokogiri (>= 1.4)
http_parser.rb (0.8.0)
@ -57,7 +57,7 @@ GEM
jekyll (>= 3.8.5)
jekyll-seo-tag (~> 2.0)
rake (>= 12.3.1, < 13.1.0)
kramdown (2.3.1)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
@ -68,13 +68,13 @@ GEM
mercenary (0.4.0)
mini_magick (4.11.0)
minitest (5.15.0)
nokogiri (1.13.3-x86_64-linux)
nokogiri (1.13.6-x86_64-linux)
racc (~> 1.4)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
premonition (4.0.2)
jekyll (>= 3.7, < 5.0)
public_suffix (4.0.6)
public_suffix (4.0.7)
racc (1.6.0)
rake (13.0.6)
rb-fsevent (0.11.1)
@ -90,10 +90,11 @@ GEM
thread_safe (0.3.6)
tzinfo (1.2.9)
thread_safe (~> 0.1)
tzinfo-data (1.2021.5)
tzinfo-data (1.2022.1)
tzinfo (>= 1.0.0)
unicode-display_width (1.8.0)
wdm (0.1.1)
webrick (1.7.0)
zeitwerk (2.5.4)
PLATFORMS
@ -110,6 +111,7 @@ DEPENDENCIES
tzinfo (~> 1.2)
tzinfo-data
wdm (~> 0.1.0)
webrick (~> 1.7)
BUNDLED WITH
2.2.27

View file

@ -1,16 +1,38 @@
---
layout: default
title: Admin
permalink: /admin
nav_order: 5
permalink: /usage/admin
nav_order: 1
parent: Usage
---
# Admin
{: .no_toc }
### CLI
#### Manage accounts
```bash
$ gancio accounts
Manage accounts
Commands:
gancio accounts list List all accounts
```
#### List accounts
```
$ gancio accounts list
📅 gancio - v1.4.3 - A shared agenda for local communities (nodejs: v16.13.0)
> Reading configuration from: ./config.json
1 admin: true enabled: true email: admin
2 admin: false enabled: true email: lesion@autistici.org
```
1. TOC
{:toc}
## Basics
## Add user

View file

@ -7,6 +7,12 @@
<meta name="Description" content="{{ page.description }}">
{% endif %}
<link href="https://github.com/lesion" rel="me">
<link href="eventi@cisti.org" rel="me">
<link rel="webmention" href="https://webmention.io/gancio.org/webmention" />
<link rel="pingback" href="https://webmention.io/gancio.org/xmlrpc" />
<link rel="shortcut icon" href="{{ '/favicon.ico' | absolute_url }}" type="image/x-icon">
<link rel="stylesheet" href="{{ '/assets/css/just-the-docs-default.css' | absolute_url }}">
<link rel="stylesheet" href="{{ '/assets/css/premonition.css' | absolute_url }}">

View file

@ -4,7 +4,7 @@ function run(fn) {
return fn();
}
function blank_object() {
return Object.create(null);
return /* @__PURE__ */ Object.create(null);
}
function run_all(fns) {
fns.forEach(run);
@ -104,7 +104,7 @@ function schedule_update() {
function add_render_callback(fn) {
render_callbacks.push(fn);
}
const seen_callbacks = new Set();
const seen_callbacks = /* @__PURE__ */ new Set();
let flushidx = 0;
function flush() {
const saved_component = current_component;
@ -146,7 +146,7 @@ function update($$) {
$$.after_update.forEach(add_render_callback);
}
}
const outroing = new Set();
const outroing = /* @__PURE__ */ new Set();
function transition_in(block, local) {
if (block && block.i) {
outroing.delete(block);
@ -282,19 +282,41 @@ if (typeof HTMLElement === "function") {
}
function get_each_context(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[11] = list[i];
child_ctx[12] = list[i];
return child_ctx;
}
function get_each_context_1(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[14] = list[i];
child_ctx[15] = list[i];
return child_ctx;
}
function create_if_block_5(ctx) {
let link;
return {
c() {
link = element("link");
attr(link, "rel", "stylesheet");
attr(link, "href", ctx[4]);
},
m(target, anchor) {
insert(target, link, anchor);
},
p(ctx2, dirty) {
if (dirty & 16) {
attr(link, "href", ctx2[4]);
}
},
d(detaching) {
if (detaching)
detach(link);
}
};
}
function create_if_block$1(ctx) {
let div;
let t;
let if_block = ctx[1] && ctx[3] === "true" && create_if_block_4(ctx);
let each_value = ctx[4];
let each_value = ctx[5];
let each_blocks = [];
for (let i = 0; i < each_value.length; i += 1) {
each_blocks[i] = create_each_block(get_each_context(ctx, each_value, i));
@ -336,8 +358,8 @@ function create_if_block$1(ctx) {
if_block.d(1);
if_block = null;
}
if (dirty & 25) {
each_value = ctx2[4];
if (dirty & 41) {
each_value = ctx2[5];
let i;
for (i = 0; i < each_value.length; i += 1) {
const child_ctx = get_each_context(ctx2, each_value, i);
@ -395,7 +417,7 @@ function create_if_block_4(ctx) {
attr(div0, "class", "title");
attr(img, "id", "logo");
attr(img, "alt", "logo");
if (!src_url_equal(img.src, img_src_value = "" + (ctx[0] + "/logo.png")))
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/logo.png"))
attr(img, "src", img_src_value);
attr(div1, "class", "content");
attr(a, "href", ctx[0]);
@ -413,7 +435,7 @@ function create_if_block_4(ctx) {
p(ctx2, dirty) {
if (dirty & 2)
set_data(t0, ctx2[1]);
if (dirty & 1 && !src_url_equal(img.src, img_src_value = "" + (ctx2[0] + "/logo.png"))) {
if (dirty & 1 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/logo.png")) {
attr(img, "src", img_src_value);
}
if (dirty & 1) {
@ -429,7 +451,7 @@ function create_if_block_4(ctx) {
function create_if_block_2(ctx) {
let div;
function select_block_type(ctx2, dirty) {
if (ctx2[11].media.length)
if (ctx2[12].media.length)
return create_if_block_3;
return create_else_block;
}
@ -472,7 +494,7 @@ function create_else_block(ctx) {
c() {
img = element("img");
attr(img, "style", "aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[11].title);
attr(img, "alt", img_alt_value = ctx[12].title);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/noimg.svg"))
attr(img, "src", img_src_value);
attr(img, "loading", "lazy");
@ -481,7 +503,7 @@ function create_else_block(ctx) {
insert(target, img, anchor);
},
p(ctx2, dirty) {
if (dirty & 16 && img_alt_value !== (img_alt_value = ctx2[11].title)) {
if (dirty & 32 && img_alt_value !== (img_alt_value = ctx2[12].title)) {
attr(img, "alt", img_alt_value);
}
if (dirty & 1 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/noimg.svg")) {
@ -502,9 +524,9 @@ function create_if_block_3(ctx) {
return {
c() {
img = element("img");
attr(img, "style", img_style_value = "object-position: " + position$1(ctx[11]) + "; aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[11].media[0].name);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/media/thumb/" + ctx[11].media[0].url))
attr(img, "style", img_style_value = "object-position: " + position$1(ctx[12]) + "; aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[12].media[0].name);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/media/thumb/" + ctx[12].media[0].url))
attr(img, "src", img_src_value);
attr(img, "loading", "lazy");
},
@ -512,13 +534,13 @@ function create_if_block_3(ctx) {
insert(target, img, anchor);
},
p(ctx2, dirty) {
if (dirty & 16 && img_style_value !== (img_style_value = "object-position: " + position$1(ctx2[11]) + "; aspect-ratio=1.7778;")) {
if (dirty & 32 && img_style_value !== (img_style_value = "object-position: " + position$1(ctx2[12]) + "; aspect-ratio=1.7778;")) {
attr(img, "style", img_style_value);
}
if (dirty & 16 && img_alt_value !== (img_alt_value = ctx2[11].media[0].name)) {
if (dirty & 32 && img_alt_value !== (img_alt_value = ctx2[12].media[0].name)) {
attr(img, "alt", img_alt_value);
}
if (dirty & 17 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/media/thumb/" + ctx2[11].media[0].url)) {
if (dirty & 33 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/media/thumb/" + ctx2[12].media[0].url)) {
attr(img, "src", img_src_value);
}
},
@ -530,7 +552,7 @@ function create_if_block_3(ctx) {
}
function create_if_block_1$1(ctx) {
let div;
let each_value_1 = ctx[11].tags;
let each_value_1 = ctx[12].tags;
let each_blocks = [];
for (let i = 0; i < each_value_1.length; i += 1) {
each_blocks[i] = create_each_block_1(get_each_context_1(ctx, each_value_1, i));
@ -550,8 +572,8 @@ function create_if_block_1$1(ctx) {
}
},
p(ctx2, dirty) {
if (dirty & 16) {
each_value_1 = ctx2[11].tags;
if (dirty & 32) {
each_value_1 = ctx2[12].tags;
let i;
for (i = 0; i < each_value_1.length; i += 1) {
const child_ctx = get_each_context_1(ctx2, each_value_1, i);
@ -579,7 +601,7 @@ function create_if_block_1$1(ctx) {
function create_each_block_1(ctx) {
let span;
let t0;
let t1_value = ctx[14] + "";
let t1_value = ctx[15] + "";
let t1;
return {
c() {
@ -594,7 +616,7 @@ function create_each_block_1(ctx) {
append(span, t1);
},
p(ctx2, dirty) {
if (dirty & 16 && t1_value !== (t1_value = ctx2[14] + ""))
if (dirty & 32 && t1_value !== (t1_value = ctx2[15] + ""))
set_data(t1, t1_value);
},
d(detaching) {
@ -608,27 +630,27 @@ function create_each_block(ctx) {
let t0;
let div2;
let div0;
let t1_value = when$1(ctx[11].start_datetime) + "";
let t1_value = when$1(ctx[12].start_datetime) + "";
let t1;
let t2;
let div1;
let t3_value = ctx[11].title + "";
let t3_value = ctx[12].title + "";
let t3;
let t4;
let span1;
let t5;
let t6_value = ctx[11].place.name + "";
let t6_value = ctx[12].place.name + "";
let t6;
let t7;
let span0;
let t8_value = ctx[11].place.address + "";
let t8_value = ctx[12].place.address + "";
let t8;
let t9;
let t10;
let a_href_value;
let a_title_value;
let if_block0 = ctx[3] !== "true" && create_if_block_2(ctx);
let if_block1 = ctx[11].tags.length && create_if_block_1$1(ctx);
let if_block1 = ctx[12].tags.length && create_if_block_1$1(ctx);
return {
c() {
a = element("a");
@ -657,9 +679,9 @@ function create_each_block(ctx) {
attr(span0, "class", "subtitle");
attr(span1, "class", "place");
attr(div2, "class", "content");
attr(a, "href", a_href_value = "" + (ctx[0] + "/event/" + (ctx[11].slug || ctx[11].id)));
attr(a, "href", a_href_value = ctx[0] + "/event/" + (ctx[12].slug || ctx[12].id));
attr(a, "class", "event");
attr(a, "title", a_title_value = ctx[11].title);
attr(a, "title", a_title_value = ctx[12].title);
attr(a, "target", "_blank");
},
m(target, anchor) {
@ -698,15 +720,15 @@ function create_each_block(ctx) {
if_block0.d(1);
if_block0 = null;
}
if (dirty & 16 && t1_value !== (t1_value = when$1(ctx2[11].start_datetime) + ""))
if (dirty & 32 && t1_value !== (t1_value = when$1(ctx2[12].start_datetime) + ""))
set_data(t1, t1_value);
if (dirty & 16 && t3_value !== (t3_value = ctx2[11].title + ""))
if (dirty & 32 && t3_value !== (t3_value = ctx2[12].title + ""))
set_data(t3, t3_value);
if (dirty & 16 && t6_value !== (t6_value = ctx2[11].place.name + ""))
if (dirty & 32 && t6_value !== (t6_value = ctx2[12].place.name + ""))
set_data(t6, t6_value);
if (dirty & 16 && t8_value !== (t8_value = ctx2[11].place.address + ""))
if (dirty & 32 && t8_value !== (t8_value = ctx2[12].place.address + ""))
set_data(t8, t8_value);
if (ctx2[11].tags.length) {
if (ctx2[12].tags.length) {
if (if_block1) {
if_block1.p(ctx2, dirty);
} else {
@ -718,10 +740,10 @@ function create_each_block(ctx) {
if_block1.d(1);
if_block1 = null;
}
if (dirty & 17 && a_href_value !== (a_href_value = "" + (ctx2[0] + "/event/" + (ctx2[11].slug || ctx2[11].id)))) {
if (dirty & 33 && a_href_value !== (a_href_value = ctx2[0] + "/event/" + (ctx2[12].slug || ctx2[12].id))) {
attr(a, "href", a_href_value);
}
if (dirty & 16 && a_title_value !== (a_title_value = ctx2[11].title)) {
if (dirty & 32 && a_title_value !== (a_title_value = ctx2[12].title)) {
attr(a, "title", a_title_value);
}
},
@ -736,41 +758,65 @@ function create_each_block(ctx) {
};
}
function create_fragment$1(ctx) {
let if_block_anchor;
let if_block = ctx[4].length && create_if_block$1(ctx);
let t;
let if_block1_anchor;
let if_block0 = ctx[4] && create_if_block_5(ctx);
let if_block1 = ctx[5].length && create_if_block$1(ctx);
return {
c() {
if (if_block)
if_block.c();
if_block_anchor = empty();
if (if_block0)
if_block0.c();
t = space();
if (if_block1)
if_block1.c();
if_block1_anchor = empty();
this.c = noop;
},
m(target, anchor) {
if (if_block)
if_block.m(target, anchor);
insert(target, if_block_anchor, anchor);
if (if_block0)
if_block0.m(target, anchor);
insert(target, t, anchor);
if (if_block1)
if_block1.m(target, anchor);
insert(target, if_block1_anchor, anchor);
},
p(ctx2, [dirty]) {
if (ctx2[4].length) {
if (if_block) {
if_block.p(ctx2, dirty);
if (ctx2[4]) {
if (if_block0) {
if_block0.p(ctx2, dirty);
} else {
if_block = create_if_block$1(ctx2);
if_block.c();
if_block.m(if_block_anchor.parentNode, if_block_anchor);
if_block0 = create_if_block_5(ctx2);
if_block0.c();
if_block0.m(t.parentNode, t);
}
} else if (if_block) {
if_block.d(1);
if_block = null;
} else if (if_block0) {
if_block0.d(1);
if_block0 = null;
}
if (ctx2[5].length) {
if (if_block1) {
if_block1.p(ctx2, dirty);
} else {
if_block1 = create_if_block$1(ctx2);
if_block1.c();
if_block1.m(if_block1_anchor.parentNode, if_block1_anchor);
}
} else if (if_block1) {
if_block1.d(1);
if_block1 = null;
}
},
i: noop,
o: noop,
d(detaching) {
if (if_block)
if_block.d(detaching);
if (if_block0)
if_block0.d(detaching);
if (detaching)
detach(if_block_anchor);
detach(t);
if (if_block1)
if_block1.d(detaching);
if (detaching)
detach(if_block1_anchor);
}
};
}
@ -799,6 +845,7 @@ function instance$1($$self, $$props, $$invalidate) {
let { theme = "light" } = $$props;
let { show_recurrent = false } = $$props;
let { sidebar = "true" } = $$props;
let { external_style = "" } = $$props;
let mounted = false;
let events = [];
function update2(v) {
@ -814,11 +861,9 @@ function instance$1($$self, $$props, $$invalidate) {
if (places) {
params.push(`places=${places}`);
}
if (show_recurrent) {
params.push(`show_recurrent=true`);
}
params.push(`show_recurrent=${show_recurrent ? "true" : "false"}`);
fetch(`${baseurl}/api/events?${params.join("&")}`).then((res) => res.json()).then((e) => {
$$invalidate(4, events = e);
$$invalidate(5, events = e);
}).catch((e) => {
console.error("Error loading Gancio API -> ", e);
});
@ -833,20 +878,22 @@ function instance$1($$self, $$props, $$invalidate) {
if ("title" in $$props2)
$$invalidate(1, title = $$props2.title);
if ("maxlength" in $$props2)
$$invalidate(5, maxlength = $$props2.maxlength);
$$invalidate(6, maxlength = $$props2.maxlength);
if ("tags" in $$props2)
$$invalidate(6, tags = $$props2.tags);
$$invalidate(7, tags = $$props2.tags);
if ("places" in $$props2)
$$invalidate(7, places = $$props2.places);
$$invalidate(8, places = $$props2.places);
if ("theme" in $$props2)
$$invalidate(2, theme = $$props2.theme);
if ("show_recurrent" in $$props2)
$$invalidate(8, show_recurrent = $$props2.show_recurrent);
$$invalidate(9, show_recurrent = $$props2.show_recurrent);
if ("sidebar" in $$props2)
$$invalidate(3, sidebar = $$props2.sidebar);
if ("external_style" in $$props2)
$$invalidate(4, external_style = $$props2.external_style);
};
$$self.$$.update = () => {
if ($$self.$$.dirty & 494) {
if ($$self.$$.dirty & 974) {
update2();
}
};
@ -855,6 +902,7 @@ function instance$1($$self, $$props, $$invalidate) {
title,
theme,
sidebar,
external_style,
events,
maxlength,
tags,
@ -873,12 +921,13 @@ class GancioEvents extends SvelteElement {
}, instance$1, create_fragment$1, safe_not_equal, {
baseurl: 0,
title: 1,
maxlength: 5,
tags: 6,
places: 7,
maxlength: 6,
tags: 7,
places: 8,
theme: 2,
show_recurrent: 8,
sidebar: 3
show_recurrent: 9,
sidebar: 3,
external_style: 4
}, null);
if (options) {
if (options.target) {
@ -899,7 +948,8 @@ class GancioEvents extends SvelteElement {
"places",
"theme",
"show_recurrent",
"sidebar"
"sidebar",
"external_style"
];
}
get baseurl() {
@ -917,21 +967,21 @@ class GancioEvents extends SvelteElement {
flush();
}
get maxlength() {
return this.$$.ctx[5];
return this.$$.ctx[6];
}
set maxlength(maxlength) {
this.$$set({ maxlength });
flush();
}
get tags() {
return this.$$.ctx[6];
return this.$$.ctx[7];
}
set tags(tags) {
this.$$set({ tags });
flush();
}
get places() {
return this.$$.ctx[7];
return this.$$.ctx[8];
}
set places(places) {
this.$$set({ places });
@ -945,7 +995,7 @@ class GancioEvents extends SvelteElement {
flush();
}
get show_recurrent() {
return this.$$.ctx[8];
return this.$$.ctx[9];
}
set show_recurrent(show_recurrent) {
this.$$set({ show_recurrent });
@ -958,6 +1008,13 @@ class GancioEvents extends SvelteElement {
this.$$set({ sidebar });
flush();
}
get external_style() {
return this.$$.ctx[4];
}
set external_style(external_style) {
this.$$set({ external_style });
flush();
}
}
customElements.define("gancio-events", GancioEvents);
function create_if_block(ctx) {
@ -996,7 +1053,7 @@ function create_if_block(ctx) {
t6 = text(t6_value);
attr(div1, "class", "place");
attr(div2, "class", "container");
attr(a, "href", a_href_value = "" + (ctx[0] + "/event/" + (ctx[1].slug || ctx[1].id)));
attr(a, "href", a_href_value = ctx[0] + "/event/" + (ctx[1].slug || ctx[1].id));
attr(a, "class", "card");
attr(a, "target", "_blank");
},
@ -1035,7 +1092,7 @@ function create_if_block(ctx) {
set_data(t3, t3_value);
if (dirty & 2 && t6_value !== (t6_value = ctx2[1].place.name + ""))
set_data(t6, t6_value);
if (dirty & 3 && a_href_value !== (a_href_value = "" + (ctx2[0] + "/event/" + (ctx2[1].slug || ctx2[1].id)))) {
if (dirty & 3 && a_href_value !== (a_href_value = ctx2[0] + "/event/" + (ctx2[1].slug || ctx2[1].id))) {
attr(a, "href", a_href_value);
}
},

View file

@ -8,6 +8,24 @@ nav_order: 10
All notable changes to this project will be documented in this file.
### 1.4.4 - 10 may '22
- better img rendering, make it easier to download flyer #153
- avoid place and tags duplication (remove white space, match case insensitive)
- show date and place to unconfirmed events
- add warning when visiting from different hostname or protocol #149
- add tags and fix html description in ics export
- add git dependency in Dockerfile #148
- add external_style param to gancio-events webcomponent
- add GANCIO_HOST and GANCIO_PORT environment vars
- fix place and address when importing from url #147
- fix user account removal
- fix timezone issue #151
- fix scrolling behavior
- fix adding event on disabled anon posting
- fix plain description meta
- fix recurrent events always shown #150
- remove `less` and `less-loader` dependency
### 1.4.3 - 10 mar '22
- fix [#140](https://framagit.org/les/gancio/-/issues/140) - Invalid date
- fix [#141](https://framagit.org/les/gancio/-/issues/141) - Cannot change logo

View file

@ -1,4 +1,5 @@
FROM node:17.4-slim
FROM node:17-slim
RUN bash -c "apt update -y && apt install git -y && apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp"
RUN yarn global remove gancio || true
RUN yarn cache clean
RUN yarn global add --latest --production --silent https://gancio.org/latest.tgz

View file

@ -4,7 +4,7 @@ services:
gancio:
build: .
restart: always
image: node:17.4-slim
image: gancio
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
@ -13,7 +13,7 @@ services:
- GANCIO_DB_DIALECT=sqlite
- GANCIO_DB_STORAGE=./gancio.sqlite
entrypoint: /entrypoint.sh
command: gancio start --docker
command: gancio start
volumes:
- ./data:/home/node/data
ports:

View file

@ -16,7 +16,7 @@ services:
gancio:
build: .
restart: always
image: node:17.4-slim
image: gancio
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
@ -24,10 +24,11 @@ services:
- NODE_ENV=production
- GANCIO_DB_DIALECT=mariadb
- GANCIO_DB_HOST=db
- GANCIO_DB_PORT=3306
- GANCIO_DB_DATABASE=gancio
- GANCIO_DB_USERNAME=gancio
- GANCIO_DB_PASSWORD=gancio
command: gancio start --docker
command: gancio start
entrypoint: /entrypoint.sh
volumes:
- ./data:/home/node/data

View file

@ -18,7 +18,7 @@ services:
gancio:
build: .
restart: always
image: node:17.4-slim
image: gancio
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
@ -26,10 +26,11 @@ services:
- NODE_ENV=production
- GANCIO_DB_DIALECT=postgres
- GANCIO_DB_HOST=db
- GANCIO_DB_PORT=5432
- GANCIO_DB_DATABASE=gancio
- GANCIO_DB_USERNAME=gancio
- GANCIO_DB_PASSWORD=gancio
command: gancio start --docker
command: gancio start
entrypoint: /entrypoint.sh
volumes:
- ./data:/home/node/data

View file

@ -4,7 +4,7 @@ services:
gancio:
build: .
restart: always
image: node:17.4-slim
image: gancio
container_name: gancio
environment:
- PATH=$PATH:/home/node/.yarn/bin
@ -13,7 +13,7 @@ services:
- GANCIO_DB_DIALECT=sqlite
- GANCIO_DB_STORAGE=./gancio.sqlite
entrypoint: /entrypoint.sh
command: gancio start --docker
command: gancio start
volumes:
- ./data:/home/node/data
ports:

View file

@ -24,14 +24,14 @@ nowhere on gancio does the identity of who posted an event appear, not even unde
- **Anonymous events**: optionally a visitor can create events without being registered (an administrator must confirm them)
- **We don't care about making hits** so we export events in many ways: via RSS feeds, via global or individual ics, allowing you to embed list of events or single event via [iframe or webcomponent]({% link embed.md %}) on other websites, via [AP]({% link federation.md %}), [microdata](https://developer.mozilla.org/en-US/docs/Web/HTML/Microdata) and [microformat](https://developer.mozilla.org/en-US/docs/Web/HTML/microformats#h-event)
- **We don't care about making hits** so we export events in many ways: via RSS feeds, via global or individual ics, allowing you to embed list of events or single event via [iframe or webcomponent]({% link usage/embed.md %}) on other websites, via [AP]({% link federation.md %}), [microdata](https://developer.mozilla.org/en-US/docs/Web/HTML/Microdata) and [microformat](https://developer.mozilla.org/en-US/docs/Web/HTML/microformats#h-event)
- Very easy UI
- Multi-day events (festival, conferences...)
- Recurring events (each monday, each two monday, each monday and friday, each two saturday, etc.)
- Filter events for tags or places
- RSS and ICS export (with filters)
- embed your events in your website with [webcomponents]({% link embed.md %}) or iframe ([example](https://gancio.cisti.org/embed/list?title=Upcoming events))
- embed your events in your website with [webcomponents]({% link usage/embed.md %}) or iframe ([example](https://gancio.cisti.org/embed/list?title=Upcoming events))
- boost / bookmark / comment events from the fediverse!
- Lot of configurations available (dark/light theme, user registration open/close, enable federation, enable recurring events)

View file

@ -65,6 +65,7 @@ You'll need to [setup nginx as a proxy]({% link install/nginx.md %}) then you ca
```bash
cd /opt/gancio
cd /opt/gancio # or where your installation is
wget https://gancio.org/docker/Dockerfile -O Dockerfile
docker-compose up -d --no-deps --build
```

View file

@ -9,9 +9,12 @@ nav_order: 7
- [gancio.cisti.org](https://gancio.cisti.org) (Turin, Italy)
- [lapunta.org](https://lapunta.org) (Florence, Italy)
- [sapratza.in](https://sapratza.in/) (Sardinia, Italy)
- [termine.161.social](https://termine.161.social) (Germany)
- [bcn.convoca.la](https://bcn.convoca.la/) (Barcelona)
- [ezkerraldea.euskaragendak.eus](https://ezkerraldea.euskaragendak.eus/)
- [lakelogaztetxea.net](https://lakelogaztetxea.net)
- [agenda.eskoria.eus](https://agenda.eskoria.eus/)
- [lubakiagenda.net](https://lubakiagenda.net/)
<small>Do you want your instance to appear here? [Write us]({% link contact.md %}).</small>

View file

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

View file

@ -1,14 +1,21 @@
<template lang='pug'>
v-app(app)
Snackbar
Confirm
Nav
<template>
<v-app app>
<Snackbar/>
<Confirm/>
<Nav/>
<v-main app>
<div class="ml-1 mb-1 mt-1" v-if='showCohorts || showBack'>
<v-btn v-show='showBack' text color='primary' to='/'><v-icon v-text='mdiChevronLeft'/></v-btn>
<v-btn v-for='cohort in cohorts' text color='primary' :key='cohort.id' :to='`/g/${cohort.name}`'>{{cohort.name}}</v-btn>
</div>
<v-fade-transition hide-on-leave>
<nuxt />
</v-fade-transition>
</v-main>
<Footer/>
v-main(app)
v-fade-transition(hide-on-leave)
nuxt
</v-app>
Footer
</template>
<script>
@ -17,6 +24,7 @@ import Snackbar from '../components/Snackbar'
import Footer from '../components/Footer'
import Confirm from '../components/Confirm'
import { mapState } from 'vuex'
import { mdiChevronLeft } from '@mdi/js'
export default {
head () {
@ -26,9 +34,24 @@ export default {
}
}
},
data () {
return { cohorts: [], mdiChevronLeft }
},
async fetch () {
this.cohorts = await this.$axios.$get('cohorts')
},
name: 'Default',
components: { Nav, Snackbar, Footer, Confirm },
computed: mapState(['settings', 'locale']),
computed: {
...mapState(['settings', 'locale']),
showBack () {
return ['tag-tag', 'g-cohort', 'p-place', 'search', 'announcement-id'].includes(this.$route.name)
},
showCohorts () {
if (!this.cohorts || this.cohorts.length === 0) return false
return ['tag-tag', 'index', 'g-cohort', 'p-place'].includes(this.$route.name)
}
},
created () {
this.$vuetify.theme.dark = this.settings['theme.is_dark']
}

View file

@ -1,8 +1,8 @@
<template lang='pug'>
v-app#iframe
nuxt
v-app#iframe
nuxt
</template>
<style lang='less'>
<style>
#iframe.v-application {
background-color: transparent !important;
}

View file

@ -109,7 +109,7 @@
"list_description": "Si tens una web i vols encastar una llista d'activitats, pots fer servir el codi de sota"
},
"register": {
"description": "Els moviments socials necessitem organitzar-nos i auto-finançar-nos.\n<br/> Abans que puguis publicar, <strong> hem d'aprovar el teu compte </strong>, tingues en comtpe que <strong> darrere d'aquesta web hi ha persones </strong> de carn i ossos, així que escriviu dues línies per fer-nos saber quins esdeveniments voleu publicar.",
"description": "Els moviments socials necessitem organitzar-nos i auto-finançar-nos.<br/>\n<br/>Abans que puguis publicar, <strong> hem d'aprovar el teu compte </strong>, tingues en compte que <strong> darrere d'aquesta web hi ha persones </strong> de carn i ossos, així que escriviu dues línies per fer-nos saber quins esdeveniments voleu publicar.",
"error": "Error: ",
"complete": "El registre ha de ser confirmat.",
"first_user": "S'ha creat i activat un compte administrador"
@ -124,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 està posat, escriu-ho i prem Enter.",
"confirmed": "S'ha confirmat l'activitat",
"not_found": "No s'ha trobat l'activitat",
"remove_confirmation": "Segur que vols esborrar l'activitat?",
@ -150,7 +150,7 @@
"from": "Des de les",
"image_too_big": "La imatge és massa gran! Max 4 MB",
"interact_with_me_at": "Interacciona amb mi a",
"follow_me_description": "Entre les diverses maneres d'estar al dia de les activitats que es publiquen aquí a {title},\n pots seguir-nos al compte <u>{account}</u> des de Mastodon o altres, i afegir recursos des d'allà. <br/> <br/>\nSi no has sentit mai sobre «Mastodon» o «Fedivers», recomanem mirar <a href='https://peertube.social/videos/watch/d9bd2ee9-b7a4-44e3-8d65-61badd15c6e6'> aquest vídeo (subtitulat en català)</a>. <br/> <br/> Introdueix la teva instància a sota (ex: red.confederac.io o mastodont.cat)",
"follow_me_description": "Entre les diverses maneres d'estar al dia de les activitats que es publiquen aquí a {title},\n pots seguir-nos al compte <u>{account}</u> des de Mastodon o altres, i afegir recursos des d'allà. <br/> <br/>\nSi no has sentit mai sobre «Mastodon» o «Fedivers», recomanem fer un cop d'ull a <a href='https://equipamentslliures.cat/divulgacio/fediverse'>aquesta breu introducció al Fedivers</a>. <br/> <br/> Introdueix la teva instància a sota (ex: kolektiva.social o mastodont.cat)",
"interact_with_me": "Segueix-nos al fedivers",
"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",
@ -159,7 +159,11 @@
"edit_recurrent": "Edita l'activitat periòdica:",
"updated": "S'ha actualitzat l'activitat",
"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)"
"import_description": "Pots importar activitats des d'altres instàncies o plataformes que facin servir formats estàndards (ics o h-event)",
"remove_media_confirmation": "Confirmeu l'eliminació de la imatge?",
"download_flyer": "Baixa el flyer",
"alt_text_description": "Descripció per a persones amb discapacitat visual",
"choose_focal_point": "Tria el punt focal"
},
"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.",
@ -226,7 +230,9 @@
"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_button": "Envia un correu de prova",
"widget": "Giny"
"widget": "Giny",
"wrong_domain_warning": "La url base configurada a config.json <b>({baseurl})</b> difereix de la que esteu visitant <b>({url})</b>",
"event_remove_ok": "S'ha suprimit l'esdeveniment"
},
"auth": {
"not_confirmed": "Encara no s'ha confirmat…",
@ -272,6 +278,8 @@
"setup": {
"completed": "S'ha completat la configuració inicial",
"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"
"start": "Comença",
"copy_password_dialog": "Sí, has de copiar la contrasenya!",
"https_warning": "Esteu visitant des d'HTTP, recordeu canviar baseurl a config.json si canvieu a HTTPS!"
}
}

View file

@ -24,5 +24,9 @@
},
"event_confirm": {
"content": "Puede confirmar este evento <a href='{{url}}'>aquí</a>"
},
"test": {
"subject": "Tu configuración SMTP funciona",
"content": "Esto es un email de prueba. Si estás leyendo esto es que tu configuración funciona."
}
}

View file

@ -86,7 +86,8 @@
"reset": "Reset",
"import": "Import",
"max_events": "N. max events",
"label": "Label"
"label": "Label",
"blobs": "Blobs"
},
"login": {
"description": "By logging in you can publish new events.",
@ -151,7 +152,7 @@
"each_month": "Each month",
"due": "until",
"from": "From",
"image_too_big": "The image can't be bigger than 4 MB",
"image_too_big": "The image can't be bigger than 4MB",
"interact_with_me_at": "Interact with me on fediverse at",
"follow_me_description": "One of the ways to stay up to date on events published here on {title},\nis following the account <u>{account}</u> from the fediverse, for example via Mastodon, and possibly add resources to an event from there.<br/><br/>\nIf you have never heard of Mastodon and the fediverse we recommend reading <a href='https://www.savjee.be/videos/simply-explained/mastodon-and-fediverse-explained/'>this article</a>.<br/><br/>Enter your instance below (e.g. mastodon.social)",
"interact_with_me": "Follow me",
@ -159,7 +160,11 @@
"import_URL": "Import from URL",
"import_ICS": "Import from ICS",
"ics": "ICS",
"import_description": "You can import events from other platforms and other instances through standard formats (ics and h-event)"
"import_description": "You can import events from other platforms and other instances through standard formats (ics and h-event)",
"alt_text_description": "Description for people with visual impairments",
"choose_focal_point": "Choose the focal point",
"remove_media_confirmation": "Do you confirm the image removal?",
"download_flyer": "Download flyer"
},
"admin": {
"place_description": "If you have gotten the place or address wrong, you can change it.<br/>All current and past events associated with this place will change address.",
@ -170,6 +175,7 @@
"delete_user_confirm": "Are you sure you want to remove {user}?",
"user_remove_ok": "User removed",
"user_create_ok": "User created",
"event_remove_ok": "Event removed",
"allow_registration_description": "Allow open registrations?",
"allow_anon_event": "Allow anonymous events (has to be confirmed)?",
"allow_recurrent_event": "Allow recurring events",
@ -226,7 +232,8 @@
"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",
"widget": "Widget"
"widget": "Widget",
"wrong_domain_warning": "The baseurl configured in config.json <b>({baseurl})</b> differs from the one you're visiting <b>({url})</b>"
},
"auth": {
"not_confirmed": "Not confirmed yet…",
@ -273,6 +280,7 @@
"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>",
"copy_password_dialog": "Yes, you have to copy the password!",
"start": "Start"
"start": "Start",
"https_warning": "You're visiting from HTTP, remember to change baseurl in config.json if you switch to HTTPS!"
}
}

View file

@ -105,19 +105,19 @@
"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, 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.",
"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/>\nAgregando 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"
},
"register": {
"description": "Los movimientos sociales necesitan organizarse y autofinanciarse. <br/> Este es un regalo para ustedes, úsenlo solamente para eventos con fines no comerciales y obviamente antifascistas, antisexistas y antirracistas.\n<br/> Antes de que puedas publicar <strong> debemos aprobar la cuenta </strong>. Como imaginarás, <strong> detrás de este sitio hay personas </strong> de carne y hueso, por esto te pedimos escribir algo para hacernos saber que tipos de eventos te gustaría publicar.",
"description": "Los movimientos sociales necesitan organizarse y autofinanciarse. <br/>\nEste es un regalo para ustedes, úsenlo solamente para eventos con fines no comerciales y obviamente antifascistas, antisexistas y antirracistas.\n<br/> Antes de que puedas publicar, <strong> debemos aprobar la cuenta </strong>. Como imaginarás, <strong> detrás de este sitio hay personas de carne y hueso</strong>, por esto te pedimos escribir algo para hacernos saber que tipos de eventos te gustaría publicar.",
"error": "Error: ",
"complete": "Confirmaremos el registro lo antes posible.",
"first_user": "Administrador creado y activado"
},
"event": {
"anon": "Anónimo",
"anon_description": "Puedes ingresar un evento sin registrarte o iniciar sesión, pero en este caso tendrás que esperar a que alguien lo lea para confirmar que es un evento adecuado para este espacio,\ndelegando esta elección. Además, no será posible modificarlo. <br/> <br/>\nSi no te gusta, puedes <a href='/login'> iniciar sesión </a> o <a href='/register'> registrarte </a>,\nde lo contrario, continúa y recibirás una respuesta lo antes posible. ",
"anon_description": "Puedes ingresar un evento sin registrarte o iniciar sesión, pero en este caso tendrás que esperar a que alguien lo lea para confirmar que es un evento adecuado para este espacio,\ndelegando esta elección. Además, no será posible modificarlo. <br/> <br/>\nSi no te gusta, puedes <a href='/login'> iniciar sesión </a> o <a href='/register'> registrarte </a>. De lo contrario, continúa y recibirás una respuesta lo antes posible. ",
"same_day": "Mismo día",
"what_description": "Nombre evento",
"description_description": "Descripción, puedes copiar y pegar",
@ -148,7 +148,7 @@
"from": "Desde las",
"image_too_big": "La imagén es demasiado grande! Tamaño máx 4M",
"interact_with_me_at": "Sígueme en el fediverso en",
"show_recurrent": "Eventos recurrientes",
"show_recurrent": "Eventos recurrentes",
"show_past": "eventos pasados",
"follow_me_description": "Entre las diversas formas de mantenerse al día con los eventos publicados aquí en {title},\npuedes seguir la cuenta <u>{account}</u> desde el fediverso, por ejemplo, a través de un Mastodon, y posiblemente añadir recursos a un evento desde allí.<br/><br/>\nSi nunca has oído hablar del Mastodon y el fediverso te sugerimos que leas <a href='https://cagizero.wordpress.com/2018/10/25/cose-mastodon/'>este artículo</a>.<br/><br/> Introduce tu instancia abajo (por ejemplo mastodon.cisti.org o mastodon.bida.im)",
"interact_with_me": "Sigueme en el fediverso",
@ -221,7 +221,14 @@
"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}?"
"disable_user_confirm": "Estas seguro de que quieres deshabilitar a {user}?",
"widget": "Widget",
"show_smtp_setup": "Ajustes de correo",
"smtp_hostname": "Nombre del equipo SMTP",
"smtp_test_success": "Un correo de prueba se ha enviado a {admin_email}. Por favos, comprueba tu bandeja de entrada",
"smtp_test_button": "Enviar correo de prueba",
"admin_email": "Correo del administrador",
"smtp_description": "<ul><li>El administrador debería recibir un correo cuando son añadidos eventos anónimos (si está habilitado).</li><li>El administrador debería recibir un correo de petición de registro (si está habilitado).</li><li>El usuario debería recibir un correo de petición de registro.</li><li>El usuario debería recibir un correo de confirmación de registro.</li><li>El usuario debería recibir un correo de confirmación cuando el administrador le subscriba directamente.</li><li>El usuario debería recibir un correo para restaurar la contraseña cuando la haya olvidado.</li></ul>"
},
"auth": {
"not_confirmed": "Todavía no hemos confirmado este email…",
@ -263,5 +270,10 @@
"validators": {
"email": "Introduce un correo electrónico valido",
"required": "{fieldName} es requerido"
},
"setup": {
"completed": "Configuración completada",
"start": "Inicio",
"completed_description": "<p>Puedes ingresar con el siguiente usuario:<br/><br/>Usuario: <b>{email}</b><br/>Contraseña: <b>{password}<b/></p>"
}
}

View file

@ -109,7 +109,7 @@
"list_description": "Webgune bat baduzu eta ekitaldien zerrenda erakutsi nahi baduzu, ondorengo kodea erabili"
},
"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": "Gizarte mugimenduak beraien kabuz antolatu behar dira.<br/>\n<br/>Argitaratzen hasi baino lehen <strong>zure kontu berria onartua izan behar da</strong>, beraz, <strong>webgune honen atzean hezur-haragizko pertsonak</strong> gaudela kontuan izanik, azal iezaguzu mesedez pare bat lerrotan zer nolako ekitaldiak argitaratu nahi dituzun.",
"error": "Errorea: ",
"complete": "Izen-ematea baieztatua izan behar da.",
"first_user": "Administratzailea sortu da"
@ -159,7 +159,11 @@
"only_future": "datozen ekitaldiak bakarrik",
"edit_recurrent": "Editatu ekitaldi errepikaria:",
"updated": "Ekitaldia eguneratu da",
"saved": "Ekitaldia gorde da"
"saved": "Ekitaldia gorde da",
"remove_media_confirmation": "Irudiaren ezabaketa baieztatzen duzu?",
"alt_text_description": "Ikusmen-urritasunak dituztenentzako deskripzioa",
"choose_focal_point": "Aukeratu arretagunea",
"download_flyer": "Deskargatu eskuorria"
},
"admin": {
"place_description": "Helbidea oker badago, alda dezakezu.<br/>Leku honekin lotutako iraganeko eta etorkizuneko ekitaldien helbidea aldatuko da.",
@ -225,7 +229,10 @@
"smtp_test_success": "Probako eposta bidali da {admin_email}-(e)ra, begiratu zure sarrera-ontzia",
"admin_email": "Administratzailearen eposta",
"smtp_hostname": "SMTP hostname",
"smtp_description": "<ul><li>Administratzaileak eposta bat jaso beharko luke anonimo batek ekitaldi bat gehitzen duenean (gaituta badago).</li><li>Administratzaileak eposta bat jaso beharko luke izena emateko eskari bakoitzeko (gaituta badago).</li><li>Erabiltzaileak eposta bat jaso beharko luke izena emateko eskariarekin.</li><li>Erabiltzaileak eposta bat jaso beharko luke izen ematea baieztatzean.</li><li>Erabiltzaileak eposta bat jaso beharko luke administratzaileak zuzenean izena emanez gero.</li><li>Erabiltzaileek eposta bat jaso beharko lukete pasahitza ahazten dutenean.</li></ul>"
"smtp_description": "<ul><li>Administratzaileak eposta bat jaso beharko luke anonimo batek ekitaldi bat gehitzen duenean (gaituta badago).</li><li>Administratzaileak eposta bat jaso beharko luke izena emateko eskari bakoitzeko (gaituta badago).</li><li>Erabiltzaileak eposta bat jaso beharko luke izena emateko eskariarekin.</li><li>Erabiltzaileak eposta bat jaso beharko luke izen ematea baieztatzean.</li><li>Erabiltzaileak eposta bat jaso beharko luke administratzaileak zuzenean izena emanez gero.</li><li>Erabiltzaileek eposta bat jaso beharko lukete pasahitza ahazten dutenean.</li></ul>",
"widget": "Tresna",
"event_remove_ok": "Ekitaldia ezabatu da",
"wrong_domain_warning": "config.json-en konfiguratuta dagoen baseurl <b>({baseurl})</b> ez da bisitatzen ari zaren berbera <b>({url})</b>"
},
"auth": {
"not_confirmed": "Oraindik baieztatu gabe dago…",
@ -271,6 +278,8 @@
"setup": {
"start": "Hasi",
"completed": "Instalazioa bukatu da",
"completed_description": "<p>Erabiltzaile honekin saioa has dezakezu orain:<br/><br/>Erabiltzailea: <b>{email}</b><br/>Pasahitza: <b>{password}<b/></p>"
"completed_description": "<p>Erabiltzaile honekin saioa has dezakezu orain:<br/><br/>Erabiltzailea: <b>{email}</b><br/>Pasahitza: <b>{password}<b/></p>",
"copy_password_dialog": "Bai, pasahitza kopiatu behar duzu!",
"https_warning": "HTTP bidez ari zarela kontuan izan. HTTPSra pasatzen bazara gogoratu config.json-en baseurl aldatzeaz!"
}
}

View file

@ -133,7 +133,10 @@
"edit_recurrent": "Modifier lévènement récurrent :",
"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é"
"saved": "Événement enregistré",
"alt_text_description": "Description pour les personnes avec une déficience visuelle",
"remove_media_confirmation": "Confirmer la suppression de l'image ?",
"download_flyer": "Télécharger le flyer"
},
"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</strong>, à qui vous pouvez écrire en deux lignes pour exprimer les évènements que vous souhaiteriez publier.",
@ -273,6 +276,7 @@
"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"
"start": "Commencer",
"copy_password_dialog": "Oui, vous devez copier le mot de passe !"
}
}

View file

@ -145,12 +145,16 @@
"normal_description": "Elixe o día.",
"recurrent_1w_days": "Cada {days}",
"recurrent_1m_ordinal": "O {n} {days} de cada mes",
"image_too_big": "A imaxe non pode superar os 4 MB",
"image_too_big": "A imaxe non pode superar os 4MB",
"recurrent_1m_days": "|O {days} de cada mes|{days} de cada mes",
"each_2w": "Cada dúas semanas",
"follow_me_description": "Un dos xeitos de recibir actualizacións dos eventos que se publican aquí en {title},\né seguindo a conta <u>{account}</u> no fediverso, por exemplo a través de Mastodon, e posiblemente tamén engadir recursos para un evento desde alí.<br/><br/>\nSe nunco escoitaches falar de Mastodon e o fediverso recomendámosche ler <a href='https://www.savjee.be/videos/simply-explained/mastodon-and-fediverse-explained/'>este artigo</a>.<br/><br/>Escribe aquí a túa instancia (ex. mastodon.social)",
"ics": "ICS",
"import_description": "Podes importar eventos desde outras plataformas e outras instancias usando formatos estándar (ics e h-event)"
"import_description": "Podes importar eventos desde outras plataformas e outras instancias usando formatos estándar (ics e h-event)",
"alt_text_description": "Descrición para persoas con problemas de visión",
"choose_focal_point": "Elixe onde centrar a atención",
"remove_media_confirmation": "Confirmas a eliminación da imaxe?",
"download_flyer": "Descargar folleto"
},
"admin": {
"place_description": "Se escribiches mal o lugar ou enderezo, podes cambialo.<br/>Cambiará o enderezo de tódolos eventos actuais e pasados asociados a este lugar.",
@ -217,7 +221,9 @@
"show_smtp_setup": "Axustes do email",
"smtp_hostname": "Servidor SMTP",
"new_announcement": "Novo anuncio",
"smtp_description": "<ul><li>Admin debería recibir un email cando se engade un evento anónimo (se está activo)</li><li>Admin debería recibir un email coas solicitudes de rexistro (se activo).</li><li>A usuaria debería recibir un email coa solicitude de rexistro.</li><li>A usuaria debería recibir un email confirmando o rexistro.</li><li>A usuaria debería recibir un email de confirmación cando fose subscrita directamente por Admin</li><li>As usuarias deberían recibir un email para restablecer o contrasinal se o esquecen</li></ul>"
"smtp_description": "<ul><li>Admin debería recibir un email cando se engade un evento anónimo (se está activo)</li><li>Admin debería recibir un email coas solicitudes de rexistro (se activo).</li><li>A usuaria debería recibir un email coa solicitude de rexistro.</li><li>A usuaria debería recibir un email confirmando o rexistro.</li><li>A usuaria debería recibir un email de confirmación cando fose subscrita directamente por Admin</li><li>As usuarias deberían recibir un email para restablecer o contrasinal se o esquecen</li></ul>",
"wrong_domain_warning": "O url base configurado en config.json <b>({baseurl)</b> é diferente ao que estás a visitar <b>({url})</b>",
"event_remove_ok": "Evento eliminado"
},
"auth": {
"not_confirmed": "Aínda non foi confirmado…",
@ -263,7 +269,9 @@
"setup": {
"completed": "Configuración completada",
"completed_description": "<p>Xa podes acceder con estas credenciais:<br/><br/>Identificador: <b>{email}</b><br/>Contrasinal: <b>{password}</b></p>",
"start": "Comezar"
"start": "Comezar",
"https_warning": "Estás entrando con HTTP, lembra cambiar baseurl no config.json se cambias a HTTPS!",
"copy_password_dialog": "Si, tes que copiar o contrasinal!"
},
"login": {
"forgot_password": "Esqueceches o contrasinal?",

View file

@ -86,7 +86,8 @@
"reset": "Reset",
"import": "Importa",
"max_events": "N. massimo eventi",
"label": "Etichetta"
"label": "Etichetta",
"blobs": "Bolle"
},
"login": {
"description": "Entrando puoi pubblicare nuovi eventi.",
@ -151,7 +152,7 @@
"each_month": "Ogni mese",
"due": "alle",
"from": "Dalle",
"image_too_big": "L'immagine non può essere più grande di 4 MB",
"image_too_big": "L'immagine non può essere più grande di 4MB",
"interact_with_me": "Seguimi dal fediverso",
"follow_me_description": "Tra i vari modi di rimanere aggiornati degli eventi pubblicati qui su {title},\npuoi seguire l'account <u>{account}</u> dal fediverso, ad esempio via Mastodon, ed eventualmente aggiungere risorse ad un evento da lì.<br/><br/>\nSe non hai mai sentito parlare di Mastodon e del fediverso ti consigliamo di leggere <a href='https://cagizero.wordpress.com/2018/10/25/cose-mastodon/'>questo articolo</a>.<br/><br/> Inserisci la tua istanza qui sotto (es. mastodon.cisti.org o mastodon.bida.im)",
"only_future": "solo eventi futuri",
@ -160,9 +161,10 @@
"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)",
"alt_text_description": "Descrizione per utenti con disabilità visive",
"alt_text_description": "Descrizione per persone con disabilità visive",
"choose_focal_point": "Scegli il punto centrale cliccando",
"remove_media_confirmation": "Confermi l'eliminazione dell'immagine?"
"remove_media_confirmation": "Confermi l'eliminazione dell'immagine?",
"download_flyer": "Scarica volantino"
},
"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).",
@ -276,6 +278,7 @@
"completed": "Setup completato",
"completed_description": "<p>Puoi entrare con le seguenti credenziali:<br/><br/>Utente: <b>{email}</b><br/>Password: <b>{password}<b/></p>",
"copy_password_dialog": "Sì, devi copiare la password!",
"start": "Inizia"
"start": "Inizia",
"https_warning": "Stai visitando il setup da HTTP, ricorda di cambiare il baseurl nel config.json quando passerai ad HTTPS!"
}
}

View file

@ -14,7 +14,6 @@ module.exports = {
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [{ rel: 'icon', type: 'image/png', href: '/logo.png' }],
link: [{ rel: 'preload', type: 'image/png', href: '/logo.png', as: 'image' }],
script: [{ src: '/gancio-events.es.js', async: true, body: true }],
},
dev: isDev,
@ -27,13 +26,12 @@ module.exports = {
}
},
css: ['./assets/style.less'],
css: ['./assets/style.css'],
/*
** Customize the progress-bar component
*/
loading: '~/components/Loading.vue',
loading: '~/components/Loading.vue',
/*
** Plugins to load before mounting the App
*/
@ -53,9 +51,27 @@ module.exports = {
// Doc: https://axios.nuxtjs.org/usage
'@nuxtjs/axios',
'@nuxtjs/auth',
'@/server/initialize.server.js'
'@nuxtjs/sitemap'
],
sitemap: {
hostname: config.baseurl,
gzip: true,
exclude: [
'/Admin',
'/settings',
'/export',
'/setup'
],
routes: async () => {
if (config.status === 'READY') {
const Event = require('./server/api/models/event')
const events = await Event.findAll({where: { is_visible: true }})
return events.map(e => `/event/${e.slug}`)
}
}
},
serverMiddleware: ['server/routes'],
/*
@ -93,7 +109,6 @@ module.exports = {
},
buildModules: ['@nuxtjs/vuetify'],
vuetify: {
customVariables: ['~/assets/variables.scss'],
treeShake: true,
theme: {
options: {

View file

@ -1,18 +1,19 @@
{
"name": "gancio",
"version": "1.4.3",
"version": "1.5.0-rc.2",
"description": "A shared agenda for local communities",
"author": "lesion",
"scripts": {
"build": "nuxt build --modern",
"start:inspect": "NODE_ENV=production node --inspect node_modules/.bin/nuxt start --modern",
"dev": "nuxt dev",
"test": "cd tests/seeds; jest; cd ../..",
"test": "cd tests/seeds; jest ; cd ../..",
"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"
"migrate:dev": "sequelize db:migrate",
"build:wc": "cd webcomponents; yarn build:lib; cp dist/gancio-events.es.js ../wp-plugin/js/; cp dist/gancio-events.es.js ../assets/; cp dist/gancio-events.es.js ../docs/assets/js/"
},
"files": [
"server/",
@ -27,19 +28,20 @@
"yarn.lock"
],
"dependencies": {
"@mdi/js": "^6.5.95",
"@mdi/js": "^6.7.96",
"@nuxtjs/auth": "^4.9.1",
"@nuxtjs/axios": "^5.13.5",
"@nuxtjs/sitemap": "^2.4.0",
"accept-language": "^3.0.18",
"axios": "^0.26.0",
"axios": "^0.27.2",
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.2",
"body-parser": "^1.20.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dayjs": "^1.10.7",
"dompurify": "^2.3.6",
"dayjs": "^1.11.3",
"dompurify": "^2.3.8",
"email-templates": "^8.0.9",
"express": "^4.17.3",
"express": "^4.18.1",
"express-oauth-server": "lesion/express-oauth-server#master",
"http-signature": "^1.3.6",
"ical.js": "^1.5.0",
@ -49,55 +51,41 @@
"linkify-html": "^3.0.4",
"linkifyjs": "3.0.5",
"lodash": "^4.17.21",
"mariadb": "^2.5.6",
"mariadb": "^3.0.0",
"microformat-node": "^2.0.1",
"minify-css-string": "^1.0.0",
"mkdirp": "^1.0.4",
"multer": "^1.4.3",
"nuxt-edge": "^2.16.0-27305297.ab1c6cb4",
"multer": "^1.4.5-lts.1",
"nuxt-edge": "^2.16.0-27358576.777a4b7f",
"pg": "^8.6.0",
"sequelize": "^6.17.0",
"sequelize-slugify": "^1.6.0",
"sequelize": "^6.20.1",
"sequelize-slugify": "^1.6.1",
"sharp": "^0.27.2",
"sqlite3": "mapbox/node-sqlite3#918052b",
"sqlite3": "^5.0.8",
"tiptap": "^1.32.0",
"tiptap-extensions": "^1.35.0",
"umzug": "^2.3.0",
"v-calendar": "2.4.1",
"v-calendar": "^2.4.1",
"vue": "^2.6.14",
"vue-i18n": "^8.26.7",
"vue-template-compiler": "^2.6.14",
"vuetify": "npm:@vuetify/nightly@dev",
"winston": "^3.6.0",
"winston-daily-rotate-file": "^4.6.1",
"yargs": "^17.2.0"
"winston": "^3.7.2",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.5.0"
},
"devDependencies": {
"@nuxtjs/vuetify": "^1.12.3",
"jest": "^27.5.1",
"less": "^4.1.1",
"less-loader": "^7",
"prettier": "^2.3.0",
"jest": "^28.1.0",
"prettier": "^2.6.2",
"pug": "^3.0.2",
"pug-plain-loader": "^1.1.0",
"sass": "^1.49.4",
"sass": "^1.52.2",
"sequelize-cli": "^6.3.0",
"supertest": "^6.2.2",
"webpack": "4",
"webpack-cli": "^4.7.2"
},
"resolutions": {
"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",
"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,7 +1,9 @@
<template lang="pug">
v-container.container.pa-0.pa-md-3
v-card
v-tabs(v-model='selectedTab' show-arrows)
v-alert(v-if='url!==settings.baseurl' outlined type='warning' color='red' show-icon :icon='mdiAlert')
span(v-html="$t('admin.wrong_domain_warning', { url, baseurl: settings.baseurl })")
v-tabs(v-model='selectedTab' show-arrows :next-icon='mdiChevronRight' :prev-icon='mdiChevronLeft')
//- SETTINGS
v-tab {{$t('common.settings')}}
@ -24,6 +26,11 @@
v-tab-item
Places
//- Cohorts
v-tab {{$t('common.blobs')}}
v-tab-item
Cohorts
//- EVENTS
v-tab
v-badge(:value='!!unconfirmedEvents.length' :content='unconfirmedEvents.length') {{$t('common.events')}}
@ -49,32 +56,42 @@
</template>
<script>
import { mapState } from 'vuex'
import { mdiAlert, mdiChevronRight, mdiChevronLeft } from '@mdi/js'
import Settings from '@/components/admin/Settings'
export default {
name: 'Admin',
components: {
Settings,
Users: () => import(/* webpackChunkName: "admin" */'../components/admin/Users'),
Events: () => import(/* webpackChunkName: "admin" */'../components/admin/Events'),
Places: () => import(/* webpackChunkName: "admin" */'../components/admin/Places'),
Settings: () => import(/* webpackChunkName: "admin" */'../components/admin/Settings'),
Cohorts: () => import(/* webpackChunkName: "admin" */'../components/admin/Cohorts'),
Federation: () => import(/* webpackChunkName: "admin" */'../components/admin/Federation.vue'),
Moderation: () => import(/* webpackChunkName: "admin" */'../components/admin/Moderation.vue'),
Announcement: () => import(/* webpackChunkName: "admin" */'../components/admin/Announcement.vue'),
Theme: () => import(/* webpackChunkName: "admin" */'../components/admin/Theme.vue')
},
middleware: ['auth'],
async asyncData ({ $axios, params, store }) {
async asyncData ({ $axios, req }) {
let url
if (process.client) {
url = window.location.protocol + '//' + window.location.host
} else {
url = req.protocol + '://' + req.headers.host
}
try {
const users = await $axios.$get('/users')
const unconfirmedEvents = await $axios.$get('/event/unconfirmed')
return { users, unconfirmedEvents, selectedTab: 0 }
return { users, unconfirmedEvents, selectedTab: 0, url }
} catch (e) {
console.error(e)
return { users: [], unconfirmedEvents: [], selectedTab: 0 }
return { users: [], unconfirmedEvents: [], selectedTab: 0, url }
}
},
data () {
return {
mdiAlert, mdiChevronRight, mdiChevronLeft,
users: [],
description: '',
unconfirmedEvents: [],
selectedTab: 0
@ -100,7 +117,7 @@ export default {
this.loading = true
await this.$axios.$get(`/event/confirm/${id}`)
this.loading = false
this.$root.$message('event.confirmed', { color: 'succes' })
this.$root.$message('event.confirmed', { color: 'success' })
this.unconfirmedEvents = this.unconfirmedEvents.filter(e => e.id !== id)
}
}

View file

@ -1,80 +0,0 @@
<template lang="pug">
v-row
v-col.col-6
v-menu(v-model='startTimeMenu'
:close-on-content-click="false"
transition="slide-x-transition"
ref='startTimeMenu'
:return-value.sync="value.start"
offset-y
absolute
top
max-width="290px"
min-width="290px")
template(v-slot:activator='{ on }')
v-text-field(
:label="$t('event.from')"
prepend-icon='mdi-clock'
:rules="[$validators.required('event.from')]"
:value='value.start'
v-on='on'
clearable)
v-time-picker(
v-if='startTimeMenu'
:label="$t('event.from')"
format="24hr"
ref='time_start'
:allowed-minutes="[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]"
v-model='value.start'
@click:minute="selectTime('start')")
v-col.col-6
v-menu(v-model='endTimeMenu'
:close-on-content-click="false"
transition="slide-x-transition"
ref='endTimeMenu'
:return-value.sync="time.end"
offset-y
absolute
top
max-width="290px"
min-width="290px")
template(v-slot:activator='{ on }')
v-text-field(
prepend-icon='mdi-clock'
:label="$t('event.due')"
:value='value.end'
v-on='on'
clearable
readonly)
v-time-picker(
v-if='endTimeMenu'
:label="$t('event.due')"
format="24hr"
:allowed-minutes="[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]"
v-model='value.end'
@click:minute="selectTime('end')")
</template>
<script>
export default {
name: 'HourInput',
props: {
value: { type: Object, default: () => { } }
},
data () {
return {
// time: { start: this.value.start, end: this.value.end },
time: {},
startTimeMenu: false,
endTimeMenu: false
}
},
methods: {
selectTime (type) {
this.$refs[`${type}TimeMenu`].save(this.value[type])
this.$emit('input', this.value)
}
}
}
</script>

View file

@ -1,102 +0,0 @@
<template lang="pug">
v-row
v-col(cols=12 md=6)
v-combobox(ref='place'
:rules="[$validators.required('common.where')]"
:label="$t('common.where')"
:hint="$t('event.where_description')"
:search-input.sync="placeName"
:prepend-icon='mdiMapMarker'
persistent-hint
:value="value.name"
:items="filteredPlaces"
no-filter
item-text='name'
@change='selectPlace')
template(v-slot:item="{ item, attrs, on }")
v-list-item(v-bind='attrs' v-on='on')
v-list-item-content(two-line v-if='item.create')
v-list-item-title <v-icon color='primary' v-text='mdiPlus' :aria-label='add'></v-icon> {{item.name}}
v-list-item-content(two-line v-else)
v-list-item-title(v-text='item.name')
v-list-item-subtitle(v-text='item.address')
v-col(cols=12 md=6)
v-text-field(ref='address'
:prepend-icon='mdiMap'
:disabled='disableAddress'
:rules="[ v => disableAddress ? true : $validators.required('common.address')(v)]"
:label="$t('common.address')"
@change="changeAddress"
:value="value.address")
</template>
<script>
import { mapState } from 'vuex'
import { mdiMap, mdiMapMarker, mdiPlus } from '@mdi/js'
export default {
name: 'WhereInput',
props: {
value: { type: Object, default: () => {} }
},
data () {
return {
mdiMap, mdiMapMarker, mdiPlus,
place: { },
placeName: '',
disableAddress: true
}
},
computed: {
...mapState(['places']),
filteredPlaces () {
if (!this.placeName) { return this.places }
const placeName = this.placeName.toLowerCase()
let nameMatch = false
const matches = this.places.filter(p => {
const tmpName = p.name.toLowerCase()
const tmpAddress = p.address.toLowerCase()
if (tmpName.includes(placeName)) {
if (tmpName === placeName) { nameMatch = true }
return true
}
if (tmpAddress.includes(placeName)) { return true }
return false
})
if (!nameMatch) {
matches.unshift({ create: true, name: this.placeName })
}
return matches
}
},
methods: {
selectPlace (p) {
if (!p) { return }
if (typeof p === 'object' && !p.create) {
this.place.name = p.name
this.place.address = p.address
this.disableAddress = true
} else { // this is a new place
this.place.name = p.name || p
// search for a place with the same name
const place = this.places.find(p => p.name === this.place.name)
if (place) {
this.place.address = place.address
this.disableAddress = true
} else {
this.place.address = ''
this.disableAddress = false
this.$refs.place.blur()
this.$refs.address.focus()
}
}
this.$emit('input', { ...this.place })
},
changeAddress (v) {
this.place.address = v
this.$emit('input', { ...this.place })
}
}
}
</script>

View file

@ -51,8 +51,11 @@
v-combobox(v-model='event.tags'
:prepend-icon="mdiTagMultiple"
chips small-chips multiple deletable-chips hide-no-data hide-selected persistent-hint
cache-items
@input.native='searchTags'
:delimiters="[',', ';']"
:items="tags.map(t => t.tag)"
:items="tags"
:menu-props="{ maxWidth: 400, eager: true }"
:label="$t('common.tags')")
template(v-slot:selection="{ item, on, attrs, selected, parent}")
v-chip(v-bind="attrs" close :close-icon='mdiCloseCircle' @click:close='parent.selectItem(item)'
@ -66,25 +69,33 @@
</template>
<script>
import { mapActions, mapState } from 'vuex'
import { mapState } from 'vuex'
import debounce from 'lodash/debounce'
import dayjs from 'dayjs'
import { mdiFileImport, mdiFormatTitle, mdiTagMultiple, mdiCloseCircle } from '@mdi/js'
import List from '@/components/List'
import Editor from '@/components/Editor'
import ImportDialog from '@/components/ImportDialog'
import MediaInput from '@/components/MediaInput'
import WhereInput from '@/components/WhereInput'
import DateInput from '@/components/DateInput'
export default {
name: 'NewEvent',
components: {
List: () => import(/* webpackChunkName: "add" */'@/components/List'),
Editor: () => import(/* webpackChunkName: "add" */'@/components/Editor'),
ImportDialog: () => import(/* webpackChunkName: "add" */'./ImportDialog.vue'),
MediaInput: () => import(/* webpackChunkName: "add" */'./MediaInput.vue'),
WhereInput: () => import(/* webpackChunkName: "add" */'./WhereInput.vue'),
DateInput: () => import(/* webpackChunkName: "add" */'./DateInput.vue')
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 }) {
async asyncData ({ params, $axios, error }) {
if (params.edit) {
const data = { event: { place: {}, media: [] } }
data.id = params.edit
@ -101,8 +112,8 @@ export default {
data.event.place.address = event.place.address || ''
data.date = {
recurrent: event.recurrent,
from: new Date(dayjs.unix(event.start_datetime)),
due: new Date(dayjs.unix(event.end_datetime)),
from: dayjs.unix(event.start_datetime).toDate(),
due: dayjs.unix(event.end_datetime).toDate(),
multidate: event.multidate,
fromHour: true,
dueHour: true
@ -118,8 +129,8 @@ export default {
return {}
},
data () {
const month = dayjs().month() + 1
const year = dayjs().year()
const month = dayjs.tz().month() + 1
const year = dayjs.tz().year()
return {
mdiFileImport, mdiFormatTitle, mdiTagMultiple, mdiCloseCircle,
valid: false,
@ -131,6 +142,7 @@ export default {
tags: [],
media: []
},
tags: [],
page: { month, year },
fileList: [],
id: null,
@ -145,9 +157,20 @@ export default {
title: `${this.settings.title} - ${this.$t('common.add_event')}`
}
},
computed: mapState(['tags', 'places', 'settings']),
computed: {
...mapState(['settings']),
filteredTags () {
if (!this.tagName) { return this.tags.slice(0, 10).map(t => t.tag) }
const tagName = this.tagName.trim().toLowerCase()
return this.tags.filter(t => t.tag.toLowerCase().includes(tagName)).map(t => t.tag)
}
},
methods: {
...mapActions(['updateMeta']),
searchTags: debounce( async function(ev) {
const search = ev.target.value
if (!search) return
this.tags = await this.$axios.$get(`/tag?search=${search}`)
}, 100),
eventImported (event) {
this.event = Object.assign(this.event, event)
this.$refs.where.selectPlace({ name: event.place.name, create: true })
@ -165,7 +188,9 @@ export default {
if (!this.$refs.form.validate()) {
this.$nextTick(() => {
const el = document.querySelector('.v-input.error--text:first-of-type')
el.scrollIntoView()
if (el) {
el.scrollIntoView(false)
}
})
return
}
@ -177,12 +202,15 @@ export default {
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_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)
if (this.event.place.id) {
formData.append('place_id', this.event.place.id)
}
formData.append('place_name', this.event.place.name)
formData.append('place_address', this.event.place.address)
formData.append('description', this.event.description)
@ -200,7 +228,6 @@ export default {
} else {
await this.$axios.$post('/event', formData)
}
this.updateMeta()
this.$router.push('/')
this.$nextTick(() => {
this.$root.$message(this.$auth.loggedIn ? (this.edit ? 'event.saved' : 'event.added') : 'event.added_anon', { color: 'success' })

View file

@ -33,7 +33,7 @@ export default {
// <iframe src='http://localhost:13120/embed/1' class='embedded_gancio'></iframe>
</script>
<style lang='less'>
<style lang='scss'>
.embed_event {
display: flex;
transition: margin .1s;

View file

@ -2,6 +2,7 @@
v-container#event.pa-0.pa-sm-2
//- EVENT PAGE
//- gancio supports microformats (http://microformats.org/wiki/h-event)
//- and microdata https://schema.org/Event
v-card.h-event(itemscope itemtype="https://schema.org/Event")
v-card-actions
//- admin controls
@ -9,41 +10,33 @@ v-container#event.pa-0.pa-sm-2
v-card-text
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' v-if='hasMedia' :src='event | mediaURL' itemprop="image")
v-img.main_image.mb-3(
contain
:alt='event | mediaURL("alt")'
:src='event | mediaURL'
:lazy-src='event | mediaURL("thumb")'
v-if='hasMedia')
v-col.col-12.col-md-8
MyPicture(v-if='hasMedia' :event='event')
.p-description.text-body-1.pa-3.rounded(v-if='!hasMedia && event.description' itemprop='description' v-html='event.description')
v-col.col-12.col-lg-4
v-col.col-12.col-md-4
v-card(outlined)
v-card-text
v-icon.float-right(v-if='event.parentId' color='success' v-text='mdiRepeat')
.title.text-h5
b.p-name(itemprop="name") {{event.title}}
.title.text-h5.mb-5
strong.p-name.text--primary(itemprop="name") {{event.title}}
time.dt-start.text-h6(:datetime='event.start_datetime|unixFormat("YYYY-MM-DD HH:mm")' itemprop="startDate" :content="event.start_datetime|unixFormat('YYYY-MM-DDTHH:mm')")
v-icon(v-text='mdiCalendar')
b.ml-2 {{event|when}}
strong.ml-2 {{event|when}}
.d-none.dt-end(itemprop="endDate" :content="event.end_datetime|unixFormat('YYYY-MM-DDTHH:mm')") {{event.end_datetime|unixFormat('YYYY-MM-DD HH:mm')}}
div.text-subtitle-1 {{event.start_datetime|from}}
div.text-subtitle-1.mb-5 {{event.start_datetime|from}}
small(v-if='event.parentId') ({{event|recurrentDetail}})
.text-h6.p-location(itemprop="location" itemscope itemtype="https://schema.org/Place")
.text-h6.p-location.h-adr(itemprop="location" itemscope itemtype="https://schema.org/Place")
v-icon(v-text='mdiMapMarker')
b.vcard.ml-2(itemprop="name") {{event.place && event.place.name}}
.text-subtitle-1.adr(itemprop='address') {{event.place && event.place.address}}
b.vcard.ml-2.p-name(itemprop="name") {{event.place && event.place.name}}
.text-subtitle-1.p-street-address(itemprop='address') {{event.place && event.place.address}}
//- tags, hashtags
v-card-text(v-if='event.tags.length')
v-card-text.pt-0(v-if='event.tags && event.tags.length')
v-chip.p-category.ml-1.mt-3(v-for='tag in event.tags' color='primary'
outlined :key='tag')
span(v-text='tag')
outlined :key='tag' :to='`/tag/${tag}`') {{tag}}
//- info & actions
v-toolbar
@ -55,6 +48,9 @@ v-container#event.pa-0.pa-sm-2
v-btn.ml-2(large icon :title="$t('common.add_to_calendar')" color='primary' :aria-label="$t('common.add_to_calendar')"
:href='`/api/event/${event.slug || event.id}.ics`')
v-icon(v-text='mdiCalendarExport')
v-btn.ml-2(v-if='hasMedia' large icon :title="$t('event.download_flyer')" color='primary' :aria-label="$t('event.download_flyer')"
:href='event | mediaURL')
v-icon(v-text='mdiFileDownloadOutline')
.p-description.text-body-1.pa-3.rounded(v-if='hasMedia && event.description' itemprop='description' v-html='event.description')
@ -133,20 +129,25 @@ import { mapState } from 'vuex'
import get from 'lodash/get'
import moment from 'dayjs'
import clipboard from '../../assets/clipboard'
const htmlToText = require('html-to-text')
import MyPicture from '~/components/MyPicture'
import EventAdmin from '@/components/eventAdmin'
import EmbedEvent from '@/components/embedEvent'
const { htmlToText } = require('html-to-text')
import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiClose,
mdiEye, mdiEyeOff, mdiDelete, mdiRepeat, mdiLock,
mdiEye, mdiEyeOff, mdiDelete, mdiRepeat, mdiLock, mdiFileDownloadOutline,
mdiCalendarExport, mdiCalendar, mdiContentCopy, mdiMapMarker } from '@mdi/js'
export default {
name: 'Event',
mixins: [clipboard],
components: {
EventAdmin: () => import(/* webpackChunkName: "event" */'./eventAdmin'),
EmbedEvent: () => import(/* webpackChunkName: "event" */'./embedEvent'),
EventAdmin,
EmbedEvent,
MyPicture
},
async asyncData ({ $axios, params, error, store }) {
async asyncData ({ $axios, params, error }) {
try {
const event = await $axios.$get(`/event/${params.slug}`)
return { event }
@ -156,7 +157,7 @@ export default {
},
data () {
return {
mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiCalendarExport, mdiCalendar,
mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiCalendarExport, mdiCalendar, mdiFileDownloadOutline,
mdiMapMarker, mdiContentCopy, mdiClose, mdiDelete, mdiEye, mdiEyeOff, mdiRepeat, mdiLock,
currentAttachment: 0,
event: {},
@ -169,7 +170,7 @@ export default {
if (!this.event) {
return {}
}
const tags_feed = this.event.tags.map(tag => ({
const tags_feed = this.event.tags && this.event.tags.map(tag => ({
rel: 'alternate',
type: 'application/rss+xml',
title: `${this.settings.title} events tagged ${tag}`,
@ -247,7 +248,7 @@ export default {
return this.event.media && this.event.media.length
},
plainDescription () {
return htmlToText.fromString(this.event.description.replace('\n', '').slice(0, 1000))
return htmlToText(this.event.description && this.event.description.replace('\n', '').slice(0, 1000))
},
currentAttachmentLabel () {
return get(this.selectedResource, `data.attachment[${this.currentAttachment}].name`, '')
@ -331,10 +332,3 @@ export default {
}
}
</script>
<style scoped>
.main_image {
margin: 0 auto;
border-radius: 5px;
transition: max-height 0.2s;
}
</style>

View file

@ -11,7 +11,7 @@
Search(
:filters='filters'
@update='f => filters = f')
v-tabs(v-model='type' show-arrows)
v-tabs(v-model='type' show-arrows :next-icon='mdiChevronRight' :prev-icon='mdiChevronLeft')
//- TOFIX
//- v-tab {{$t('common.email')}}
@ -86,7 +86,7 @@ import { mapState } from 'vuex'
import FollowMe from '../components/FollowMe'
import Search from '@/components/Search'
import clipboard from '../assets/clipboard'
import { mdiContentCopy } from '@mdi/js'
import { mdiContentCopy, mdiChevronRight, mdiChevronLeft } from '@mdi/js'
export default {
name: 'Exports',
@ -104,7 +104,7 @@ export default {
},
data ({ $store }) {
return {
mdiContentCopy,
mdiContentCopy, mdiChevronLeft, mdiChevronRight,
type: 'rss',
notification: { email: '' },
list: {

31
pages/g/_cohort.vue Normal file
View file

@ -0,0 +1,31 @@
<template>
<v-container id='home' fluid>
<h1 class='d-block text-h3 font-weight-black text-center align-center text-uppercase mt-10 mb-12 mx-auto w-100 text-underline'><u>{{cohort}}</u></h1>
<!-- Events -->
<div class='mb-2 mt-1 pl-1 pl-sm-2' id="events">
<Event :event='event' v-for='(event, idx) in events' :lazy='idx>2' :key='event.id'></Event>
</div>
</v-container>
</template>
<script>
import Event from '@/components/Event'
export default {
name: 'Tag',
components: { Event },
async asyncData ({ $axios, params, error }) {
try {
const cohort = params.cohort
const events = await $axios.$get(`/cohorts/${cohort}`)
return { events, cohort }
} catch (e) {
console.error(e)
error({ statusCode: 400, message: 'Error!' })
}
}
}
</script>

View file

@ -1,43 +1,41 @@
<template lang="pug">
v-container#home(fluid)
v-container#home(fluid)
//- Announcements
#announcements.mx-1.mt-1(v-if='announcements.length')
Announcement(v-for='announcement in announcements' :key='`a_${announcement.id}`' :announcement='announcement')
//- Announcements
#announcements.mx-1.mt-1(v-if='announcements.length')
Announcement(v-for='announcement in announcements' :key='`a_${announcement.id}`' :announcement='announcement')
//- Calendar and search bar
v-row.pt-0.pt-sm-2.pl-0.pl-sm-2
#calh.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(placeholder='Calendar unavailable without js')
Calendar(@dayclick='dayChange' @monthchange='monthChange' :events='filteredEvents')
//- Calendar and search bar
v-row.ma-2
#calh.col-xl-5.col-lg-5.col-md-7.col-sm-12.col-xs-12.pa-0.ma-0
//- this is needed as v-calendar does not support SSR
//- https://github.com/nathanreyes/v-calendar/issues/336
client-only(placeholder='Loading...')
Calendar(@dayclick='dayChange' @monthchange='monthChange' :events='events')
.col.pt-0.pt-md-2
Search(:filters='filters' @update='updateFilters')
v-chip(v-if='selectedDay' close :close-icon='mdiCloseCircle' @click:close='dayChange()') {{selectedDay}}
//- Events
#events.mb-2.mt-1.pl-1.pl-sm-2
Event(:event='event' @destroy='destroy' v-for='(event, idx) in visibleEvents' :lazy='idx>2' :key='event.id' @tagclick='tagClick' @placeclick='placeClick')
.col.pt-0.pt-md-2.mt-4.ma-md-0.pb-0
//- v-btn(to='/search' color='primary' ) {{$t('common.search')}}
v-form(to='/search' action='/search' method='GET')
v-text-field(name='search' :label='$t("common.search")' outlined rounded hide-details :append-icon='mdiMagnify')
//- Events
#events.mb-2.mt-1.pl-1.pl-sm-2
Event(:event='event' @destroy='destroy' v-for='(event, idx) in visibleEvents' :lazy='idx>2' :key='event.id')
</template>
<script>
import { mapState, mapActions } from 'vuex'
import intersection from 'lodash/intersection'
import { mapState } from 'vuex'
import dayjs from 'dayjs'
import Event from '@/components/Event'
import Announcement from '@/components/Announcement'
import Search from '@/components/Search'
import Calendar from '@/components/Calendar'
import { mdiCloseCircle } from '@mdi/js'
import { mdiMagnify } from '@mdi/js'
export default {
name: 'Index',
components: { Event, Search, Announcement, Calendar },
components: { Event, Announcement, Calendar },
middleware: 'setup',
async asyncData ({ params, $api, store }) {
async asyncData ({ $api }) {
const events = await $api.getEvents({
start: dayjs().startOf('month').unix(),
end: null,
@ -45,13 +43,13 @@ export default {
})
return { events }
},
data ({ $store }) {
data () {
return {
mdiCloseCircle,
mdiMagnify,
first: true,
isCurrentMonth: true,
now: dayjs().unix(),
date: dayjs().format('YYYY-MM-DD'),
date: dayjs.tz().format('YYYY-MM-DD'),
events: [],
start: dayjs().startOf('month').unix(),
end: null,
@ -74,49 +72,22 @@ export default {
]
}
},
computed: {
...mapState(['settings', 'announcements', 'filters']),
filteredEvents () {
let events = this.events
if (!this.filters.places.length && !this.filters.tags.length) {
if (this.filters.show_recurrent) {
return this.events
}
events = events.filter(e => !e.parentId)
}
return events.filter(e => {
// check tags intersection
if (this.filters.tags.length) {
const ret = intersection(this.filters.tags, e.tags)
if (!ret.length) { return false }
}
// check if place is in filtered places
if (this.filters.places.length && !this.filters.places.includes(e.place.id)) {
return false
}
return true
})
},
...mapState(['settings', 'announcements']),
visibleEvents () {
const now = dayjs().unix()
if (this.selectedDay) {
const min = dayjs(this.selectedDay).startOf('day').unix()
const max = dayjs(this.selectedDay).endOf('day').unix()
return this.filteredEvents.filter(e => (e.start_datetime < max && e.start_datetime > min))
return this.events.filter(e => (e.start_datetime <= max && e.start_datetime >= min))
} else if (this.isCurrentMonth) {
return this.filteredEvents.filter(e => e.end_datetime ? e.end_datetime > now : e.start_datetime + 2 * 60 * 60 > now)
return this.events.filter(e => e.end_datetime ? e.end_datetime > now : e.start_datetime + 2 * 60 * 60 > now)
} else {
return this.filteredEvents
return this.events
}
}
},
methods: {
// onIntersect (isIntersecting, eventId) {
// this.intersecting[eventId] = isIntersecting
// },
...mapActions(['setFilters']),
destroy (id) {
this.events = this.events.filter(e => e.id !== id)
},
@ -131,20 +102,6 @@ export default {
this.$nuxt.$loading.finish()
})
},
placeClick (place_id) {
if (this.filters.places.includes(place_id)) {
this.setFilters({ ...this.filters, places: this.filters.places.filter(p_id => p_id !== place_id) })
} else {
this.setFilters({ ...this.filters, places: [].concat(this.filters.places, place_id) })
}
},
tagClick (tag) {
if (this.filters.tags.includes(tag)) {
this.setFilters({ ...this.filters, tags: this.filters.tags.filter(t => t !== tag) })
} else {
this.setFilters({ ...this.filters, tags: [].concat(this.filters.tags, tag) })
}
},
monthChange ({ year, month }) {
// avoid first time monthChange event (onload)
if (this.first) {
@ -158,24 +115,20 @@ export default {
this.selectedDay = null
// check if current month is selected
if (month - 1 === dayjs().month() && year === dayjs().year()) {
if (month - 1 === dayjs.tz().month() && year === dayjs.tz().year()) {
this.isCurrentMonth = true
this.start = dayjs().startOf('month').unix()
this.date = dayjs().format('YYYY-MM-DD')
this.date = dayjs.tz().format('YYYY-MM-DD')
} else {
this.isCurrentMonth = false
this.date = ''
this.start = dayjs().year(year).month(month - 1).startOf('month').unix() // .startOf('week').unix()
}
// TODO: check if calendar view is double
this.end = dayjs().year(year).month(month).endOf('month').unix() // .endOf('week').unix()
this.updateEvents()
},
updateFilters (filters) {
this.setFilters(filters)
},
dayChange (day) {
this.selectedDay = day ? dayjs(day).format('YYYY-MM-DD') : null
this.selectedDay = day ? dayjs.tz(day).format('YYYY-MM-DD') : null
}
}
}

30
pages/p/_place.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<v-container id='home' fluid>
<h1 class='d-block text-h4 font-weight-black text-center text-uppercase mt-5 mx-auto w-100 text-underline'><u>{{place.name}}</u></h1>
<span class="d-block text-subtitle text-center w-100 mb-14">{{place.address}}</span>
<!-- Events -->
<div class="mb-2 mt-1 pl-1 pl-sm-2" id="events">
<Event :event='event' v-for='(event, idx) in events' :lazy='idx>2' :key='event.id'></Event>
</div>
</v-container>
</template>
<script>
import Event from '@/components/Event'
export default {
name: 'Tag',
components: { Event },
asyncData ({ $axios, params, error }) {
try {
const place = params.place
return $axios.$get(`/place/${place}/events`)
} catch (e) {
error({ statusCode: 400, message: 'Error!' })
}
}
}
</script>

42
pages/search.vue Normal file
View file

@ -0,0 +1,42 @@
<template lang="pug">
v-container#home(fluid)
v-form.ma-5(to='/search' action='/search' method='GET')
v-text-field(name='search' :label='$t("common.search")' :value='$route.query.search' hide-details outlined rounded :append-icon='mdiMagnify')
//- Events
#events.mb-2.mt-1.pl-1.pl-sm-2
Event(:event='event' @destroy='destroy' v-for='(event, idx) in events' :lazy='idx>2' :key='event.id')
</template>
<script>
import { mapState } from 'vuex'
import dayjs from 'dayjs'
import Event from '@/components/Event'
import Announcement from '@/components/Announcement'
import Calendar from '@/components/Calendar'
import { mdiMagnify } from '@mdi/js'
export default {
name: 'Index',
components: { Event, Announcement, Calendar },
data () {
return {
mdiMagnify,
events: [],
start: dayjs().startOf('month').unix(),
end: null,
}
},
async fetch () {
const search = this.$route.query.search
this.events = await this.$axios.$get(`/event/search?search=${search}`)
},
computed: mapState(['settings']),
methods: {
destroy (id) {
this.events = this.events.filter(e => e.id !== id)
}
}
}
</script>

View file

@ -1,41 +0,0 @@
<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-alert.mb-3.mt-1(outlined type='warning' color='red' show-icon :icon='mdiAlert') {{$t('setup.copy_password_dialog')}}
v-card-actions
v-btn(text @click='next' color='primary' :loading='loading' :disabled='loading') {{$t('setup.start')}}
v-icon(v-text='mdiArrowRight')
</template>
<script>
import { mdiArrowRight, mdiAlert } from '@mdi/js'
export default {
data () {
return {
mdiArrowRight, mdiAlert,
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>

View file

@ -1,32 +1,26 @@
<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(v-show='!dbdone' :complete='step > 1' step='1') Database
v-divider(v-show='!dbdone')
v-stepper-step(:complete='step > 2' step='2') Configuration
v-divider
v-stepper-step(:complete='step > 3' step='3') Finish
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(v-show='!dbdone' :complete='step > 1' step='1') Database
v-divider(v-show='!dbdone')
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(v-show='!dbdone' step='1')
DbStep(@complete='dbCompleted')
v-stepper-content(step='2')
Settings(setup, @complete='configCompleted')
v-stepper-content(step='3')
Completed(ref='completed')
v-stepper-items
v-stepper-content(v-show='!dbdone' step='1')
DbStep(@complete='dbCompleted')
v-stepper-content(step='2')
Settings(setup, @complete='configCompleted')
v-stepper-content(step='3')
Completed(ref='completed' :isHttp='isHttp')
</template>
<script>
import DbStep from './DbStep'
import Settings from '../../components/admin/Settings'
import Completed from './Completed'
import DbStep from '@/components/DbStep'
import Settings from '@/components/admin/Settings'
import Completed from '@/components/Completed'
export default {
components: { DbStep, Settings, Completed },
@ -36,9 +30,10 @@ export default {
title: 'Setup',
},
auth: false,
asyncData ({ params }) {
asyncData ({ params, req }) {
const protocol = process.client ? window.location.protocol : req.protocol + ':'
return {
isHttp: protocol === 'http:',
dbdone: !!Number(params.db),
config: {
db: {

30
pages/tag/_tag.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<v-container id='home' fluid>
<h1 class='d-block text-h3 font-weight-black text-center align-center text-uppercase mt-5 mb-16 mx-auto w-100 text-underline'><u>{{tag}}</u></h1>
<!-- Events -->
<div class="mb-2 mt-1 pl-1 pl-sm-2" id="events">
<Event :event='event' v-for='(event, idx) in events' :lazy='idx>2' :key='event.id'></Event>
</div>
</v-container>
</template>
<script>
import Event from '@/components/Event'
export default {
name: 'Tag',
components: { Event },
async asyncData ({ $axios, params, error }) {
try {
const tag = params.tag
const events = await $axios.$get(`/events?tags=${tag}`)
return { events, tag }
} catch (e) {
error({ statusCode: 400, message: 'Error!' })
}
}
}
</script>

View file

@ -10,7 +10,7 @@ export default ({ $axios }, inject) => {
* end_datetime: unix_timestamp
* tags: [tag, list],
* places: [place_id],
* limit: (default )
* max: (default )
* }
*
*/
@ -22,7 +22,8 @@ export default ({ $axios }, inject) => {
end: params.end,
places: params.places && params.places.join(','),
tags: params.tags && params.tags.join(','),
show_recurrent: !!params.show_recurrent
show_recurrent: !!params.show_recurrent,
max: params.maxs
}
})
return events.map(e => Object.freeze(e))

View file

@ -7,12 +7,15 @@ import localizedFormat from 'dayjs/plugin/localizedFormat'
import 'dayjs/locale/it'
import 'dayjs/locale/en'
import 'dayjs/locale/es'
import 'dayjs/locale/ca'
import 'dayjs/locale/pl'
import 'dayjs/locale/eu'
import 'dayjs/locale/nb'
import 'dayjs/locale/fr'
import 'dayjs/locale/de'
import 'dayjs/locale/gl'
dayjs.extend(relativeTime)
dayjs.extend(utc)
@ -23,25 +26,27 @@ export default ({ app, store }) => {
// set timezone to instance_timezone!!
// to show local time relative to event's place
// not where in the world I'm looking at the page from
dayjs.tz.setDefault(store.state.settings.instance_timezone)
dayjs.locale(store.state.locale)
const instance_timezone = store.state.settings.instance_timezone
const locale = store.state.locale
dayjs.tz.setDefault(instance_timezone)
dayjs.locale(locale)
// replace links with anchors
// TODO: remove fb tracking id?
Vue.filter('linkify', value => value.replace(/(https?:\/\/([^\s]+))/g, '<a href="$1">$2</a>'))
Vue.filter('url2host', url => url.match(/^https?:\/\/(.[^/:]+)/i)[1])
Vue.filter('datetime', value => dayjs(value).locale(store.state.locale).format('ddd, D MMMM HH:mm'))
Vue.filter('dateFormat', (value, format) => dayjs(value).format(format))
Vue.filter('unixFormat', (timestamp, format) => dayjs.unix(timestamp).format(format))
Vue.filter('datetime', value => dayjs.tz(value).locale(locale).format('ddd, D MMMM HH:mm'))
Vue.filter('dateFormat', (value, format) => dayjs.tz(value).format(format))
Vue.filter('unixFormat', (timestamp, format) => dayjs.unix(timestamp).tz(instance_timezone).format(format))
// shown in mobile homepage
Vue.filter('day', value => dayjs.unix(value).locale(store.state.locale).format('dddd, D MMM'))
Vue.filter('mediaURL', (event, type) => {
Vue.filter('day', value => dayjs.unix(value).tz(instance_timezone).locale(store.state.locale).format('dddd, D MMM'))
Vue.filter('mediaURL', (event, type, format = '.jpg') => {
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
return store.state.settings.baseurl + '/media/' + (type === 'thumb' ? 'thumb/' : '') + event.media[0].url.replace(/.jpg$/, format)
}
} else if (type !== 'alt') {
return store.state.settings.baseurl + '/media/' + (type === 'thumb' ? 'thumb/' : '') + 'logo.svg'
@ -49,16 +54,16 @@ export default ({ app, store }) => {
return ''
})
Vue.filter('from', timestamp => dayjs.unix(timestamp).fromNow())
Vue.filter('from', timestamp => dayjs.unix(timestamp).tz(instance_timezone).fromNow())
Vue.filter('recurrentDetail', event => {
const parent = event.parent
const { frequency, type } = parent.recurrent
let recurrent
if (frequency === '1w' || frequency === '2w') {
recurrent = app.i18n.t(`event.recurrent_${frequency}_days`, { days: dayjs.unix(parent.start_datetime).format('dddd') })
recurrent = app.i18n.t(`event.recurrent_${frequency}_days`, { days: dayjs.unix(parent.start_datetime).tz(instance_timezone).format('dddd') })
} else if (frequency === '1m' || frequency === '2m') {
const d = type === 'ordinal' ? dayjs.unix(parent.start_datetime).date() : dayjs.unix(parent.start_datetime).format('dddd')
const d = type === 'ordinal' ? dayjs.unix(parent.start_datetime).date() : dayjs.unix(parent.start_datetime).tz(instance_timezone).format('dddd')
if (type === 'ordinal') {
recurrent = app.i18n.t(`event.recurrent_${frequency}_days`, { days: d })
} else {
@ -70,8 +75,8 @@ export default ({ app, store }) => {
})
Vue.filter('when', (event) => {
const start = dayjs.unix(event.start_datetime)
const end = dayjs.unix(event.end_datetime)
const start = dayjs.unix(event.start_datetime).tz(instance_timezone)
const end = dayjs.unix(event.end_datetime).tz(instance_timezone)
// const normal = `${start.format('dddd, D MMMM (HH:mm-')}${end.format('HH:mm) ')}`
// // recurrent event
@ -90,10 +95,10 @@ export default ({ app, store }) => {
// multidate
if (event.multidate) {
return `${start.format('ddd, D MMM HH:mm')} - ${end.format('ddd, D MMM')}`
return `${start.format('ddd, D MMM HH:mm')} - ${end.format('ddd, D MMM HH:mm')}`
}
// normal event
return start.format('ddd, D MMMM HH:mm')
return `${start.format('ddd, D MMM HH:mm')} - ${end.format('HH:mm')}`
})
}

View file

@ -0,0 +1,191 @@
const Cohort = require('../models/cohort')
const Filter = require('../models/filter')
const Event = require('../models/event')
const Tag = require('../models/tag')
const Place = require('../models/place')
const log = require('../../log')
const dayjs = require('dayjs')
// const { sequelize } = require('../models/index')
const { Op, Sequelize } = require('sequelize')
const cohortController = {
async getAll (req, res) {
const withFilters = req.query.withFilters
let cohorts
if (withFilters) {
cohorts = await Cohort.findAll({ include: [Filter] })
} else {
cohorts = await Cohort.findAll()
}
return res.json(cohorts)
},
// return events from cohort
async getEvents (req, res) {
const name = req.params.name
const cohort = await Cohort.findOne({ where: { name } })
if (!cohort) {
return res.sendStatus(404)
}
const filters = await Filter.findAll({ where: { cohortId: cohort.id } })
const start = dayjs().unix()
const where = {
// do not include parent recurrent event
recurrent: null,
// confirmed event only
is_visible: true,
// [Op.or]: {
start_datetime: { [Op.gte]: start },
// end_datetime: { [Op.gte]: start }
// }
}
// if (!show_recurrent) {
// where.parentId = null
// }
// if (end) {
// where.start_datetime = { [Op.lte]: end }
// }
const replacements = []
const ors = []
filters.forEach(f => {
if (f.tags && f.tags.length) {
const tags = Sequelize.fn('EXISTS', Sequelize.literal('SELECT 1 FROM event_tags WHERE "event_tags"."eventId"="event".id AND "tagTag" in (?)'))
replacements.push(f.tags)
if (f.places && f.places.length) {
ors.push({ [Op.and]: [ { placeId: f.places.map(p => p.id) },tags] })
} else {
ors.push(tags)
}
} else if (f.places && f.places.length) {
ors.push({ placeId: f.places.map(p => p.id) })
}
})
// if (tags && places) {
// where[Op.or] = {
// placeId: places ? places.split(',') : [],
// // '$tags.tag$': Sequelize.literal(`EXISTS (SELECT 1 FROM event_tags WHERE tagTag in ( ${Sequelize.QueryInterface.escape(tags)} ) )`)
// }
// } else if (tags) {
// where[Op.and] = Sequelize.literal(`EXISTS (SELECT 1 FROM event_tags WHERE event_tags.eventId=event.id AND tagTag in (?))`)
// replacements.push(tags)
// } else if (places) {
// where.placeId = places.split(',')
// }
if (ors.length) {
where[Op.or] = ors
}
const events = await Event.findAll({
logging: console.log,
where,
attributes: {
exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'updatedAt', 'description', 'resources']
},
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'],
through: { attributes: [] }
},
{ model: Place, required: true, attributes: ['id', 'name', 'address'] }
],
// limit: max,
replacements
}).catch(e => {
log.error('[EVENT]', e)
return []
})
const ret = events.map(e => {
e = e.get()
e.tags = e.tags ? e.tags.map(t => t && t.tag) : []
return e
})
return res.json(ret)
},
async add (req, res) {
const cohortDetail = {
name: req.body.name,
isActor: true,
isTop: true
}
// TODO: validation
log.info('Create cohort: ' + req.body.name)
const cohort = await Cohort.create(cohortDetail)
res.json(cohort)
},
async remove (req, res) {
const cohort_id = req.params.id
log.info('Remove cohort', cohort_id)
try {
const cohort = await Cohort.findByPk(cohort_id)
await cohort.destroy()
res.sendStatus(200)
} catch (e) {
log.error('Remove cohort failed:', e)
res.sendStatus(404)
}
},
async getFilters (req, res) {
const cohortId = req.params.cohort_id
const filters = await Filter.findAll({ where: { cohortId } })
return res.json(filters)
},
async addFilter (req, res) {
const cohortId = req.body.cohortId
const tags = req.body.tags
const places = req.body.places
try {
const filter = await Filter.create({ cohortId, tags, places })
return res.json(filter)
} catch (e) {
log.error(String(e))
return res.status(500)
}
},
async removeFilter (req, res) {
const filter_id = req.params.id
log.info('Remove filter', filter_id)
try {
const filter = await Filter.findByPk(filter_id)
await filter.destroy()
res.sendStatus(200)
} catch (e) {
log.error('Remove filter failed:', e)
res.sendStatus(404)
}
},
}
module.exports = cohortController

View file

@ -8,7 +8,6 @@ const linkifyHtml = require('linkify-html')
const Sequelize = require('sequelize')
const dayjs = require('dayjs')
const helpers = require('../../helpers')
const settingsController = require('./settings')
const Event = require('../models/event')
const Resource = require('../models/resource')
@ -23,31 +22,110 @@ const log = require('../../log')
const eventController = {
async _getMeta () {
async searchMeta (req, res) {
const search = req.query.search
const places = await Place.findAll({
order: [[Sequelize.literal('weigth'), 'DESC']],
attributes: {
include: [[Sequelize.fn('count', Sequelize.col('events.placeId')), 'weigth']],
exclude: ['createdAt', 'updatedAt']
order: [[Sequelize.col('w'), 'DESC']],
where: {
[Op.or]: [
{ name: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%' )},
{ address: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('address')), 'LIKE', '%' + search + '%')},
]
},
attributes: [['name', 'label'], 'address', 'id', [Sequelize.cast(Sequelize.fn('COUNT', Sequelize.col('events.placeId')),'INTEGER'), 'w']],
include: [{ model: Event, where: { is_visible: true }, required: true, attributes: [] }],
group: ['place.id']
group: ['place.id'],
raw: true
})
const tags = await Tag.findAll({
order: [[Sequelize.literal('w'), 'DESC']],
attributes: {
include: [[Sequelize.fn('COUNT', Sequelize.col('tag.tag')), 'w']]
order: [[Sequelize.col('w'), 'DESC']],
where: {
tag: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('tag')), 'LIKE', '%' + search + '%'),
},
attributes: [['tag','label'], [Sequelize.cast(Sequelize.fn('COUNT', Sequelize.col('tag.tag')), 'INTEGER'), 'w']],
include: [{ model: Event, where: { is_visible: true }, attributes: [], through: { attributes: [] }, required: true }],
group: ['tag.tag']
group: ['tag.tag'],
raw: true
})
return { places, tags }
const ret = places.map(p => {
p.type = 'place'
return p
}).concat(tags.map(t => {
t.type = 'tag'
return t
})).sort( (a, b) => b.w - a.w).slice(0, 10)
return res.json(ret)
},
async getMeta (req, res) {
res.json(await eventController._getMeta())
async search (req, res) {
const search = req.query.search.trim().toLocaleLowerCase()
const show_recurrent = req.query.show_recurrent || false
const end = req.query.end
const replacements = []
const where = {
// do not include parent recurrent event
recurrent: null,
// confirmed event only
is_visible: true,
}
if (!show_recurrent) {
where.parentId = null
}
if (end) {
where.start_datetime = { [Op.lte]: end }
}
if (search) {
replacements.push(search)
where[Op.or] =
[
{ title: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('title')), 'LIKE', '%' + search + '%') },
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%'),
Sequelize.fn('EXISTS', Sequelize.literal('SELECT 1 FROM event_tags WHERE "event_tags"."eventId"="event".id AND "tagTag" = ?'))
]
}
const events = await Event.findAll({
where,
attributes: {
exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'updatedAt', 'description', 'resources']
},
order: [['start_datetime', 'DESC']],
include: [
{
model: Tag,
order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')],
attributes: ['tag'],
through: { attributes: [] }
},
{ model: Place, required: true, attributes: ['id', 'name', 'address'] }
],
replacements,
limit: 30,
}).catch(e => {
log.error('[EVENT]', e)
return res.json([])
})
const ret = events.map(e => {
e = e.get()
e.tags = e.tags ? e.tags.map(t => t && t.tag) : []
return e
})
return res.json(ret)
},
async getNotifications (event, action) {
@ -75,14 +153,7 @@ const eventController = {
const notifications = await Notification.findAll({ where: { action }, include: [Event] })
// get notification that matches with selected event
const ret = notifications.filter(notification => match(event, notification.filters))
return ret
},
async updatePlace (req, res) {
const place = await Place.findByPk(req.body.id)
await place.update(req.body)
res.json(place)
return notifications.filter(notification => match(event, notification.filters))
},
async _get(slug) {
@ -290,8 +361,8 @@ const eventController = {
res.sendStatus(200)
},
async isAnonEventAllowed (req, res, next) {
if (!res.locals.settings.allow_anon_event) {
async isAnonEventAllowed (_req, res, next) {
if (!res.locals.settings.allow_anon_event && !res.locals.user) {
return res.sendStatus(403)
}
next()
@ -308,16 +379,33 @@ const eventController = {
const body = req.body
const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null
const required_fields = [ 'title', 'place_name', 'start_datetime']
const missing_field = required_fields.find(required_field => !body[required_field])
const required_fields = [ 'title', 'start_datetime']
let missing_field = required_fields.find(required_field => !body[required_field])
if (missing_field) {
log.warn(`${missing_field} is required`)
return res.status(400).send(`${missing_field} is required`)
log.warn(`${missing_field} required`)
return res.status(400).send(`${missing_field} required`)
}
// find or create the place
let place
if (body.place_id) {
place = await Place.findByPk(body.place_id)
} else {
place = await Place.findOne({ where: { name: body.place_name.trim() }})
if (!place) {
if (!body.place_address || !body.place_name) {
return res.status(400).send(`place_id or place_name and place_address required`)
}
place = await Place.create({
name: body.place_name,
address: body.place_address
})
}
}
const eventDetails = {
title: body.title,
// remove html tags
// sanitize and linkify html
description: helpers.sanitizeHTML(linkifyHtml(body.description || '')),
multidate: body.multidate,
start_datetime: body.start_datetime,
@ -328,17 +416,16 @@ const eventController = {
}
if (req.file || body.image_url) {
let url
if (req.file) {
url = req.file.filename
} else {
url = await helpers.getImageFromURL(body.image_url)
if (!req.file && body.image_url) {
req.file = 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,
url: req.file.filename,
height: req.file.height,
width: req.file.width,
name: body.image_name || body.title || '',
focalpoint: [parseFloat(focalpoint[0]), parseFloat(focalpoint[1])]
}]
@ -346,24 +433,16 @@ const eventController = {
eventDetails.media = []
}
const event = await Event.create(eventDetails)
const [place] = await Place.findOrCreate({
where: { name: body.place_name },
defaults: {
address: body.place_address
}
})
let event = await Event.create(eventDetails)
await event.setPlace(place)
event.place = place
// create/assign tags
if (body.tags) {
body.tags = body.tags.map(t => t.trim())
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)
event.tags = tags
}
// associate user to event and reverse
@ -372,6 +451,9 @@ const eventController = {
await event.setUser(res.locals.user)
}
event = event.get()
event.tags = body.tags
event.place = place
// return created event to the client
res.json(event)
@ -405,47 +487,44 @@ const eventController = {
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' })),
title: body.title || event.title,
// sanitize and linkify html
description: helpers.sanitizeHTML(linkifyHtml(body.description, { target: '_blank' })) || event.description,
multidate: body.multidate,
start_datetime: body.start_datetime,
end_datetime: body.end_datetime,
recurrent
}
// remove old media in case a new one is uploaded
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 {
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)
fs.unlinkSync(old_path)
fs.unlinkSync(old_thumb_path)
} catch (e) {
log.info(e.toString())
}
}
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
}
}
if (url && !event.recurrent) {
// modify associated media only if a new file is uploaded or remote image_url is used
if (req.file || (body.image_url && /^https?:\/\//.test(body.image_url))) {
if (body.image_url) {
req.file = await helpers.getImageFromURL(body.image_url)
}
const focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0']
eventDetails.media = [{
url,
name: body.image_name || '',
url: req.file.filename,
height: req.file.height,
width: req.file.width,
name: body.image_name || body.title || '',
focalpoint: [parseFloat(focalpoint[0].slice(0, 6)), parseFloat(focalpoint[1].slice(0, 6))]
}]
} else {
} else if (!body.image) {
eventDetails.media = []
}
await event.update(eventDetails)
const [place] = await Place.findOrCreate({
where: { name: body.place_name },
@ -481,9 +560,9 @@ const eventController = {
// check if event is mine (or user is admin)
if (event && (res.locals.user.is_admin || res.locals.user.id === event.userId)) {
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 {
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)
fs.unlinkSync(old_thumb_path)
fs.unlinkSync(old_path)
} catch (e) {
@ -523,22 +602,22 @@ const eventController = {
if (!show_recurrent) {
where.parentId = null
}
if (end) {
where.start_datetime = { [Op.lte]: end }
}
const replacements = []
if (tags && places) {
where[Op.or] = {
placeId: places ? places.split(',') : [],
'$tags.tag$': tags.split(',')
// '$tags.tag$': Sequelize.literal(`EXISTS (SELECT 1 FROM event_tags WHERE tagTag in ( ${Sequelize.QueryInterface.escape(tags)} ) )`)
}
}
if (tags) {
where['$tags.tag$'] = tags.split(',')
}
if (places) {
} else if (tags) {
// where[Op.and] = Sequelize.literal(`EXISTS (SELECT 1 FROM event_tags WHERE eventId=event.id AND tagTag in (?))`)
where[Op.and] = Sequelize.fn('EXISTS', Sequelize.literal('SELECT 1 FROM event_tags WHERE "event_tags"."eventId"="event".id AND "tagTag" in (?)'))
replacements.push(tags)
} else if (places) {
where.placeId = places.split(',')
}
@ -554,12 +633,12 @@ const eventController = {
model: Tag,
order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')],
attributes: ['tag'],
required: !!tags,
through: { attributes: [] }
},
{ model: Place, required: true, attributes: ['id', 'name', 'address'] }
],
limit: max
limit: max,
replacements
}).catch(e => {
log.error('[EVENT]', e)
return []

View file

@ -2,6 +2,7 @@ const Event = require('../models/event')
const Place = require('../models/place')
const Tag = require('../models/tag')
const { htmlToText } = require('html-to-text')
const { Op, literal } = require('sequelize')
const moment = require('dayjs')
const ics = require('ics')
@ -68,7 +69,7 @@ const exportController = {
}
},
feed (req, res, events) {
feed (_req, res, events) {
const settings = res.locals.settings
res.type('application/rss+xml; charset=UTF-8')
res.render('feed/rss.pug', { events, settings, moment })
@ -79,7 +80,7 @@ const exportController = {
* @param {*} events array of events from sequelize
* @param {*} alarms https://github.com/adamgibbons/ics#attributes (alarms)
*/
ics (req, res, events, alarms = []) {
ics (_req, res, events, alarms = []) {
const settings = res.locals.settings
const eventsMap = events.map(e => {
const tmpStart = moment.unix(e.start_datetime)
@ -88,13 +89,14 @@ const exportController = {
const end = tmpEnd.utc(true).format('YYYY-M-D-H-m').split('-').map(Number)
return {
start,
// startOutputType: 'utc',
end,
// endOutputType: 'utc',
title: `[${settings.title}] ${e.title}`,
description: e.description,
description: htmlToText(e.description),
htmlContent: e.description,
location: `${e.place.name} - ${e.place.address}`,
url: `${settings.baseurl}/event/${e.slug || e.id}`,
status: 'CONFIRMED',
categories: e.tags.map(t => t.tag),
alarms
}
})

View file

@ -137,8 +137,7 @@ const oauthController = {
code.userId = user.id
code.clientId = client.id
code.expiresAt = dayjs(code.expiresAt).toDate()
const ret = await OAuthCode.create(code)
return ret
return OAuthCode.create(code)
},
// TODO

View file

@ -0,0 +1,59 @@
const dayjs = require('dayjs')
const Place = require('../models/place')
const Event = require('../models/event')
const eventController = require('./event')
const log = require('../../log')
const { Op, where, col, fn, cast } = require('sequelize')
module.exports = {
async getEvents (req, res) {
const name = req.params.placeName
const place = await Place.findOne({ where: { name }})
if (!place) {
log.warn(`Place ${name} not found`)
return res.sendStatus(404)
}
const start = dayjs().unix()
const events = await eventController._select({ start, places: `${place.id}`, show_recurrent: true})
return res.json({ events, place })
},
async updatePlace (req, res) {
const place = await Place.findByPk(req.body.id)
await place.update(req.body)
res.json(place)
},
async getAll (_req, res) {
const places = await Place.findAll({
order: [[cast(fn('COUNT', col('events.placeId')),'INTEGER'), 'DESC']],
include: [{ model: Event, where: { is_visible: true }, required: true, attributes: [] }],
group: ['place.id'],
raw: true
})
return res.json(places)
},
async get (req, res) {
const search = req.query.search.toLocaleLowerCase()
const places = await Place.findAll({
order: [[cast(fn('COUNT', col('events.placeId')),'INTEGER'), 'DESC']],
where: {
[Op.or]: [
{ name: where(fn('LOWER', col('name')), 'LIKE', '%' + search + '%' )},
{ address: where(fn('LOWER', col('address')), 'LIKE', '%' + search + '%')},
]
},
attributes: ['name', 'address', 'id'],
include: [{ model: Event, where: { is_visible: true }, required: true, attributes: [] }],
group: ['place.id'],
raw: true
})
// TOFIX: don't know why limit does not work
return res.json(places.slice(0, 10))
}
}

View file

@ -5,7 +5,6 @@ const crypto = require('crypto')
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')
@ -42,7 +41,7 @@ const defaultSettings = {
{ href: '/about', label: 'about' }
],
admin_email: config.admin_email || '',
smtp: config.smtp || false
smtp: config.smtp || {}
}
/**
@ -185,7 +184,7 @@ const settingsController = {
return sharp(uploadedPath)
.resize(400)
.png({ quality: 90 })
.toFile(baseImgPath + '.png', (err, info) => {
.toFile(baseImgPath + '.png', (err) => {
if (err) {
log.error('[LOGO] ' + err)
}

View file

@ -0,0 +1,47 @@
const dayjs = require('dayjs')
const Tag = require('../models/tag')
const Event = require('../models/event')
const eventController = require('./event')
const Sequelize = require('sequelize')
module.exports = {
// async getEvents (req, res) {
// const name = req.params.placeName
// const place = await Place.findOne({ where: { name }})
// if (!place) {
// log.warn(`Place ${name} not found`)
// return res.sendStatus(404)
// }
// const start = dayjs().unix()
// const events = await eventController._select({ start, places: `${place.id}`, show_recurrent: true})
// return res.json({ events, place })
// },
async get (req, res) {
const search = req.query.search
console.error(search)
const tags = await Tag.findAll({
order: [[Sequelize.fn('COUNT', Sequelize.col('tag.tag')), 'DESC']],
attributes: ['tag'],
where: {
tag: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('tag')), 'LIKE', '%' + search + '%'),
},
include: [{ model: Event, where: { is_visible: true }, attributes: [], through: { attributes: [] }, required: true }],
group: ['tag.tag'],
limit: 10,
subQuery:false
})
return res.json(tags.map(t => t.tag))
}
// async getPlaces (req, res) {
// const search = req.params.search
// const places = await Place.findAll({ where: {
// [Op.or]: [
// { name: }
// ]
// }})
// }
}

View file

@ -123,7 +123,12 @@ const userController = {
async remove (req, res) {
try {
const user = await User.findByPk(req.params.id)
let user
if (res.locals.user.is_admin && req.params.id) {
user = await User.findByPk(req.params.id)
} else {
user = await User.findByPk(res.locals.user.id)
}
await user.destroy()
log.warn(`User ${user.email} removed!`)
res.sendStatus(200)

View file

@ -23,6 +23,8 @@ if (config.status !== 'READY') {
const { isAuth, isAdmin } = require('./auth')
const eventController = require('./controller/event')
const placeController = require('./controller/place')
const tagController = require('./controller/tag')
const settingsController = require('./controller/settings')
const exportController = require('./controller/export')
const userController = require('./controller/user')
@ -31,6 +33,7 @@ if (config.status !== 'READY') {
const resourceController = require('./controller/resource')
const oauthController = require('./controller/oauth')
const announceController = require('./controller/announce')
const cohortController = require('./controller/cohort')
const helpers = require('../helpers')
const storage = require('./storage')
const upload = multer({ storage })
@ -72,14 +75,11 @@ if (config.status !== 'READY') {
// delete user
api.delete('/user/:id', isAdmin, userController.remove)
api.delete('/user', isAdmin, userController.remove)
api.delete('/user', isAuth, userController.remove)
// get all users
api.get('/users', isAdmin, userController.getAll)
// update a place (modify address..)
api.put('/place', isAdmin, eventController.updatePlace)
/**
* Get events
* @category Event
@ -120,6 +120,8 @@ if (config.status !== 'READY') {
// allow anyone to add an event (anon event has to be confirmed, TODO: flood protection)
api.post('/event', eventController.isAnonEventAllowed, upload.single('image'), eventController.add)
api.get('/event/search', eventController.search)
api.put('/event', isAuth, upload.single('image'), eventController.update)
api.get('/event/import', isAuth, helpers.importURL)
@ -127,7 +129,7 @@ if (config.status !== 'READY') {
api.delete('/event/:id', isAuth, eventController.remove)
// get tags/places
api.get('/event/meta', eventController.getMeta)
api.get('/event/meta', eventController.searchMeta)
// get unconfirmed events
api.get('/event/unconfirmed', isAdmin, eventController.getUnconfirmed)
@ -150,6 +152,15 @@ if (config.status !== 'READY') {
// export events (rss/ics)
api.get('/export/:type', cors, exportController.export)
api.get('/place/:placeName/events', cors, placeController.getEvents)
api.get('/place/all', isAdmin, placeController.getAll)
api.get('/place', cors, placeController.get)
api.put('/place', isAdmin, placeController.updatePlace)
api.get('/tag', cors, tagController.get)
// - FEDIVERSE INSTANCES, MODERATION, RESOURCES
api.get('/instances', isAdmin, instanceController.getAll)
api.get('/instances/:instance_domain', isAdmin, instanceController.get)
api.post('/instances/toggle_block', isAdmin, instanceController.toggleBlock)
@ -164,16 +175,25 @@ if (config.status !== 'READY') {
api.put('/announcements/:announce_id', isAdmin, announceController.update)
api.delete('/announcements/:announce_id', isAdmin, announceController.remove)
// - COHORT
api.get('/cohorts/:name', cohortController.getEvents)
api.get('/cohorts', cohortController.getAll)
api.post('/cohorts', isAdmin, cohortController.add)
api.delete('/cohort/:id', isAdmin, cohortController.remove)
api.get('/filter/:cohort_id', isAdmin, cohortController.getFilters)
api.post('/filter', isAdmin, cohortController.addFilter)
api.delete('/filter/:id', isAdmin, cohortController.removeFilter)
// 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))
api.use((_req, res) => res.sendStatus(404))
// Handle 500
api.use((error, req, res, next) => {
api.use((error, _req, res, _next) => {
log.error('[API ERROR]', error)
res.status(500).send('500: Internal Server Error')
})

View file

@ -9,7 +9,7 @@ const locales = require('../../locales')
const mail = {
send (addresses, template, locals, locale) {
locale = locale || settingsController.settings.instance_locale
if (process.env.NODE_ENV === 'production' && (!settingsController.settings.admin_email || !settingsController.settings.smtp)) {
if (process.env.NODE_ENV === 'production' && (!settingsController.settings.admin_email || !settingsController.settings.smtp || !settingsController.settings.smtp.user)) {
log.error(`Cannot send any email: SMTP Email configuration not completed!`)
return
}

View file

@ -0,0 +1,27 @@
const { Model, DataTypes } = require('sequelize')
const sequelize = require('./index').sequelize
class Cohort extends Model {}
Cohort.init({
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
unique: true,
index: true,
allowNull: false
},
isActor: {
type: DataTypes.BOOLEAN
},
isTop: {
type: DataTypes.BOOLEAN
}
}, { sequelize, modelName: 'cohort', timestamps: false })
module.exports = Cohort

View file

@ -1,5 +1,4 @@
const config = require('../../config')
const moment = require('dayjs')
const { htmlToText } = require('html-to-text')
const { Model, DataTypes } = require('sequelize')
@ -14,9 +13,12 @@ const Place = require('./place')
const User = require('./user')
const Tag = require('./tag')
const utc = require('dayjs/plugin/utc')
const dayjs = require('dayjs')
const timezone = require('dayjs/plugin/timezone')
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)
dayjs.extend(timezone)
class Event extends Model {}
@ -76,7 +78,7 @@ Event.prototype.toAP = function (username, locale, to = []) {
const plainDescription = htmlToText(this.description && this.description.replace('\n', '').slice(0, 1000))
const content = `
📍 ${this.place && this.place.name}
📅 ${moment.unix(this.start_datetime).locale(locale).format('dddd, D MMMM (HH:mm)')}
📅 ${dayjs.unix(this.start_datetime).tz().locale(locale).format('dddd, D MMMM (HH:mm)')}
${plainDescription}
`
@ -99,8 +101,8 @@ Event.prototype.toAP = function (username, locale, to = []) {
name: this.title,
url: `${config.baseurl}/event/${this.slug || this.id}`,
type: 'Event',
startTime: moment.unix(this.start_datetime).locale(locale).format(),
endTime: this.end_datetime ? moment.unix(this.end_datetime).locale(locale).format() : null,
startTime: dayjs.unix(this.start_datetime).tz().locale(locale).format(),
endTime: this.end_datetime ? dayjs.unix(this.end_datetime).tz().locale(locale).format() : null,
location: {
name: this.place.name,
address: this.place.address

View file

@ -0,0 +1,24 @@
const { Model, DataTypes } = require('sequelize')
const Cohort = require('./cohort')
const sequelize = require('./index').sequelize
class Filter extends Model {}
Filter.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
tags: {
type: DataTypes.JSON,
},
places: {
type: DataTypes.JSON,
}
}, { sequelize, modelName: 'filter', timestamps: false })
Filter.belongsTo(Cohort)
Cohort.hasMany(Filter)
module.exports = Filter

View file

@ -15,6 +15,17 @@ const db = {
connect (dbConf = config.db) {
log.debug(`Connecting to DB: ${JSON.stringify(dbConf)}`)
dbConf.dialectOptions = { autoJsonMap: false }
if (dbConf.dialect === 'sqlite') {
dbConf.retry = {
match: [
Sequelize.ConnectionError,
Sequelize.ConnectionTimedOutError,
Sequelize.TimeoutError,
/Deadlock/i,
/SQLITE_BUSY/],
max: 15
}
}
db.sequelize = new Sequelize(dbConf)
return db.sequelize.authenticate()
},
@ -39,7 +50,7 @@ const db = {
path: path.resolve(__dirname, '..', '..', 'migrations')
}
})
return await umzug.up()
return umzug.up()
},
async initialize () {
if (config.status === 'READY') {

View file

@ -12,48 +12,33 @@ try {
const DiskStorage = {
_handleFile (req, file, cb) {
const filename = crypto.randomBytes(16).toString('hex') + '.jpg'
const finalPath = path.resolve(config.upload_path, filename)
const thumbPath = path.resolve(config.upload_path, 'thumb', filename)
const outStream = fs.createWriteStream(finalPath)
const thumbStream = fs.createWriteStream(thumbPath)
const filename = crypto.randomBytes(16).toString('hex')
const sharpStream = sharp({ failOnError: true })
const promises = [
sharpStream.clone().resize(500, null, { withoutEnlargement: true }).jpeg({ mozjpeg: true, progressive: true }).toFile(path.resolve(config.upload_path, 'thumb', filename + '.jpg')),
sharpStream.clone().resize(1200, null, { withoutEnlargement: true } ).jpeg({ quality: 95, mozjpeg: true, progressive: true }).toFile(path.resolve(config.upload_path, filename + '.jpg')),
]
const resizer = sharp().resize(1200).jpeg({ quality: 98 })
const thumbnailer = sharp().resize(500).jpeg({ quality: 98 })
let onError = false
const err = e => {
if (onError) {
log.error('[UPLOAD]', err)
return
}
onError = true
log.error('[UPLOAD]', e)
req.err = e
cb(null)
}
file.stream
.pipe(thumbnailer)
.on('error', err)
.pipe(thumbStream)
.on('error', err)
file.stream
.pipe(resizer)
.on('error', err)
.pipe(outStream)
.on('error', err)
outStream.on('finish', () => {
cb(null, {
destination: config.upload_path,
filename,
path: finalPath,
size: outStream.bytesWritten
file.stream.pipe(sharpStream)
Promise.all(promises)
.then(res => {
const info = res[1]
cb(null, {
destination: config.upload_path,
filename: filename + '.jpg',
path: path.resolve(config.upload_path, filename + '.jpg'),
height: info.height,
width: info.width,
size: info.size,
})
})
.catch(err => {
console.error(err)
req.err = err
cb(null)
})
})
},
_removeFile (req, file, cb) {
_removeFile (_req, file, cb) {
delete file.destination
delete file.filename
fs.unlink(file.path, cb)

View file

@ -22,16 +22,17 @@ require('yargs')
.option('config', {
alias: 'c',
describe: 'Configuration file',
default: path.resolve(process.env.cwd, 'config.json')
})
.coerce('config', config_path => {
const absolute_config_path = path.resolve(process.env.cwd, config_path)
process.env.config_path = absolute_config_path
return absolute_config_path
})
.command(['accounts'], 'Manage accounts', accountsCLI)
default: path.resolve(process.env.cwd, 'config.json'),
coerce: config_path => {
const absolute_config_path = path.resolve(process.env.cwd, config_path)
process.env.config_path = absolute_config_path
return absolute_config_path
}})
.command(['start', 'run', '$0'], 'Start gancio', {}, start)
.command(['accounts'], 'Manage accounts', accountsCLI)
.help('h')
.alias('h', 'help')
.epilog('Made with ❤ by underscore hacklab - https://gancio.org')
.recommendCommands()
.demandCommand(1, '')
.argv

View file

@ -1,8 +1,9 @@
let db
function _initializeDB () {
const config = require('../config')
config.load()
config.log_level = 'error'
const db = require('../api/models/index')
db = require('../api/models/index')
return db.initialize()
}
@ -25,7 +26,30 @@ async function modify (args) {
}
}
async function add (args) {
async function create (args) {
await _initializeDB()
const User = require('../api/models/user')
console.error(args)
const user = await User.create({
email: args.email,
is_active: true,
is_admin: args.admin || false
})
console.error(user)
await db.close()
}
async function remove (args) {
await _initializeDB()
const User = require('../api/models/user')
const user = await User.findOne({
where: { email: args.email }
})
if (user) {
await user.destroy()
}
await db.close()
}
async function list () {
@ -33,22 +57,31 @@ async function list () {
const User = require('../api/models/user')
const users = await User.findAll()
console.log()
users.forEach(u => console.log(`${u.id}\tadmin: ${u.is_admin}\tenabled: ${u.is_active}\temail: ${u.email} - ${u.password}`))
users.forEach(u => console.log(`${u.id}\tadmin: ${u.is_admin}\tenabled: ${u.is_active}\temail: ${u.email}`))
console.log()
await db.close()
}
const accountsCLI = yargs => {
return yargs
const accountsCLI = yargs => yargs
.command('list', 'List all accounts', list)
.command('modify', 'Modify', {
account: {
describe: 'Account to modify'
describe: 'Account to modify',
type: 'string',
demandOption: true
},
'reset-password': {
describe: 'Resets the password of the given accoun '
describe: 'Resets the password of the given account ',
type: 'boolean'
}
}, modify)
.command('add', 'Add an account', {}, add)
}
.command('create <email|username>', 'Create an account', {
admin: { describe: 'Define this account as administrator', type: 'boolean' }
}, create)
.positional('email', { describe: '', type: 'string', demandOption: true })
.command('remove <email|username>', 'Remove an account', {}, remove)
.recommendCommands()
.demandCommand(1, '')
.argv
module.exports = accountsCLI

View file

@ -7,8 +7,8 @@ let config = {
baseurl: '',
hostname: '',
server: {
host: '0.0.0.0',
port: 13120
host: process.env.GANCIO_HOST || '0.0.0.0',
port: process.env.GANCIO_PORT || 13120
},
log_level: 'debug',
log_path: path.resolve(process.env.cwd || '', 'logs'),

View file

@ -1,3 +1,4 @@
// needed by sequelize
const config = require('./config')
config.load()
module.exports = config.db

View file

@ -20,7 +20,7 @@ const log = require('../log')
router.use(cors())
// is federation enabled? middleware
router.use((req, res, next) => {
router.use((_req, res, next) => {
if (settingsController.settings.enable_federation) { return next() }
log.debug('Federation disabled!')
return res.status(401).send('Federation disabled')
@ -29,14 +29,20 @@ router.use((req, res, next) => {
router.use(express.json({ type: ['application/json', 'application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'] }))
router.get('/m/:event_id', async (req, res) => {
const settingsController = require('../api/controller/settings')
log.debug('[AP] Get event details ')
const event_id = req.params.event_id
if (req.accepts('html')) { return res.redirect(301, `/event/${event_id}`) }
const acceptHtml = req.accepts('html', 'application/activity+json') === 'html'
if (acceptHtml) { return res.redirect(301, `/event/${event_id}`) }
const event = await Event.findByPk(req.params.event_id, { include: [User, Tag, Place] })
if (!event) { return res.status(404).send('Not found') }
return res.json(event.toAP(settingsController.settings.instance_name, settingsController.settings.instance_locale))
const eventAp = event.toAP(settingsController.settings.instance_name, settingsController.settings.instance_locale)
eventAp['@context'] = [
"https://www.w3.org/ns/activitystreams"
]
res.type('application/activity+json; charset=utf-8')
return res.json(eventAp)
})
// get any message coming from federation

View file

@ -5,6 +5,7 @@ const APUser = require('../api/models/ap_user')
const log = require('../log')
const helpers = require('../helpers')
const linkifyHtml = require('linkify-html')
const get = require('lodash/get')
module.exports = {
@ -59,7 +60,7 @@ module.exports = {
async remove (req, res) {
const resource = await Resource.findOne({
where: { activitypub_id: req.body.object.id },
where: { activitypub_id: get(req.body, 'object.id', req.body.object) },
include: [{ model: APUser, required: true, attributes: ['ap_id'] }]
})
if (!resource) {

View file

@ -28,7 +28,7 @@ router.get('/webfinger', allowFederation, (req, res) => {
}
const resource = req.query.resource
const domain = (new url.URL(settings.baseurl)).host
const domain = (new url.URL(res.locals.settings.baseurl)).host
const [, name, req_domain] = resource.match(/acct:(.*)@(.*)/)
if (domain !== req_domain) {
log.warn(`Bad webfinger request, requested domain "${req_domain}" instead of "${domain}"`)

View file

@ -7,7 +7,6 @@ const dayjs = require('dayjs')
const config = require('./config')
const log = require('./log')
const pkg = require('../package.json')
const fs = require('fs')
const path = require('path')
const sharp = require('sharp')
const axios = require('axios')
@ -95,8 +94,8 @@ module.exports = {
serveStatic () {
const router = express.Router()
// serve event's images/thumb
router.use('/media/', express.static(config.upload_path, { immutable: true, maxAge: '1y' } ))
// serve images/thumb
router.use('/media/', express.static(config.upload_path, { immutable: true, maxAge: '1y' } ), (_req, res) => res.sendStatus(404))
router.use('/noimg.svg', express.static('./static/noimg.svg'))
router.use('/logo.png', (req, res, next) => {
@ -112,7 +111,7 @@ module.exports = {
return router
},
logRequest (req, res, next) {
logRequest (req, _res, next) {
log.debug(`${req.method} ${req.path}`)
next()
},
@ -122,41 +121,34 @@ module.exports = {
if(!/^https?:\/\//.test(url)) {
throw Error('Hacking attempt?')
}
const filename = crypto.randomBytes(16).toString('hex') + '.jpg'
const finalPath = path.resolve(config.upload_path, filename)
const thumbPath = path.resolve(config.upload_path, 'thumb', filename)
const outStream = fs.createWriteStream(finalPath)
const thumbStream = fs.createWriteStream(thumbPath)
const resizer = sharp().resize(1200).jpeg({ quality: 95 })
const thumbnailer = sharp().resize(400).jpeg({ quality: 90 })
const filename = crypto.randomBytes(16).toString('hex')
const sharpStream = sharp({ failOnError: true })
const promises = [
sharpStream.clone().resize(500, null, { withoutEnlargement: true }).jpeg({ effort: 6, mozjpeg: true }).toFile(path.resolve(config.upload_path, 'thumb', filename + '.jpg')),
sharpStream.clone().resize(1200, null, { withoutEnlargement: true } ).jpeg({ quality: 95, effort: 6, mozjpeg: true}).toFile(path.resolve(config.upload_path, filename + '.jpg')),
]
const response = await axios({ method: 'GET', url, responseType: 'stream' })
const response = await axios({ method: 'GET', url: encodeURI(url), responseType: 'stream' })
return new Promise((resolve, reject) => {
let onError = false
const err = e => {
if (onError) {
return
response.data.pipe(sharpStream)
return Promise.all(promises)
.then(res => {
const info = res[1]
return {
destination: config.upload_path,
filename: filename + '.jpg',
path: path.resolve(config.upload_path, filename + '.jpg'),
height: info.height,
width: info.width,
size: info.size,
}
onError = true
reject(e)
}
response.data
.pipe(thumbnailer)
.on('error', err)
.pipe(thumbStream)
.on('error', err)
response.data
.pipe(resizer)
.on('error', err)
.pipe(outStream)
.on('error', err)
outStream.on('finish', () => resolve(filename))
})
})
.catch(err => {
log.error(err)
req.err = err
cb(null)
})
},
/**
@ -232,7 +224,8 @@ module.exports = {
},
async APRedirect (req, res, next) {
if (!req.accepts('html')) {
const acceptJson = req.accepts('html', 'application/activity+json') === 'application/activity+json'
if (acceptJson) {
const eventController = require('../server/api/controller/event')
const event = await eventController._get(req.params.slug)
if (event) {

View file

@ -1,14 +1,29 @@
const config = require('../server/config')
config.load()
module.exports = function () {
const config = require('../server/config')
config.load()
const log = require('../server/log')
const settingsController = require('./api/controller/settings')
const db = require('./api/models/index')
const dayjs = require('dayjs')
const timezone = require('dayjs/plugin/timezone')
async function start (nuxt) {
const initialize = {
// close connections/port/unix socket
async shutdown (exit = true) {
const log = require('../server/log')
const TaskManager = require('../server/taskManager').TaskManager
if (TaskManager) { TaskManager.stop() }
log.info('Closing DB')
const sequelize = require('../server/api/models')
await sequelize.close()
process.off('SIGTERM', initialize.shutdown)
process.off('SIGINT', initialize.shutdown)
if (exit) {
process.exit()
}
},
async start () {
const log = require('../server/log')
const settingsController = require('./api/controller/settings')
const db = require('./api/models/index')
const dayjs = require('dayjs')
const timezone = require('dayjs/plugin/timezone')
dayjs.extend(timezone)
if (config.status == 'READY') {
await db.initialize()
} else {
@ -18,18 +33,21 @@ module.exports = function () {
dialect: process.env.GANCIO_DB_DIALECT,
storage: process.env.GANCIO_DB_STORAGE,
host: process.env.GANCIO_DB_HOST,
port: process.env.GANCIO_DB_PORT,
database: process.env.GANCIO_DB_DATABASE,
username: process.env.GANCIO_DB_USERNAME,
password: process.env.GANCIO_DB_PASSWORD,
}
setupController._setupDb(dbConf)
.catch(e => { process.exit(1) })
.catch(e => {
log.warn(String(e))
process.exit(1)
})
}
await settingsController.load()
}
dayjs.extend(timezone)
dayjs.tz.setDefault(settingsController.settings.instance_timezone)
let TaskManager
@ -37,22 +55,11 @@ module.exports = function () {
TaskManager = require('../server/taskManager').TaskManager
TaskManager.start()
}
log.info(`Listen on ${config.server.host}:${config.server.port}`)
// close connections/port/unix socket
async function shutdown () {
if (TaskManager) { TaskManager.stop() }
log.info('Closing DB')
const sequelize = require('../server/api/models')
await sequelize.close()
process.off('SIGTERM', shutdown)
process.off('SIGINT', shutdown)
nuxt.close()
process.exit()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
process.on('SIGTERM', initialize.shutdown)
process.on('SIGINT', initialize.shutdown)
}
return start(this.nuxt)
}
module.exports = initialize

View file

@ -0,0 +1,30 @@
'use strict';
module.exports = {
up (queryInterface, Sequelize) {
return queryInterface.createTable('cohorts', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
allowNull: false,
autoIncrement: true,
},
name: {
type: Sequelize.STRING,
unique: true,
index: true,
allowNull: false
},
isActor: {
type: Sequelize.BOOLEAN
},
isTop: {
type: Sequelize.BOOLEAN
}
})
},
down (queryInterface, Sequelize) {
return queryInterface.dropTable('cohorts')
}
};

View file

@ -0,0 +1,35 @@
'use strict';
module.exports = {
up (queryInterface, Sequelize) {
return queryInterface.createTable('filters', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
cohortId: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'cohorts',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
tags: {
type: Sequelize.JSON,
},
places: {
type: Sequelize.JSON,
}
})
},
down (queryInterface, _Sequelize) {
return queryInterface.dropTable('filters')
}
}

View file

@ -1,6 +1,9 @@
const express = require('express')
const cookieParser = require('cookie-parser')
const initialize = require('./initialize.server')
initialize.start()
// const metricsController = require('./metrics')
// const promBundle = require('express-prom-bundle')
// const metricsMiddleware = promBundle({ includeMethod: true })
@ -34,13 +37,14 @@ if (config.status === 'READY') {
// rss/ics/atom feed
app.get('/feed/:type', cors(), exportController.export)
app.use('/.well-known', webfinger)
app.use('/event/:slug', helpers.APRedirect)
// federation api / activitypub / webfinger / nodeinfo
app.use('/federation', federation)
app.use('/.well-known', webfinger)
// ignore unimplemented ping url from fediverse
// ignore unimplemented ping url from fediverse
app.use(spamFilter)
// fill res.locals.user if request is authenticated
@ -54,26 +58,34 @@ if (config.status === 'READY') {
app.use('/api', api)
// // Handle 500
app.use((error, req, res, next) => {
app.use((error, _req, res, _next) => {
log.error('[ERROR]', error)
res.status(500).send('500: Internal Server Error')
return res.status(500).send('500: Internal Server Error')
})
// remaining request goes to nuxt
// first nuxt component is ./pages/index.vue (with ./layouts/default.vue)
// prefill current events, tags, places and announcements (used in every path)
app.use(async (req, res, next) => {
// const start_datetime = getUnixTime(startOfWeek(startOfMonth(new Date())))
// req.events = await eventController._select(start_datetime, 100)
if (config.status === 'READY') {
const eventController = require('./api/controller/event')
const announceController = require('./api/controller/announce')
res.locals.meta = await eventController._getMeta()
res.locals.announcements = await announceController._getVisible()
}
res.locals.status = config.status
next()
})
module.exports = app
module.exports = {
handler: app,
load () {
console.error('dentro load !')
},
unload: () => initialize.shutdown(false)
// async unload () {
// const db = require('./api/models/index')
// await db.close()
// process.off('SIGTERM')
// process.off('SIGINT')
// }
}

View file

@ -4,7 +4,7 @@ function run(fn) {
return fn();
}
function blank_object() {
return Object.create(null);
return /* @__PURE__ */ Object.create(null);
}
function run_all(fns) {
fns.forEach(run);
@ -104,7 +104,7 @@ function schedule_update() {
function add_render_callback(fn) {
render_callbacks.push(fn);
}
const seen_callbacks = new Set();
const seen_callbacks = /* @__PURE__ */ new Set();
let flushidx = 0;
function flush() {
const saved_component = current_component;
@ -146,7 +146,7 @@ function update($$) {
$$.after_update.forEach(add_render_callback);
}
}
const outroing = new Set();
const outroing = /* @__PURE__ */ new Set();
function transition_in(block, local) {
if (block && block.i) {
outroing.delete(block);
@ -282,19 +282,41 @@ if (typeof HTMLElement === "function") {
}
function get_each_context(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[11] = list[i];
child_ctx[12] = list[i];
return child_ctx;
}
function get_each_context_1(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[14] = list[i];
child_ctx[15] = list[i];
return child_ctx;
}
function create_if_block_5(ctx) {
let link;
return {
c() {
link = element("link");
attr(link, "rel", "stylesheet");
attr(link, "href", ctx[4]);
},
m(target, anchor) {
insert(target, link, anchor);
},
p(ctx2, dirty) {
if (dirty & 16) {
attr(link, "href", ctx2[4]);
}
},
d(detaching) {
if (detaching)
detach(link);
}
};
}
function create_if_block$1(ctx) {
let div;
let t;
let if_block = ctx[1] && ctx[3] === "true" && create_if_block_4(ctx);
let each_value = ctx[4];
let each_value = ctx[5];
let each_blocks = [];
for (let i = 0; i < each_value.length; i += 1) {
each_blocks[i] = create_each_block(get_each_context(ctx, each_value, i));
@ -336,8 +358,8 @@ function create_if_block$1(ctx) {
if_block.d(1);
if_block = null;
}
if (dirty & 25) {
each_value = ctx2[4];
if (dirty & 41) {
each_value = ctx2[5];
let i;
for (i = 0; i < each_value.length; i += 1) {
const child_ctx = get_each_context(ctx2, each_value, i);
@ -395,7 +417,7 @@ function create_if_block_4(ctx) {
attr(div0, "class", "title");
attr(img, "id", "logo");
attr(img, "alt", "logo");
if (!src_url_equal(img.src, img_src_value = "" + (ctx[0] + "/logo.png")))
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/logo.png"))
attr(img, "src", img_src_value);
attr(div1, "class", "content");
attr(a, "href", ctx[0]);
@ -413,7 +435,7 @@ function create_if_block_4(ctx) {
p(ctx2, dirty) {
if (dirty & 2)
set_data(t0, ctx2[1]);
if (dirty & 1 && !src_url_equal(img.src, img_src_value = "" + (ctx2[0] + "/logo.png"))) {
if (dirty & 1 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/logo.png")) {
attr(img, "src", img_src_value);
}
if (dirty & 1) {
@ -429,7 +451,7 @@ function create_if_block_4(ctx) {
function create_if_block_2(ctx) {
let div;
function select_block_type(ctx2, dirty) {
if (ctx2[11].media.length)
if (ctx2[12].media.length)
return create_if_block_3;
return create_else_block;
}
@ -472,7 +494,7 @@ function create_else_block(ctx) {
c() {
img = element("img");
attr(img, "style", "aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[11].title);
attr(img, "alt", img_alt_value = ctx[12].title);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/noimg.svg"))
attr(img, "src", img_src_value);
attr(img, "loading", "lazy");
@ -481,7 +503,7 @@ function create_else_block(ctx) {
insert(target, img, anchor);
},
p(ctx2, dirty) {
if (dirty & 16 && img_alt_value !== (img_alt_value = ctx2[11].title)) {
if (dirty & 32 && img_alt_value !== (img_alt_value = ctx2[12].title)) {
attr(img, "alt", img_alt_value);
}
if (dirty & 1 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/noimg.svg")) {
@ -502,9 +524,9 @@ function create_if_block_3(ctx) {
return {
c() {
img = element("img");
attr(img, "style", img_style_value = "object-position: " + position$1(ctx[11]) + "; aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[11].media[0].name);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/media/thumb/" + ctx[11].media[0].url))
attr(img, "style", img_style_value = "object-position: " + position$1(ctx[12]) + "; aspect-ratio=1.7778;");
attr(img, "alt", img_alt_value = ctx[12].media[0].name);
if (!src_url_equal(img.src, img_src_value = ctx[0] + "/media/thumb/" + ctx[12].media[0].url))
attr(img, "src", img_src_value);
attr(img, "loading", "lazy");
},
@ -512,13 +534,13 @@ function create_if_block_3(ctx) {
insert(target, img, anchor);
},
p(ctx2, dirty) {
if (dirty & 16 && img_style_value !== (img_style_value = "object-position: " + position$1(ctx2[11]) + "; aspect-ratio=1.7778;")) {
if (dirty & 32 && img_style_value !== (img_style_value = "object-position: " + position$1(ctx2[12]) + "; aspect-ratio=1.7778;")) {
attr(img, "style", img_style_value);
}
if (dirty & 16 && img_alt_value !== (img_alt_value = ctx2[11].media[0].name)) {
if (dirty & 32 && img_alt_value !== (img_alt_value = ctx2[12].media[0].name)) {
attr(img, "alt", img_alt_value);
}
if (dirty & 17 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/media/thumb/" + ctx2[11].media[0].url)) {
if (dirty & 33 && !src_url_equal(img.src, img_src_value = ctx2[0] + "/media/thumb/" + ctx2[12].media[0].url)) {
attr(img, "src", img_src_value);
}
},
@ -530,7 +552,7 @@ function create_if_block_3(ctx) {
}
function create_if_block_1$1(ctx) {
let div;
let each_value_1 = ctx[11].tags;
let each_value_1 = ctx[12].tags;
let each_blocks = [];
for (let i = 0; i < each_value_1.length; i += 1) {
each_blocks[i] = create_each_block_1(get_each_context_1(ctx, each_value_1, i));
@ -550,8 +572,8 @@ function create_if_block_1$1(ctx) {
}
},
p(ctx2, dirty) {
if (dirty & 16) {
each_value_1 = ctx2[11].tags;
if (dirty & 32) {
each_value_1 = ctx2[12].tags;
let i;
for (i = 0; i < each_value_1.length; i += 1) {
const child_ctx = get_each_context_1(ctx2, each_value_1, i);
@ -579,7 +601,7 @@ function create_if_block_1$1(ctx) {
function create_each_block_1(ctx) {
let span;
let t0;
let t1_value = ctx[14] + "";
let t1_value = ctx[15] + "";
let t1;
return {
c() {
@ -594,7 +616,7 @@ function create_each_block_1(ctx) {
append(span, t1);
},
p(ctx2, dirty) {
if (dirty & 16 && t1_value !== (t1_value = ctx2[14] + ""))
if (dirty & 32 && t1_value !== (t1_value = ctx2[15] + ""))
set_data(t1, t1_value);
},
d(detaching) {
@ -608,27 +630,27 @@ function create_each_block(ctx) {
let t0;
let div2;
let div0;
let t1_value = when$1(ctx[11].start_datetime) + "";
let t1_value = when$1(ctx[12].start_datetime) + "";
let t1;
let t2;
let div1;
let t3_value = ctx[11].title + "";
let t3_value = ctx[12].title + "";
let t3;
let t4;
let span1;
let t5;
let t6_value = ctx[11].place.name + "";
let t6_value = ctx[12].place.name + "";
let t6;
let t7;
let span0;
let t8_value = ctx[11].place.address + "";
let t8_value = ctx[12].place.address + "";
let t8;
let t9;
let t10;
let a_href_value;
let a_title_value;
let if_block0 = ctx[3] !== "true" && create_if_block_2(ctx);
let if_block1 = ctx[11].tags.length && create_if_block_1$1(ctx);
let if_block1 = ctx[12].tags.length && create_if_block_1$1(ctx);
return {
c() {
a = element("a");
@ -657,9 +679,9 @@ function create_each_block(ctx) {
attr(span0, "class", "subtitle");
attr(span1, "class", "place");
attr(div2, "class", "content");
attr(a, "href", a_href_value = "" + (ctx[0] + "/event/" + (ctx[11].slug || ctx[11].id)));
attr(a, "href", a_href_value = ctx[0] + "/event/" + (ctx[12].slug || ctx[12].id));
attr(a, "class", "event");
attr(a, "title", a_title_value = ctx[11].title);
attr(a, "title", a_title_value = ctx[12].title);
attr(a, "target", "_blank");
},
m(target, anchor) {
@ -698,15 +720,15 @@ function create_each_block(ctx) {
if_block0.d(1);
if_block0 = null;
}
if (dirty & 16 && t1_value !== (t1_value = when$1(ctx2[11].start_datetime) + ""))
if (dirty & 32 && t1_value !== (t1_value = when$1(ctx2[12].start_datetime) + ""))
set_data(t1, t1_value);
if (dirty & 16 && t3_value !== (t3_value = ctx2[11].title + ""))
if (dirty & 32 && t3_value !== (t3_value = ctx2[12].title + ""))
set_data(t3, t3_value);
if (dirty & 16 && t6_value !== (t6_value = ctx2[11].place.name + ""))
if (dirty & 32 && t6_value !== (t6_value = ctx2[12].place.name + ""))
set_data(t6, t6_value);
if (dirty & 16 && t8_value !== (t8_value = ctx2[11].place.address + ""))
if (dirty & 32 && t8_value !== (t8_value = ctx2[12].place.address + ""))
set_data(t8, t8_value);
if (ctx2[11].tags.length) {
if (ctx2[12].tags.length) {
if (if_block1) {
if_block1.p(ctx2, dirty);
} else {
@ -718,10 +740,10 @@ function create_each_block(ctx) {
if_block1.d(1);
if_block1 = null;
}
if (dirty & 17 && a_href_value !== (a_href_value = "" + (ctx2[0] + "/event/" + (ctx2[11].slug || ctx2[11].id)))) {
if (dirty & 33 && a_href_value !== (a_href_value = ctx2[0] + "/event/" + (ctx2[12].slug || ctx2[12].id))) {
attr(a, "href", a_href_value);
}
if (dirty & 16 && a_title_value !== (a_title_value = ctx2[11].title)) {
if (dirty & 32 && a_title_value !== (a_title_value = ctx2[12].title)) {
attr(a, "title", a_title_value);
}
},
@ -736,41 +758,65 @@ function create_each_block(ctx) {
};
}
function create_fragment$1(ctx) {
let if_block_anchor;
let if_block = ctx[4].length && create_if_block$1(ctx);
let t;
let if_block1_anchor;
let if_block0 = ctx[4] && create_if_block_5(ctx);
let if_block1 = ctx[5].length && create_if_block$1(ctx);
return {
c() {
if (if_block)
if_block.c();
if_block_anchor = empty();
if (if_block0)
if_block0.c();
t = space();
if (if_block1)
if_block1.c();
if_block1_anchor = empty();
this.c = noop;
},
m(target, anchor) {
if (if_block)
if_block.m(target, anchor);
insert(target, if_block_anchor, anchor);
if (if_block0)
if_block0.m(target, anchor);
insert(target, t, anchor);
if (if_block1)
if_block1.m(target, anchor);
insert(target, if_block1_anchor, anchor);
},
p(ctx2, [dirty]) {
if (ctx2[4].length) {
if (if_block) {
if_block.p(ctx2, dirty);
if (ctx2[4]) {
if (if_block0) {
if_block0.p(ctx2, dirty);
} else {
if_block = create_if_block$1(ctx2);
if_block.c();
if_block.m(if_block_anchor.parentNode, if_block_anchor);
if_block0 = create_if_block_5(ctx2);
if_block0.c();
if_block0.m(t.parentNode, t);
}
} else if (if_block) {
if_block.d(1);
if_block = null;
} else if (if_block0) {
if_block0.d(1);
if_block0 = null;
}
if (ctx2[5].length) {
if (if_block1) {
if_block1.p(ctx2, dirty);
} else {
if_block1 = create_if_block$1(ctx2);
if_block1.c();
if_block1.m(if_block1_anchor.parentNode, if_block1_anchor);
}
} else if (if_block1) {
if_block1.d(1);
if_block1 = null;
}
},
i: noop,
o: noop,
d(detaching) {
if (if_block)
if_block.d(detaching);
if (if_block0)
if_block0.d(detaching);
if (detaching)
detach(if_block_anchor);
detach(t);
if (if_block1)
if_block1.d(detaching);
if (detaching)
detach(if_block1_anchor);
}
};
}
@ -799,6 +845,7 @@ function instance$1($$self, $$props, $$invalidate) {
let { theme = "light" } = $$props;
let { show_recurrent = false } = $$props;
let { sidebar = "true" } = $$props;
let { external_style = "" } = $$props;
let mounted = false;
let events = [];
function update2(v) {
@ -814,11 +861,9 @@ function instance$1($$self, $$props, $$invalidate) {
if (places) {
params.push(`places=${places}`);
}
if (show_recurrent) {
params.push(`show_recurrent=true`);
}
params.push(`show_recurrent=${show_recurrent ? "true" : "false"}`);
fetch(`${baseurl}/api/events?${params.join("&")}`).then((res) => res.json()).then((e) => {
$$invalidate(4, events = e);
$$invalidate(5, events = e);
}).catch((e) => {
console.error("Error loading Gancio API -> ", e);
});
@ -833,20 +878,22 @@ function instance$1($$self, $$props, $$invalidate) {
if ("title" in $$props2)
$$invalidate(1, title = $$props2.title);
if ("maxlength" in $$props2)
$$invalidate(5, maxlength = $$props2.maxlength);
$$invalidate(6, maxlength = $$props2.maxlength);
if ("tags" in $$props2)
$$invalidate(6, tags = $$props2.tags);
$$invalidate(7, tags = $$props2.tags);
if ("places" in $$props2)
$$invalidate(7, places = $$props2.places);
$$invalidate(8, places = $$props2.places);
if ("theme" in $$props2)
$$invalidate(2, theme = $$props2.theme);
if ("show_recurrent" in $$props2)
$$invalidate(8, show_recurrent = $$props2.show_recurrent);
$$invalidate(9, show_recurrent = $$props2.show_recurrent);
if ("sidebar" in $$props2)
$$invalidate(3, sidebar = $$props2.sidebar);
if ("external_style" in $$props2)
$$invalidate(4, external_style = $$props2.external_style);
};
$$self.$$.update = () => {
if ($$self.$$.dirty & 494) {
if ($$self.$$.dirty & 974) {
update2();
}
};
@ -855,6 +902,7 @@ function instance$1($$self, $$props, $$invalidate) {
title,
theme,
sidebar,
external_style,
events,
maxlength,
tags,
@ -873,12 +921,13 @@ class GancioEvents extends SvelteElement {
}, instance$1, create_fragment$1, safe_not_equal, {
baseurl: 0,
title: 1,
maxlength: 5,
tags: 6,
places: 7,
maxlength: 6,
tags: 7,
places: 8,
theme: 2,
show_recurrent: 8,
sidebar: 3
show_recurrent: 9,
sidebar: 3,
external_style: 4
}, null);
if (options) {
if (options.target) {
@ -899,7 +948,8 @@ class GancioEvents extends SvelteElement {
"places",
"theme",
"show_recurrent",
"sidebar"
"sidebar",
"external_style"
];
}
get baseurl() {
@ -917,21 +967,21 @@ class GancioEvents extends SvelteElement {
flush();
}
get maxlength() {
return this.$$.ctx[5];
return this.$$.ctx[6];
}
set maxlength(maxlength) {
this.$$set({ maxlength });
flush();
}
get tags() {
return this.$$.ctx[6];
return this.$$.ctx[7];
}
set tags(tags) {
this.$$set({ tags });
flush();
}
get places() {
return this.$$.ctx[7];
return this.$$.ctx[8];
}
set places(places) {
this.$$set({ places });
@ -945,7 +995,7 @@ class GancioEvents extends SvelteElement {
flush();
}
get show_recurrent() {
return this.$$.ctx[8];
return this.$$.ctx[9];
}
set show_recurrent(show_recurrent) {
this.$$set({ show_recurrent });
@ -958,6 +1008,13 @@ class GancioEvents extends SvelteElement {
this.$$set({ sidebar });
flush();
}
get external_style() {
return this.$$.ctx[4];
}
set external_style(external_style) {
this.$$set({ external_style });
flush();
}
}
customElements.define("gancio-events", GancioEvents);
function create_if_block(ctx) {
@ -996,7 +1053,7 @@ function create_if_block(ctx) {
t6 = text(t6_value);
attr(div1, "class", "place");
attr(div2, "class", "container");
attr(a, "href", a_href_value = "" + (ctx[0] + "/event/" + (ctx[1].slug || ctx[1].id)));
attr(a, "href", a_href_value = ctx[0] + "/event/" + (ctx[1].slug || ctx[1].id));
attr(a, "class", "card");
attr(a, "target", "_blank");
},
@ -1035,7 +1092,7 @@ function create_if_block(ctx) {
set_data(t3, t3_value);
if (dirty & 2 && t6_value !== (t6_value = ctx2[1].place.name + ""))
set_data(t6, t6_value);
if (dirty & 3 && a_href_value !== (a_href_value = "" + (ctx2[0] + "/event/" + (ctx2[1].slug || ctx2[1].id)))) {
if (dirty & 3 && a_href_value !== (a_href_value = ctx2[0] + "/event/" + (ctx2[1].slug || ctx2[1].id))) {
attr(a, "href", a_href_value);
}
},

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