#247 [calendar] ics - add all-time, just-one-event, add clipboard button

This commit is contained in:
Avris 2021-10-27 21:00:45 +02:00
parent be09e8a3fd
commit 4bbdce77e4
18 changed files with 127 additions and 25 deletions

View File

@ -26,7 +26,7 @@
<div class="card-body"> <div class="card-body">
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
<li v-for="event in year.eventsByDate[d.toString()]" class="mb-2"> <li v-for="event in year.eventsByDate[d.toString()]" class="mb-2">
<CalendarEvent :event="event" :key="event.name"/> <CalendarEvent :event="event" :year="year.year" :key="event.name"/>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -1,10 +1,13 @@
<template> <template>
<span> <span>
<span v-if="range" class="badge bg-primary">{{ event.getRange(range) }}</span> <span v-if="range" class="badge bg-primary">{{ event.getRange(year) }}</span>
<Flag v-if="event.flag" name="" alt="" :img="`/flags/${event.flag}.png`"/> <Flag v-if="event.flag" name="" alt="" :img="`/flags/${event.flag}.png`"/>
<Icon v-else v="arrow-circle-right"/> <Icon v-else v="arrow-circle-right"/>
<T v-if="$te(`calendar.events.${eventName}`)" :params="{param: eventParam}">calendar.events.{{eventName}}</T> <T v-if="$te(`calendar.events.${eventName}`)" :params="{param: eventParam}">calendar.events.{{eventName}}</T>
<LinkedText v-else :text="eventName"/> <LinkedText v-else :text="eventName"/>
<a :href="`/api/queer-calendar-${config.locale}-${year}-${event.getUuid()}.ics`" class="small" :aria-label="$t('crud.download') + ' .ics'" :title="$t('crud.download') + ' .ics'">
<Icon v="calendar-plus"/>
</a>
</span> </span>
</template> </template>
@ -12,7 +15,8 @@
export default { export default {
props: { props: {
event: { required: true }, event: { required: true },
range: {}, year: { 'default': () => (new Date).getFullYear() },
range: { type: Boolean },
}, },
computed: { computed: {
eventName() { eventName() {

View File

@ -25,9 +25,14 @@
<p class="mb-0"> <p class="mb-0">
iCalendar: iCalendar:
</p> </p>
<a :href="`/api/calendar/queer-calendar-${year.year}.ics`" class="btn btn-outline-primary m-1"> <button :class="['btn', clipboardFeedback ? 'btn-success' : 'btn-outline-primary', 'm-1']" ref="clipboard" :data-clipboard-text="icsLink">
<Icon :v="clipboardFeedback ? 'clipboard-check' : 'clipboard'"/>
<T>crud.copy</T>
</button>
<a :href="icsLink" class="btn btn-outline-primary m-1">
<Icon v="calendar-plus"/> <Icon v="calendar-plus"/>
ICS <T>crud.download</T>
.ics
</a> </a>
</div> </div>
<div> <div>
@ -56,10 +61,29 @@
</template> </template>
<script> <script>
import ClipboardJS from 'clipboard';
export default { export default {
props: { props: {
day: {}, day: {},
year: {}, year: {},
} },
data() {
return {
clipboardFeedback: false,
}
},
mounted() {
const clipboard = new ClipboardJS(this.$refs.clipboard);
clipboard.on('success', (e) => {
this.clipboardFeedback = true;
setTimeout(() => this.clipboardFeedback = false, 3000);
});
},
computed: {
icsLink() {
return `${process.env.BASE_URL}/api/queer-calendar-${this.config.locale}${this.year.year === (new Date).getFullYear() ? '' : '-' + this.year.year}.ics`;
},
},
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
<li v-for="event in events" class="mb-2"> <li v-for="event in events" class="mb-2">
<CalendarEvent :event="event" :range="year.year" :key="event.name"/> <CalendarEvent :event="event" :year="year.year" range :key="event.name"/>
</li> </li>
</ul> </ul>
</template> </template>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="input-group my-2"> <div class="input-group my-2">
<input class="form-control" readonly :value="link.replace('https://', '').replace('http://', '')" id="link" ref="link"> <input class="form-control" readonly :value="link.replace('https://', '').replace('http://', '')" id="link" ref="link">
<button class="btn btn-outline-secondary btn-clipboard" data-clipboard-target="#link" :data-clipboard-text="link" @click="focusLink"> <button class="btn btn-outline-secondary" ref="clipboard" data-clipboard-target="#link" :data-clipboard-text="link" @click="focusLink" :aria-label="$t('crud.copy')" :title="$t('crud.copy')">
<Icon v="clipboard"/> <Icon v="clipboard"/>
</button> </button>
<a :href="link" target="_blank" rel="noopener" class="btn btn-outline-secondary"> <a :href="link" target="_blank" rel="noopener" class="btn btn-outline-secondary">
@ -18,7 +18,7 @@
link: { required: true }, link: { required: true },
}, },
mounted () { mounted () {
new ClipboardJS('.btn-clipboard'); new ClipboardJS(this.$refs.clipboard);
}, },
methods: { methods: {
focusLink() { focusLink() {

View File

@ -470,6 +470,8 @@ crud:
author: 'Hinzugefügt von' author: 'Hinzugefügt von'
saved: 'Änderungen erfolgreich gespeichert!' saved: 'Änderungen erfolgreich gespeichert!'
loginRequired: '{/account=Log dich ein} um Eintrag einzureichen' loginRequired: '{/account=Log dich ein} um Eintrag einzureichen'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >
@ -755,4 +757,4 @@ calendar:
overview: 'Überschau' overview: 'Überschau'
labels: 'Etiketten' labels: 'Etiketten'
link: 'Link' link: 'Link'
full: 'Ganzer Kalender' full: 'Ganzer Kalender'

View File

@ -549,6 +549,8 @@ crud:
author: 'Added by' author: 'Added by'
saved: 'Changes saved successfully!' saved: 'Changes saved successfully!'
loginRequired: '{/account=Log in} to submit an entry' loginRequired: '{/account=Log in} to submit an entry'
copy: 'Copy link'
download: 'Download'
footer: footer:
license: > license: >

View File

@ -483,6 +483,8 @@ crud:
author: 'Añadido por' author: 'Añadido por'
saved: 'Los cambios se guardaron exitosamente!' saved: 'Los cambios se guardaron exitosamente!'
loginRequired: '{/cuenta=Log in} para enviar un ítem' loginRequired: '{/cuenta=Log in} para enviar un ítem'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -473,6 +473,8 @@ crud:
author: 'Ajouté par' author: 'Ajouté par'
saved: 'Modifications enregistrées !' saved: 'Modifications enregistrées !'
loginRequired: '{/compte=Connectez-vous} pour proposer un mot' loginRequired: '{/compte=Connectez-vous} pour proposer un mot'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -462,6 +462,8 @@ crud:
author: 'Toegevoegd door' author: 'Toegevoegd door'
saved: 'Veranderingen succesvol opgeslagen!' saved: 'Veranderingen succesvol opgeslagen!'
loginRequired: '{/account=Login} om een toevoeging in te dienen' loginRequired: '{/account=Login} om een toevoeging in te dienen'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -472,6 +472,8 @@ crud:
author: 'Lagt til av' author: 'Lagt til av'
saved: 'Endringer er lagret!' saved: 'Endringer er lagret!'
loginRequired: '{/account=Log in} for å' loginRequired: '{/account=Log in} for å'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -1191,6 +1191,8 @@ crud:
author: 'Dodane przez' author: 'Dodane przez'
saved: 'Zmiany zapisane!' saved: 'Zmiany zapisane!'
loginRequired: '{/konto=Zaloguj się}, aby zgłosić wpis' loginRequired: '{/konto=Zaloguj się}, aby zgłosić wpis'
copy: 'Skopiuj link'
download: 'Ściągnij'
footer: footer:
license: > license: >

View File

@ -480,6 +480,8 @@ crud:
author: 'Adicionado por' author: 'Adicionado por'
saved: 'Alterações salvas com sucesso!' saved: 'Alterações salvas com sucesso!'
loginRequired: 'Faça {/conta=login} para participar do nosso banco de dados' loginRequired: 'Faça {/conta=login} para participar do nosso banco de dados'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -446,6 +446,8 @@ crud:
search: '搜索' search: '搜索'
author: '添加人' author: '添加人'
saved: '更改保存成功' saved: '更改保存成功'
copy: 'Copy link' # TODO
download: 'Download' # TODO
footer: footer:
license: > license: >

View File

@ -45,6 +45,7 @@
"suml-loader": "^0.1.1", "suml-loader": "^0.1.1",
"twitter": "^1.7.1", "twitter": "^1.7.1",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"uuid": "^8.3.2",
"vue-client-only": "^2.1.0", "vue-client-only": "^2.1.0",
"vue-lazy-hydration": "^2.0.0-beta.4", "vue-lazy-hydration": "^2.0.0-beta.4",
"vue-matomo": "^3.13.5-0", "vue-matomo": "^3.13.5-0",

View File

@ -26,7 +26,7 @@
<h3><T :params="{day: day.day}">calendar.dates.{{day.month}}</T> {{day.year}}</h3> <h3><T :params="{day: day.day}">calendar.dates.{{day.month}}</T> {{day.year}}</h3>
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
<li v-for="event in year.eventsByDate[day.toString()]" class="mb-2"> <li v-for="event in year.eventsByDate[day.toString()]" class="mb-2">
<CalendarEvent :event="event" :key="event.name"/> <CalendarEvent :event="event" :year="year.year" :key="event.name"/>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -8,26 +8,68 @@ import { clearLinkedText } from '../../src/helpers';
const translations = loadSuml('translations'); const translations = loadSuml('translations');
const renderEvents = (yearEvents, res) => {
const events = [];
let i = 1;
for (let year in yearEvents) {
if (!yearEvents.hasOwnProperty(year)) { continue; }
for (let event of yearEvents[year]) {
if (!event) { continue; }
const ics = event.toIcs(year, translations, clearLinkedText, i);
if (ics !== null) {
events.push(ics);
}
}
i++;
}
if (events.length === 0) {
return res.status(404).json({error: 'Not found'});
}
createEvents(
events,
(error, value) => {
if (error) {
console.error(error);
return res.status(500).json({error: 'Unexpected server error'});
}
res.header('Content-type', 'text/calendar');
res.send(value);
}
);
}
const router = Router(); const router = Router();
router.get('/calendar/queer-calendar-:year.ics', handleErrorAsync(async (req, res) => { const routeBase = `/queer-calendar-${global.config.locale}`;
router.get(routeBase + '.ics', handleErrorAsync(async (req, res) => {
let events = {};
for (let year of calendar.getAllYears()) {
events[year.year] = year.events;
}
renderEvents(events, res);
}));
router.get(routeBase + '-:year-:uuid.ics', handleErrorAsync(async (req, res) => {
const year = calendar.getYear(req.params.year); const year = calendar.getYear(req.params.year);
if (!year) { if (!year) {
return res.status(404).json({error: 'Not found'}); return res.status(404).json({error: 'Not found'});
} }
const events = year.events
.map(e => e.toIcs(req.params.year, translations, clearLinkedText))
.filter(e => e !== null);
createEvents(events, (error, value) => { renderEvents({[year.year]: [year.eventsByUuid[req.params.uuid]]}, res)
if (error) { }));
console.error(error);
return res.status(500).json({error: 'Unexpected server error'});
}
res.header('Content-type', 'text/calendar'); router.get(routeBase + '-:year.ics', handleErrorAsync(async (req, res) => {
res.send(value); const year = calendar.getYear(req.params.year);
}) if (!year) {
return res.status(404).json({error: 'Not found'});
}
renderEvents({[year.year]: year.events}, res)
})); }));
export default router; export default router;

View File

@ -1,4 +1,5 @@
const md5 = require("js-md5"); const md5 = require("js-md5");
const { v5: uuid5 } = require('uuid');
class Day { class Day {
constructor(year, month, day, dayOfWeek) { constructor(year, month, day, dayOfWeek) {
@ -104,7 +105,11 @@ module.exports.Event = class {
return this.getDays(day.year)[0].equals(day); return this.getDays(day.year)[0].equals(day);
} }
toIcs(year, translations, clearLinkedText) { getUuid() {
return uuid5(`${process.env.BASE_URL}/calendar/event/${this.name}`, uuid5.URL);
}
toIcs(year, translations, clearLinkedText, sequence = 1) {
const days = this.getDays(year); const days = this.getDays(year);
const first = days[0]; const first = days[0];
const last = days[days.length - 1].next(); const last = days[days.length - 1].next();
@ -123,6 +128,7 @@ module.exports.Event = class {
start: [first.year, first.month, first.day], start: [first.year, first.month, first.day],
end: [last.year, last.month, last.day], end: [last.year, last.month, last.day],
calName: translations.calendar.headerLong, calName: translations.calendar.headerLong,
sequence,
} }
} }
} }
@ -194,13 +200,20 @@ class Year {
for (let event of events) { for (let event of events) {
for (let term of event.terms) { for (let term of event.terms) {
if (this.eventsByTerm[term] === undefined) { this.eventsByTerm[term] = []; } if (this.eventsByTerm[term] === undefined) { this.eventsByTerm[term] = []; }
this.eventsByTerm[term].push(event); if (event.getDays(this.year).length) {
this.eventsByTerm[term].push(event);
}
} }
} }
for (let term in this.eventsByTerm) { for (let term in this.eventsByTerm) {
if (!this.eventsByTerm.hasOwnProperty(term)) { continue; } if (!this.eventsByTerm.hasOwnProperty(term)) { continue; }
this.eventsByTerm[term].sort((a, b) => a.getDays(this.year)[0].toInt() - b.getDays(this.year)[0].toInt()) this.eventsByTerm[term].sort((a, b) => a.getDays(this.year)[0].toInt() - b.getDays(this.year)[0].toInt())
} }
this.eventsByUuid = {}
for (let event of events) {
this.eventsByUuid[event.getUuid()] = event;
}
} }
isCurrent() { isCurrent() {