Merge branch 'event-metadata' into 'master'

Allow to add metadata to an event

Closes #410

See merge request framasoft/mobilizon!1017
This commit is contained in:
Thomas Citharel 2021-08-09 14:51:34 +00:00
commit 887ac38b96
29 changed files with 1533 additions and 345 deletions

View file

@ -201,7 +201,7 @@ pages:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$CI_REGISTRY_AUTH\",\"email\":\"$CI_REGISTRY_EMAIL\"}}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor --cache=true --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/docker/production/Dockerfile --destination $DOCKER_IMAGE_NAME --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/docker/production/Dockerfile --destination $DOCKER_IMAGE_NAME --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP
build-docker-master:
<<: *docker

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="742.753" height="742.753" viewBox="0 0 557.065 557.065"><path style="stroke:none;fill-rule:nonzero;fill:#636363;fill-opacity:1" d="M135.848 206.352c-4.887 9.359-12.715 17.152-22.098 21.996L235.066 350.14l29.25-14.825zm160.023 160.64-29.25 14.824 61.473 61.711c4.886-9.359 12.719-17.156 22.105-21.996zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#878787;fill-opacity:1" d="m436.234 254.543-68.68 34.809 5.063 32.39 77.711-39.383c-7.39-7.543-12.387-17.398-14.094-27.816zM327.68 309.559l-162.39 82.3c7.39 7.54 12.386 17.395 14.093 27.817l153.363-77.727zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#636363;fill-opacity:1" d="m275.457 106.828-78.36 152.977 23.133 23.226 82.97-161.969c-10.41-1.761-20.243-6.804-27.743-14.234zm-98.742 192.766-39.692 77.488c10.41 1.758 20.239 6.805 27.743 14.23l35.086-68.496zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#5c5c5c;fill-opacity:1" d="M113.074 228.688a51.922 51.922 0 0 1-25.808 5.398 52.012 52.012 0 0 1-4.989-.524l23.176 148.247a51.976 51.976 0 0 1 25.813-5.395c1.668.094 3.332.266 4.984.52zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#575757;fill-opacity:1" d="M179.508 420.41c.527 3.438.71 6.93.539 10.406a51.888 51.888 0 0 1-5.45 20.387l148.22 23.781a51.814 51.814 0 0 1-.54-10.406 51.852 51.852 0 0 1 5.45-20.383zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#878787;fill-opacity:1" d="m450.852 282.898-68.414 133.563c10.41 1.762 20.242 6.805 27.742 14.238l68.414-133.562c-10.41-1.762-20.242-6.805-27.742-14.239zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#ccc;fill-opacity:1" d="M357.543 93.996c-4.887 9.363-12.719 17.156-22.106 22l105.95 106.36c4.886-9.36 12.718-17.157 22.101-22zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#8f8f8f;fill-opacity:1" d="m260.84 78.473-133.93 67.875c7.39 7.539 12.383 17.394 14.094 27.812l133.93-67.875c-7.391-7.539-12.387-17.394-14.094-27.812zm74.355 37.648a52.01 52.01 0 0 1-26.238 5.61 51.5 51.5 0 0 1-4.52-.473l11.864 75.969 32.37 5.191zm-12 125.27 28.051 179.613a51.909 51.909 0 0 1 25.434-5.211c1.812.105 3.617.3 5.406.594l-26.52-169.805zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#ababab;fill-opacity:1" d="M141.098 174.73a51.84 51.84 0 0 1 .57 10.575 51.878 51.878 0 0 1-5.371 20.234l76.027 12.211 14.942-29.18zm130.304 20.926-14.945 29.184 179.633 28.85a51.828 51.828 0 0 1-.52-10.289 51.863 51.863 0 0 1 5.512-20.492zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#c2c2c2;fill-opacity:.995968" d="M358.672 72.691c-1.414 25.907-23.555 45.762-49.461 44.348-25.902-1.41-45.758-23.55-44.348-49.457 1.414-25.902 23.555-45.758 49.461-44.348 25.903 1.414 45.758 23.555 44.348 49.457zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#b3b3b3;fill-opacity:.995968" d="M534.066 248.766c-1.41 25.906-23.554 45.761-49.457 44.347-25.906-1.41-45.761-23.55-44.347-49.457 1.41-25.902 23.55-45.758 49.457-44.347 25.902 1.41 45.758 23.554 44.347 49.457zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#7d7d7d;fill-opacity:.995968" d="M420.773 469.941c-1.414 25.903-23.554 45.758-49.457 44.348-25.906-1.41-45.761-23.555-44.351-49.457 1.414-25.902 23.555-45.758 49.46-44.348 25.903 1.41 45.759 23.555 44.348 49.457zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#4a4a4a;fill-opacity:.995968" d="M175.355 430.563c-1.41 25.902-23.55 45.757-49.457 44.347-25.902-1.414-45.757-23.555-44.347-49.457 1.414-25.906 23.554-45.762 49.457-44.351 25.906 1.414 45.762 23.554 44.347 49.46zm0 0"/><path style="stroke:none;fill-rule:nonzero;fill:#4d4d4d;fill-opacity:.995968" d="M136.977 185.047c-1.41 25.902-23.555 45.758-49.457 44.348-25.907-1.41-45.758-23.555-44.348-49.458 1.41-25.902 23.555-45.757 49.457-44.347 25.902 1.41 45.758 23.555 44.348 49.457zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="2799 -911 16 22"><g data-name="Artboard 1"><g data-name="Symbol 3 1"><g data-name="Group 44"><path d="M2799-911v11l8-5" data-name="Path 4"/><path d="M2799-900v11l8-6" data-name="Path 5"/><path d="M2807-905v10l8-5" data-name="Path 6"/><path fill="transparent" d="M2807-895v-10l-8 5z" data-name="Path 7"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 378 B

View file

@ -0,0 +1 @@
<svg height="100px" width="100px" fill="#000000" version="1.0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 100 84.922" enable-background="new 0 0 100 84.922" xml:space="preserve"><path d="M50.29,42.212"/><path d="M50.29,42.212"/><path d="M50.29,42.212"/><path d="M95.223,22.145c-5.377,3.135-8.271,4.414-13.844,4.125c-2.098-0.024-3.207-0.917-5.281-0.724 c-1.93,0.072-2.918,0.796-4.849,1.447c-2.966,0.917-4.583,1.737-7.694,2.702c-1.977,0.555-3.23,0.579-5.281,1.278 c-1.76,0.748-0.916,0.145-2.146,1.567c-1.592,1.761-1.206,3.136-2.701,6.15c-1.856,3.956-1.641,4.486-0.579,4.969 c-0.699,2.025-0.867,3.377-0.699,5.571c0.192,1.664-0.506,2.821,1.278,4.147c2.437,1.81,7.55,6.054,8.853,6.125 c1.953-0.071,7.646,0.146,10.539-1.855c0.652-0.41,0.845-0.82,0.289,0.725c-0.119,0.192-7.211,5.715-7.982,5.69 c-5.463,0.176-6.511,0.431-6.611,0.556c-0.628-0.243-4.812-5.237-7.377-3.402c-0.99,0.604-1.448,1.183-1.835,2.269 c0.338,0.699,1.931,1.81,3.4,2.726c2.123,1.352,2.315,2.772,4.438,3.281c2.58,0.577,5.33,0.191,6.969,0.408 c0.629,0.169,6.32-1.495,9.865-3.4c3.28-1.688-4.51,3.256-6.416,5.692c-5.74,1.81-4.123,1.882-6.85,2.437 c-2.532,0.578-2.412,0.434-4.148,0.988c-1.785,0.434-3.256,2.87-1.566,4.147c1.013,0.845,4.173-0.337,7.283-0.435 c1.375-0.022,6.994-2.604,8.272-3.279c3.185-1.713,5.522-4.076,7.404-5.428c2.604-1.807-2.942,4.994-5.282,7.84 c-0.675,0.918-0.988,1.109-1.832,2.291c-1.398,1.811,0.385,4.56,1.277,4.125c1.037-0.385,1.713-2.459,2.846-3.135 c0.58-0.314,2.123-2.582,3.57-4.125c1.061-1.205,1.833-1.736,2.846-2.99c1.713-2.123,2.074-3.738,3.57-6.006 c0.916-1.183,1.566-1.543,2.41-2.99c4.463-8.441,3.16-12.229,7.43-18.258c1.303-1.785,2.773-2.22,4.848-3.281 C101.566,30.031,95.175,22.169,95.223,22.145z M74.529,44.528c-1.014,3.545-0.916,3.955-2.846,5.281 c-2.34,1.785-2.461,0.434-7.838,3.425c0,0-2.22-2.315-5.717-3.425c0.023,0.049-0.941-4.486-2.846-6.006 c4.533-3.449,4.412-6.366,5.137-6.27c1.616,0.145,4.198,0.965,8.973-0.844C72.143,41.923,75.471,41.657,74.529,44.528z"/><path d="M47.3,18.863c-2.122-1.423-2.339-2.846-4.438-3.304c-2.58-0.627-5.354-0.241-6.971-0.555 c-0.627-0.072-6.318,1.592-9.84,3.425c-3.328,1.761,4.486-3.184,6.416-5.716c5.716-1.712,4.076-1.785,6.85-2.412 c2.508-0.506,2.387-0.362,4.124-0.868c1.761-0.482,3.256-2.895,1.567-4.269c-1.037-0.772-4.172,0.41-7.26,0.434 C36.35,5.696,30.729,8.277,29.451,9c-3.184,1.664-5.523,4.028-7.404,5.282c-2.629,1.906,2.942-4.872,5.282-7.694 c0.675-0.965,0.989-1.158,1.856-2.291c1.352-1.857-0.41-4.607-1.277-4.269c-1.062,0.482-1.736,2.556-3.016,3.28 c-0.458,0.266-2.002,2.533-3.424,4.125c-1.062,1.158-1.834,1.688-2.847,3.015c-1.736,2.05-2.074,3.666-3.569,5.837 c-0.916,1.302-1.592,1.64-2.412,3.135C8.179,27.813,9.457,31.6,5.212,37.533c-1.302,1.905-2.773,2.315-4.848,3.425 c-1.93,14.037,4.438,21.876,4.413,21.828c5.379-3.062,8.249-4.342,13.845-4.147c2.099,0.12,3.208,1.012,5.282,0.866 c1.93-0.119,2.918-0.844,4.848-1.422c2.967-0.988,4.582-1.81,7.549-2.727c2.123-0.604,3.377-0.627,5.428-1.422 c1.76-0.652,0.916-0.049,2.146-1.424c1.567-1.809,1.205-3.184,2.557-6.127c2.002-4.027,1.784-4.558,0.723-4.992 c0.676-2.074,0.869-3.4,0.699-5.571c-0.217-1.688,0.507-2.87-1.277-4.125c-2.437-1.881-7.55-6.126-8.828-6.15 c-1.978,0.024-7.67-0.193-10.564,1.712c-0.65,0.506-0.844,0.917-0.289-0.555c0.121-0.266,7.212-5.789,7.983-5.861 c5.059-0.108,6.332-0.315,6.573-0.429c0.5,0.013,4.804,5.243,7.417,3.42c0.965-0.651,1.447-1.23,1.857-2.267 C50.364,20.817,48.772,19.708,47.3,18.863z M28.318,35.121c2.315-1.712,2.459-0.362,7.838-3.28c0-0.073,2.219,2.243,5.717,3.28 c-0.024,0.024,0.94,4.558,2.846,6.126c-4.535,3.401-4.414,6.32-5.138,6.271c-1.617-0.193-4.197-1.014-8.972,0.725 c-2.774-5.162-6.078-4.873-5.138-7.864C26.484,36.954,26.364,36.52,28.318,35.121z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -165,12 +165,13 @@ function doMerge<T = any>(
args: Record<string, any> | null
): Array<T> {
const merged = existing && Array.isArray(existing) ? existing.slice(0) : [];
const previous = incoming && Array.isArray(incoming) ? incoming.slice(0) : [];
let res;
if (args) {
// Assume an page of 1 if args.page omitted.
const { page = 1, limit = 10 } = args;
for (let i = 0; i < incoming.length; ++i) {
merged[(page - 1) * limit + i] = incoming[i];
for (let i = 0; i < previous.length; ++i) {
merged[(page - 1) * limit + i] = previous[i];
}
res = merged;
} else {
@ -178,7 +179,7 @@ function doMerge<T = any>(
// to receive any arguments, so you might prefer to throw an
// exception here, instead of recovering by appending incoming
// onto the existing array.
res = [...merged, ...incoming];
res = [...merged, ...previous];
// eslint-disable-next-line no-underscore-dangle
res = uniqBy(res, (elem: any) => elem.__ref);
}

View file

@ -250,7 +250,9 @@ export default class EditorComponent extends Vue {
Mention.configure(MentionOptions),
CustomImage,
Underline,
Link,
Link.configure({
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
}),
CharacterCount.configure({
limit: this.maxSize,
}),

View file

@ -2,7 +2,18 @@
<div>
<h2>{{ title }}</h2>
<div class="eventMetadataBlock">
<b-icon v-if="icon" :icon="icon" size="is-medium" />
<!-- Custom icons -->
<span
class="icon is-medium"
v-if="icon && icon.substring(0, 7) === 'mz:icon'"
>
<img
:src="`/img/${icon.substring(8)}_monochrome.svg`"
width="32"
height="32"
/>
</span>
<b-icon v-else-if="icon" :icon="icon" size="is-medium" />
<p :class="{ 'padding-left': icon }">
<slot></slot>
</p>
@ -36,6 +47,13 @@ div.eventMetadataBlock {
&.padding-left {
padding: 0 20px;
a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -0,0 +1,140 @@
<template>
<div class="card card-content">
<div class="media">
<div class="media-left">
<img
v-if="
metadataItem.icon && metadataItem.icon.substring(0, 7) === 'mz:icon'
"
:src="`/img/${metadataItem.icon.substring(8)}_monochrome.svg`"
width="24"
height="24"
/>
<b-icon v-else-if="metadataItem.icon" :icon="metadataItem.icon" />
<b-icon v-else icon="help-circle" />
</div>
<div class="media-content">
<b>{{ metadataItem.title || metadataItem.label }}</b>
<br />
<small>
{{ metadataItem.description }}
</small>
<div
v-if="
metadataItem.type === EventMetadataType.STRING &&
metadataItem.keyType === EventMetadataKeyType.CHOICE &&
metadataItem.choices
"
>
<b-field v-for="(value, key) in metadataItem.choices" :key="key">
<b-radio v-model="metadataItemValue" :native-value="key">{{
value
}}</b-radio>
</b-field>
</div>
<b-field
v-else-if="
metadataItem.type === EventMetadataType.STRING &&
metadataItem.keyType == EventMetadataKeyType.URL
"
>
<b-input
@blur="validatePattern"
ref="urlInput"
type="url"
:pattern="
metadataItem.pattern ? metadataItem.pattern.source : undefined
"
:validation-message="$t(`This URL doesn't seem to be valid`)"
required
v-model="metadataItemValue"
:placeholder="metadataItem.placeholder"
/>
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.STRING">
<b-input
v-model="metadataItemValue"
:placeholder="metadataItem.placeholder"
/>
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.INTEGER">
<b-numberinput v-model="metadataItemValue" />
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.BOOLEAN">
<b-checkbox v-model="metadataItemValue">
{{
metadataItemValue === "true"
? metadataItem.choices["true"]
: metadataItem.choices["false"]
}}
</b-checkbox>
</b-field>
</div>
<b-button
icon-left="close"
@click="$emit('removeItem', metadataItem.key)"
/>
</div>
</div>
</template>
<script lang="ts">
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
@Component
export default class EventMetadataItem extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
value!: IEventMetadataDescription;
EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType;
@Ref("urlInput") readonly urlInput!: any;
get metadataItem(): IEventMetadataDescription {
return this.value;
}
get metadataItemValue(): string {
return this.metadataItem.value;
}
set metadataItemValue(value: string) {
if (this.validate(value)) {
this.$emit("input", { ...this.metadataItem, value: value.toString() });
}
}
validatePattern(): void {
this.urlInput.checkHtml5Validity();
}
private validate(value: string): boolean {
if (this.metadataItem.keyType === EventMetadataKeyType.URL) {
try {
const url = new URL(value);
if (!["http:", "https:", "mailto:"].includes(url.protocol))
return false;
if (this.metadataItem.pattern) {
return value.match(this.metadataItem.pattern) !== null;
}
} catch {
return false;
}
}
return true;
}
}
</script>
<style lang="scss" scoped>
.card .media {
align-items: center;
& > button {
margin-left: 1rem;
}
}
</style>

View file

@ -0,0 +1,206 @@
<template>
<section>
<div class="mb-4">
<div v-for="(item, index) in metadata" :key="item.key" class="my-2">
<event-metadata-item
:value="metadata[index]"
@input="updateSingleMetadata"
@removeItem="removeItem"
/>
</div>
</div>
<b-field grouped :label="$t('Find or add an element')">
<b-autocomplete
expanded
v-model="search"
ref="autocomplete"
:data="filteredDataArray"
group-field="category"
group-options="items"
open-on-focus
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
@select="(option) => addElement(option)"
>
<template slot-scope="props">
<div class="media">
<div class="media-left">
<img
v-if="
props.option.icon &&
props.option.icon.substring(0, 7) === 'mz:icon'
"
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
width="24"
height="24"
/>
<b-icon v-else-if="props.option.icon" :icon="props.option.icon" />
<b-icon v-else icon="help-circle" />
</div>
<div class="media-content">
<b>{{ props.option.label }}</b>
<br />
<small>
{{ props.option.description }}
</small>
</div>
</div>
</template>
<template #empty>{{
$t("No results for {search}", { search })
}}</template>
</b-autocomplete>
<p class="control">
<b-button @click="showNewElementModal = true">
{{ $t("Add new…") }}
</b-button>
</p>
</b-field>
<b-modal has-modal-card v-model="showNewElementModal">
<div class="modal-card">
<header class="modal-card-head">
<button
type="button"
class="delete"
@click="showNewElementModal = false"
/>
</header>
<div class="modal-card-body">
<form @submit="addNewElement">
<b-field :label="$t('Element title')">
<b-input v-model="newElement.title" />
</b-field>
<b-field :label="$t('Element value')">
<b-input v-model="newElement.value" />
</b-field>
<b-button type="is-primary" native-type="submit">{{
$t("Add")
}}</b-button>
</form>
</div>
</div>
</b-modal>
</section>
</template>
<script lang="ts">
import {
IEventMetadata,
IEventMetadataDescription,
} from "@/types/event-metadata";
import cloneDeep from "lodash/cloneDeep";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMetadataItem from "./EventMetadataItem.vue";
import { eventMetaDataList } from "../../services/EventMetadata";
import { EventMetadataCategories, EventMetadataType } from "@/types/enums";
type GroupedIEventMetadata = Array<{
category: string;
items: IEventMetadata[];
}>;
@Component({
components: {
EventMetadataItem,
},
})
export default class EventMetadataList extends Vue {
@Prop({ type: Array as PropType<Array<IEventMetadata>>, required: true })
value!: IEventMetadata[];
newElement = {
title: "",
value: "",
};
search = "";
data: IEventMetadataDescription[] = eventMetaDataList;
showNewElementModal = false;
get metadata(): IEventMetadata[] {
return this.value.map((val) => {
const def = this.data.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
}) as any[];
}
set metadata(metadata: IEventMetadata[]) {
this.$emit("input", metadata);
}
localizedCategories: Record<EventMetadataCategories, string> = {
[EventMetadataCategories.ACCESSIBILITY]: this.$t("Accessibility") as string,
[EventMetadataCategories.LIVE]: this.$t("Live") as string,
[EventMetadataCategories.REPLAY]: this.$t("Replay") as string,
[EventMetadataCategories.TOOLS]: this.$t("Tools") as string,
[EventMetadataCategories.SOCIAL]: this.$t("Social") as string,
[EventMetadataCategories.DETAILS]: this.$t("Details") as string,
[EventMetadataCategories.BOOKING]: this.$t("Booking") as string,
};
get filteredDataArray(): GroupedIEventMetadata {
return this.data
.filter((option) => {
return (
option.label
.toString()
.toLowerCase()
.indexOf(this.search.toLowerCase()) >= 0
);
})
.filter(({ key }) => {
return !this.metadata.map(({ key: key2 }) => key2).includes(key);
})
.reduce(
(acc: GroupedIEventMetadata, current: IEventMetadataDescription) => {
const group = acc.find(
(elem) =>
elem.category === this.localizedCategories[current.category]
);
if (group) {
group.items.push(current);
} else {
acc.push({
category: this.localizedCategories[current.category],
items: [current],
});
}
return acc;
},
[]
);
}
updateSingleMetadata(element: IEventMetadataDescription): void {
const metadataClone = cloneDeep(this.metadata);
const index = metadataClone.findIndex((elem) => elem.key === element.key);
metadataClone.splice(index, 1, element);
this.$emit("input", metadataClone);
}
removeItem(itemKey: string): void {
const metadataClone = cloneDeep(this.metadata);
const index = metadataClone.findIndex((elem) => elem.key === itemKey);
metadataClone.splice(index, 1);
this.$emit("input", metadataClone);
}
addElement(element: IEventMetadata): void {
this.metadata = [...this.metadata, element];
}
addNewElement(e: Event): void {
e.preventDefault();
this.addElement({
...this.newElement,
type: EventMetadataType.STRING,
key: `mz:plain:${(Math.random() + 1).toString(36).substring(7)}`,
});
this.showNewElementModal = false;
}
}
</script>

View file

@ -0,0 +1,450 @@
<template>
<div>
<event-metadata-block
:title="$t('Location')"
:icon="physicalAddress ? physicalAddress.poiInfos.poiIcon.icon : 'earth'"
>
<div class="address-wrapper">
<span v-if="!physicalAddress">{{ $t("No address defined") }}</span>
<div class="address" v-if="physicalAddress">
<div>
<address>
<p
class="addressDescription"
:title="physicalAddress.poiInfos.name"
>
{{ physicalAddress.poiInfos.name }}
</p>
<p class="has-text-grey-dark">
{{ physicalAddress.poiInfos.alternativeName }}
</p>
</address>
</div>
<span
class="map-show-button"
@click="showMap = !showMap"
v-if="physicalAddress.geom"
>{{ $t("Show map") }}</span
>
</div>
</div>
</event-metadata-block>
<event-metadata-block :title="$t('Date and time')" icon="calendar">
<event-full-date
:beginsOn="event.beginsOn"
:show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime"
:endsOn="event.endsOn"
/>
</event-metadata-block>
<event-metadata-block
class="metadata-organized-by"
:title="$t('Organized by')"
>
<popover-actor-card
:actor="event.organizerActor"
v-if="!event.attributedTo"
>
<actor-card :actor="event.organizerActor" />
</popover-actor-card>
<router-link
v-if="event.attributedTo"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(event.attributedTo),
},
}"
>
<popover-actor-card
:actor="event.attributedTo"
v-if="
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
"
>
<actor-card :actor="event.attributedTo" />
</popover-actor-card>
</router-link>
<popover-actor-card
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
>
<actor-card :actor="contact" />
</popover-actor-card>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
icon="link"
:title="$t('Website')"
>
<a
target="_blank"
rel="noopener noreferrer ugc"
:href="event.onlineAddress"
:title="
$t('View page on {hostname} (in a new window)', {
hostname: urlToHostname(event.onlineAddress),
})
"
>{{ simpleURL(event.onlineAddress) }}</a
>
</event-metadata-block>
<event-metadata-block
v-for="extra in extraMetadata"
:title="extra.title || extra.label"
:icon="extra.icon"
:key="extra.key"
>
<span
v-if="
((extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.CHOICE) ||
extra.type === EventMetadataType.BOOLEAN) &&
extra.choices &&
extra.choices[extra.value]
"
>
{{ extra.choices[extra.value] }}
</span>
<a
v-else-if="
extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.URL
"
target="_blank"
rel="noopener noreferrer ugc"
:href="extra.value"
:title="
$t('View page on {hostname} (in a new window)', {
hostname: urlToHostname(extra.value),
})
"
>{{ simpleURL(extra.value) }}</a
>
<a
v-else-if="
extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.HANDLE
"
target="_blank"
rel="noopener noreferrer ugc"
:href="accountURL(extra)"
:title="
$t('View account on {hostname} (in a new window)', {
hostname: urlToHostname(accountURL(extra)),
})
"
>{{ extra.value }}</a
>
<span v-else>{{ extra.value }}</span>
</event-metadata-block>
<b-modal
class="map-modal"
v-if="physicalAddress && physicalAddress.geom"
:active.sync="showMap"
has-modal-card
full-screen
>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="showMap = false" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Address } from "@/types/address.model";
import { IConfig } from "@/types/config.model";
import {
EventMetadataKeyType,
EventMetadataType,
RoutingTransportationType,
RoutingType,
} from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { usernameWithDomain } from "../../types/actor";
import EventMetadataBlock from "./EventMetadataBlock.vue";
import EventFullDate from "./EventFullDate.vue";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import {
IEventMetadata,
IEventMetadataDescription,
} from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";
@Component({
components: {
EventMetadataBlock,
EventFullDate,
PopoverActorCard,
ActorCard,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
},
})
export default class EventMetadataSidebar extends Vue {
@Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent;
@Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig;
showMap = false;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
eventMetaDataList = eventMetaDataList;
EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType;
RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
get extraMetadata(): IEventMetadata[] {
return this.event.metadata.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
});
}
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
const routingType = this.config.maps.routing.type;
/**
* build urls to routing map
*/
if (!this.RoutingParamType[routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
}
simpleURL(url: string): string | null {
try {
const uri = new URL(url);
return `${this.removeWWW(uri.hostname)}${uri.pathname}${uri.search}${
uri.hash
}`;
} catch (e) {
return null;
}
}
private removeWWW(string: string): string {
return string.replace(/^www./, "");
}
accountURL(extra: IEventMetadataDescription): string | undefined {
switch (extra.key) {
case "mz:social:twitter:account": {
const handle =
extra.value[0] === "@" ? extra.value.slice(1) : extra.value;
return `https://twitter.com/${handle}`;
}
}
}
}
</script>
<style lang="scss" scoped>
::v-deep .metadata-organized-by {
.v-popover.popover .trigger {
width: 100%;
.media-content {
width: calc(100% - 32px - 1rem);
max-width: 80vw;
p.has-text-grey-dark {
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
div.address-wrapper {
display: flex;
flex: 1;
flex-wrap: wrap;
div.address {
flex: 1;
.map-show-button {
cursor: pointer;
}
address {
font-style: normal;
flex-wrap: wrap;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
}
:not(.addressDescription) {
flex: 1;
min-width: 100%;
}
}
}
}
.map-modal {
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div class="peertube">
<div class="peertube-video" v-if="videoDetails">
<iframe
width="100%"
height="100%"
sandbox="allow-same-origin allow-scripts allow-popups"
:src="`https://${videoDetails.host}/videos/embed/${videoDetails.uuid}`"
frameborder="0"
allowfullscreen
></iframe>
</div>
</div>
</template>
<script lang="ts">
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class PeerTubeIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
get videoDetails(): { host: string; uuid: string } | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1] && matches[2]) {
return { host: matches[1], uuid: matches[2] };
}
}
return null;
}
get origin(): string {
return window.location.hostname;
}
}
</script>
<style lang="scss" scoped>
.peertube {
.peertube-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div class="twitch">
<div class="twitch-video" v-if="channelName">
<iframe
:src="`https://player.twitch.tv/?channel=${channelName}&parent=${origin}&autoplay=false`"
frameborder="0"
scrolling="no"
allowfullscreen="true"
height="100%"
width="100%"
>
</iframe>
</div>
</div>
</template>
<script lang="ts">
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class TwitchIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
get channelName(): string | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
}
return null;
}
get origin(): string {
return window.location.hostname;
}
}
</script>
<style lang="scss" scoped>
.twitch {
.twitch-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div class="youtube">
<div class="youtube-video" v-if="videoID">
<iframe
width="100%"
height="100%"
:src="`https://www.youtube.com/embed/${videoID}`"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</div>
</template>
<script lang="ts">
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class YouTubeIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
get videoID(): string | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
}
return null;
}
get origin(): string {
return window.location.hostname;
}
}
</script>
<style lang="scss" scoped>
.youtube {
.youtube-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View file

@ -171,6 +171,12 @@ const FULL_EVENT_FRAGMENT = gql`
options {
...EventOptions
}
metadata {
key
title
value
type
}
}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
@ -326,6 +332,7 @@ export const EDIT_EVENT = gql`
$physicalAddress: AddressInput
$options: EventOptionsInput
$contacts: [Contact]
$metadata: EventMetadataInput
) {
updateEvent(
eventId: $id
@ -347,6 +354,7 @@ export const EDIT_EVENT = gql`
physicalAddress: $physicalAddress
options: $options
contacts: $contacts
metadata: $metadata
) {
...FullEvent
}

View file

@ -1072,5 +1072,55 @@
"+ Create a post": "+ Create a post",
"Edited {relative_time} ago": "Edited {relative_time} ago",
"Members-only post": "Members-only post",
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator."
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.",
"Find or add an element": "Find or add an element",
"e.g. Accessibility, Twitch, PeerTube": "e.g. Accessibility, Twitch, PeerTube",
"Add new…": "Add new…",
"No results for {search}": "No results for {search}",
"Wheelchair accessibility": "Wheelchair accessibility",
"Whether the event is accessible with a wheelchair": "Whether the event is accessible with a wheelchair",
"Not accessible with a wheelchair": "Not accessible with a wheelchair",
"Partially accessible with a wheelchair": "Partially accessible with a wheelchair",
"Fully accessible with a wheelchair": "Fully accessible with a wheelchair",
"YouTube replay": "YouTube replay",
"The URL where the event live can be watched again after it has ended": "The URL where the event live can be watched again after it has ended",
"Twitch replay": "Twitch replay",
"PeerTube replay": "PeerTube replay",
"PeerTube live": "PeerTube live",
"The URL where the event can be watched live": "The URL where the event can be watched live",
"Twitch live": "Twitch live",
"YouTube live": "YouTube live",
"Event metadata": "Event metadata",
"Framadate poll": "Framadate poll",
"The URL of a poll where the choice for the event date is happening": "The URL of a poll where the choice for the event date is happening",
"View account on {hostname} (in a new window)": "View account on {hostname} (in a new window)",
"Twitter account": "Twitter account",
"A twitter account handle to follow for event updates": "A twitter account handle to follow for event updates",
"Fediverse account": "Fediverse account",
"A fediverse account URL to follow for event updates": "A fediverse account URL to follow for event updates",
"Element title": "Element title",
"Element value": "Element value",
"Subtitles": "Subtitles",
"Whether the event live video is subtitled": "Whether the event live video is subtitled",
"The event live video contains subtitles": "The event live video contains subtitles",
"The event live video does not contain subtitles": "The event live video does not contain subtitles",
"Sign Language": "Sign Language",
"Whether the event is interpreted in sign language": "Whether the event is interpreted in sign language",
"The event has a sign language interpreter": "The event has a sign language interpreter",
"The event hasn't got a sign language interpreter": "The event hasn't got a sign language interpreter",
"Online ticketing": "Online ticketing",
"An URL to an external ticketing platform": "An URL to an external ticketing platform",
"Price sheet": "Price sheet",
"A link to a page presenting the price options": "A link to a page presenting the price options",
"Integrate this event with 3rd-party tools and show metadata for the event.": "Integrate this event with 3rd-party tools and show metadata for the event.",
"This URL doesn't seem to be valid": "This URL doesn't seem to be valid",
"Schedule": "Schedule",
"A link to a page presenting the event schedule": "A link to a page presenting the event schedule",
"Accessibility": "Accessibility",
"Live": "Live",
"Replay": "Replay",
"Tools": "Tools",
"Social": "Social",
"Details": "Details",
"Booking": "Booking"
}

View file

@ -1163,5 +1163,55 @@
"+ Create a post": "+ Créer un billet",
"Edited {relative_time} ago": "Édité il y a {relative_time}",
"Members-only post": "Billet reservé aux membres",
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "Ce billet est accessible uniquement aux membres. Vous y avez accès à des fins de modération car vous êtes modérateur⋅ice de l'instance."
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "Ce billet est accessible uniquement aux membres. Vous y avez accès à des fins de modération car vous êtes modérateur⋅ice de l'instance.",
"Find or add an element": "Trouver ou ajouter un élément",
"e.g. Accessibility, Twitch, PeerTube": "par ex. Accessibilité, Framadate, PeerTube",
"Add new…": "Ajouter un nouvel élément…",
"No results for {search}": "Pas de résultats pour {search}",
"Wheelchair accessibility": "Accessibilité aux fauteuils roulants",
"Whether the event is accessible with a wheelchair": "Si l'événement est accessible avec un fauteuil roulant",
"Not accessible with a wheelchair": "Non accessible avec un fauteuil roulant",
"Partially accessible with a wheelchair": "Partiellement accessible avec un fauteuil roulant",
"Fully accessible with a wheelchair": "Entièrement accessible avec un fauteuil roulant",
"YouTube replay": "Replay sur YouTube",
"The URL where the event live can be watched again after it has ended": "L'URL où le direct de l'événement peut être visionné à nouveau une fois terminé",
"Twitch replay": "Replay sur Twitch",
"PeerTube replay": "Replay sur PeerTube",
"PeerTube live": "Direct sur PeerTube",
"The URL where the event can be watched live": "L'URL où l'événement peut être visionné en direct",
"Twitch live": "Direct sur Twitch",
"YouTube live": "Direct sur YouTube",
"Event metadata": "Métadonnées de l'événement",
"Framadate poll": "Sondage Framadate",
"The URL of a poll where the choice for the event date is happening": "L'URL d'un sondage où la date de l'événement doit être choisie",
"View account on {hostname} (in a new window)": "Voir le compte sur {hostname} (dans une nouvelle fenêtre)",
"Twitter account": "Compte Twitter",
"A twitter account handle to follow for event updates": "Un compte sur Twitter à suivre pour les mises à jour de l'événement",
"Fediverse account": "Compte fediverse",
"A fediverse account URL to follow for event updates": "Un compte sur le fediverse à suivre pour les mises à jour de l'événement",
"Element title": "Titre de l'élement",
"Element value": "Valeur de l'élement",
"Subtitles": "Sous-titres",
"Whether the event live video is subtitled": "Si le direct vidéo de l'événement est sous-titré",
"The event live video contains subtitles": "Le direct vidéo de l'événement contient des sous-titres",
"The event live video does not contain subtitles": "Le direct vidéo de l'événement ne contient pas de sous-titres",
"Sign Language": "Langue des signes",
"Whether the event is interpreted in sign language": "Si l'événement est interprété en langue des signes",
"The event has a sign language interpreter": "L'événement a un interprète en langue des signes",
"The event hasn't got a sign language interpreter": "L'événement n'a pas d'interprète en langue des signes",
"Online ticketing": "Billetterie en ligne",
"An URL to an external ticketing platform": "Une URL vers une plateforme de billetterie externe",
"Price sheet": "Feuille des prix",
"A link to a page presenting the price options": "Un lien vers une page présentant la tarification",
"Integrate this event with 3rd-party tools and show metadata for the event.": "Intégrer cet événement avec des outils tiers et afficher des métadonnées pour l'événement.",
"This URL doesn't seem to be valid": "Cette URL ne semble pas être valide",
"Schedule": "Programme",
"A link to a page presenting the event schedule": "Un lien vers une page présentant le programme de l'événement",
"Accessibility": "Accessibilité",
"Live": "Direct",
"Replay": "Rattrapage",
"Tools": "Outils",
"Social": "Social",
"Details": "Détails",
"Booking": "Réservations"
}

View file

@ -193,13 +193,4 @@ export default class EventMixin extends mixins(Vue) {
console.error(error);
}
}
// eslint-disable-next-line class-methods-use-this
urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
}
}

View file

@ -0,0 +1,212 @@
import {
EventMetadataType,
EventMetadataKeyType,
EventMetadataCategories,
} from "@/types/enums";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { i18n } from "@/utils/i18n";
export const eventMetaDataList: IEventMetadataDescription[] = [
{
icon: "wheelchair-accessibility",
key: "mz:accessibility:wheelchairAccessible",
label: i18n.t("Wheelchair accessibility") as string,
description: i18n.t(
"Whether the event is accessible with a wheelchair"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.CHOICE,
choices: {
no: i18n.t("Not accessible with a wheelchair") as string,
partially: i18n.t("Partially accessible with a wheelchair") as string,
fully: i18n.t("Fully accessible with a wheelchair") as string,
},
category: EventMetadataCategories.ACCESSIBILITY,
},
{
icon: "subtitles",
key: "mz:accessibility:live:subtitle",
label: i18n.t("Subtitles") as string,
description: i18n.t("Whether the event live video is subtitled") as string,
value: "",
type: EventMetadataType.BOOLEAN,
keyType: EventMetadataKeyType.PLAIN,
choices: {
true: i18n.t("The event live video contains subtitles") as string,
false: i18n.t(
"The event live video does not contain subtitles"
) as string,
},
category: EventMetadataCategories.ACCESSIBILITY,
},
{
icon: "mz:icon:sign_language",
key: "mz:accessibility:live:sign_language",
label: i18n.t("Sign Language") as string,
description: i18n.t(
"Whether the event is interpreted in sign language"
) as string,
value: "",
type: EventMetadataType.BOOLEAN,
keyType: EventMetadataKeyType.PLAIN,
choices: {
true: i18n.t("The event has a sign language interpreter") as string,
false: i18n.t(
"The event hasn't got a sign language interpreter"
) as string,
},
category: EventMetadataCategories.ACCESSIBILITY,
},
{
icon: "youtube",
key: "mz:replay:youtube:url",
label: i18n.t("YouTube replay") as string,
description: i18n.t(
"The URL where the event live can be watched again after it has ended"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
pattern:
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-_]*)(&(amp;)?[\w?=]*)?/,
category: EventMetadataCategories.REPLAY,
},
// {
// icon: "twitch",
// key: "mz:replay:twitch:url",
// label: i18n.t("Twitch replay") as string,
// description: i18n.t(
// "The URL where the event live can be watched again after it has ended"
// ) as string,
// value: "",
// type: EventMetadataType.STRING,
// },
{
icon: "mz:icon:peertube",
key: "mz:replay:peertube:url",
label: i18n.t("PeerTube replay") as string,
description: i18n.t(
"The URL where the event live can be watched again after it has ended"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
pattern: /^https?:\/\/([^/]+)\/(?:videos\/(?:watch|embed)|w)\/([^/]+)$/,
category: EventMetadataCategories.REPLAY,
},
{
icon: "mz:icon:peertube",
key: "mz:live:peertube:url",
label: i18n.t("PeerTube live") as string,
description: i18n.t(
"The URL where the event can be watched live"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
pattern: /^https?:\/\/([^/]+)\/(?:videos\/(?:watch|embed)|w)\/([^/]+)$/,
category: EventMetadataCategories.LIVE,
},
{
icon: "twitch",
key: "mz:live:twitch:url",
label: i18n.t("Twitch live") as string,
description: i18n.t(
"The URL where the event can be watched live"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
placeholder: "https://www.twitch.tv/",
pattern: /^(?:https?:\/\/)?(?:www\.|go\.)?twitch\.tv\/([a-z0-9_]+)($|\?)/,
category: EventMetadataCategories.LIVE,
},
{
icon: "youtube",
key: "mz:live:youtube:url",
label: i18n.t("YouTube live") as string,
description: i18n.t(
"The URL where the event can be watched live"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
pattern:
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-_]*)(&(amp;)?[\w?=]*)?/,
category: EventMetadataCategories.LIVE,
},
{
icon: "calendar-check",
key: "mz:poll:framadate:url",
label: i18n.t("Framadate poll") as string,
description: i18n.t(
"The URL of a poll where the choice for the event date is happening"
) as string,
value: "",
placeholder: "https://framadate.org/",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
category: EventMetadataCategories.TOOLS,
},
{
icon: "twitter",
key: "mz:social:twitter:account",
label: i18n.t("Twitter account") as string,
description: i18n.t(
"A twitter account handle to follow for event updates"
) as string,
value: "",
placeholder: "@JoinMobilizon",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.HANDLE,
category: EventMetadataCategories.SOCIAL,
},
{
icon: "mz:icon:fediverse",
key: "mz:social:fediverse:account_url",
label: i18n.t("Fediverse account") as string,
description: i18n.t(
"A fediverse account URL to follow for event updates"
) as string,
value: "",
placeholder: "https://framapiaf.org/@mobilizon",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
category: EventMetadataCategories.SOCIAL,
},
{
icon: "ticket-confirmation",
key: "mz:ticket:external_url",
label: i18n.t("Online ticketing") as string,
description: i18n.t("An URL to an external ticketing platform") as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
category: EventMetadataCategories.BOOKING,
},
{
icon: "cash",
key: "mz:ticket:price_url",
label: i18n.t("Price sheet") as string,
description: i18n.t(
"A link to a page presenting the price options"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
category: EventMetadataCategories.DETAILS,
},
{
icon: "calendar-text",
key: "mz:schedule_url",
label: i18n.t("Schedule") as string,
description: i18n.t(
"A link to a page presenting the event schedule"
) as string,
value: "",
type: EventMetadataType.STRING,
keyType: EventMetadataKeyType.URL,
category: EventMetadataCategories.DETAILS,
},
];

View file

@ -251,3 +251,27 @@ export enum SortDirection {
ASC = "ASC",
DESC = "DESC",
}
export enum EventMetadataType {
STRING = "STRING",
INTEGER = "INTEGER",
FLOAT = "FLOAT",
BOOLEAN = "BOOLEAN",
}
export enum EventMetadataKeyType {
PLAIN = "PLAIN",
URL = "URL",
CHOICE = "CHOICE",
HANDLE = "HANDLE",
}
export enum EventMetadataCategories {
ACCESSIBILITY = "ACCESSIBILITY",
LIVE = "LIVE",
REPLAY = "REPLAY",
SOCIAL = "SOCIAL",
TOOLS = "TOOLS",
DETAILS = "DETAILS",
BOOKING = "BOOKING",
}

View file

@ -0,0 +1,23 @@
import {
EventMetadataCategories,
EventMetadataKeyType,
EventMetadataType,
} from "./enums";
export interface IEventMetadata {
key: string;
title?: string;
value: string;
type: EventMetadataType;
}
export interface IEventMetadataDescription extends IEventMetadata {
icon?: string;
placeholder?: string;
description: string;
choices?: Record<string, string>;
keyType: EventMetadataKeyType;
pattern?: RegExp;
label: string;
category: EventMetadataCategories;
}

View file

@ -10,6 +10,7 @@ import type { IParticipant } from "./participant.model";
import { EventOptions } from "./event-options.model";
import type { IEventOptions } from "./event-options.model";
import { EventJoinOptions, EventStatus, EventVisibility } from "./enums";
import { IEventMetadata } from "./event-metadata";
export interface IEventCardOptions {
hideDate: boolean;
@ -49,6 +50,7 @@ interface IEventEditJSON {
tags: string[];
options: IEventOptions;
contacts: { id?: string }[];
metadata: IEventMetadata[];
}
export interface IEvent {
@ -84,6 +86,7 @@ export interface IEvent {
tags: ITag[];
options: IEventOptions;
metadata: IEventMetadata[];
contacts: IActor[];
toEditJSON(): IEventEditJSON;
@ -153,6 +156,8 @@ export class EventModel implements IEvent {
options: IEventOptions = new EventOptions();
metadata: IEventMetadata[] = [];
constructor(hash?: IEvent) {
if (!hash) return;
@ -193,6 +198,7 @@ export class EventModel implements IEvent {
this.contacts = hash.contacts;
this.tags = hash.tags;
this.metadata = hash.metadata;
if (hash.options) this.options = hash.options;
}
@ -212,6 +218,12 @@ export class EventModel implements IEvent {
phoneAddress: this.phoneAddress,
physicalAddress: this.removeTypeName(this.physicalAddress),
options: this.removeTypeName(this.options),
metadata: this.metadata.map(({ key, value, type, title }) => ({
key,
value,
type,
title,
})),
attributedToId:
this.attributedTo && this.attributedTo.id ? this.attributedTo.id : null,
contacts: this.contacts.map(({ id }) => ({

View file

@ -122,6 +122,15 @@
</span>
</p>
</div>
<subtitle>{{ $t("Event metadata") }}</subtitle>
<p>
{{
$t(
"Integrate this event with 3rd-party tools and show metadata for the event."
)
}}
</p>
<event-metadata-list v-model="event.metadata" />
<subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
<div class="field">
<b-radio
@ -451,6 +460,7 @@ import PictureUpload from "@/components/PictureUpload.vue";
import EditorComponent from "@/components/Editor.vue";
import TagInput from "@/components/Event/TagInput.vue";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import EventMetadataList from "@/components/Event/EventMetadataList.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import Subtitle from "@/components/Utils/Subtitle.vue";
import { Route } from "vue-router";
@ -515,6 +525,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
TagInput,
PictureUpload,
Editor: EditorComponent,
EventMetadataList,
},
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,

View file

@ -294,104 +294,11 @@
<div class="event-description-wrapper">
<aside class="event-metadata">
<div class="sticky">
<event-metadata-block
:title="$t('Location')"
:icon="
physicalAddress
? physicalAddress.poiInfos.poiIcon.icon
: 'earth'
"
>
<div class="address-wrapper">
<span v-if="!physicalAddress">{{
$t("No address defined")
}}</span>
<div class="address" v-if="physicalAddress">
<div>
<address>
<p
class="addressDescription"
:title="physicalAddress.poiInfos.name"
>
{{ physicalAddress.poiInfos.name }}
</p>
<p class="has-text-grey-dark">
{{ physicalAddress.poiInfos.alternativeName }}
</p>
</address>
</div>
<span
class="map-show-button"
@click="showMap = !showMap"
v-if="physicalAddress.geom"
>{{ $t("Show map") }}</span
>
</div>
</div>
</event-metadata-block>
<event-metadata-block :title="$t('Date and time')" icon="calendar">
<event-full-date
:beginsOn="event.beginsOn"
:show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime"
:endsOn="event.endsOn"
/>
</event-metadata-block>
<event-metadata-block
class="metadata-organized-by"
:title="$t('Organized by')"
>
<popover-actor-card
:actor="event.organizerActor"
v-if="!event.attributedTo"
>
<actor-card :actor="event.organizerActor" />
</popover-actor-card>
<router-link
v-if="event.attributedTo"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(event.attributedTo),
},
}"
>
<popover-actor-card
:actor="event.attributedTo"
v-if="
!event.attributedTo ||
!event.options.hideOrganizerWhenGroupEvent
"
>
<actor-card :actor="event.attributedTo" />
</popover-actor-card>
</router-link>
<popover-actor-card
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
>
<actor-card :actor="contact" />
</popover-actor-card>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
icon="link"
:title="$t('Website')"
>
<a
target="_blank"
rel="noopener noreferrer"
:href="event.onlineAddress"
:title="
$t('View page on {hostname} (in a new window)', {
hostname: urlToHostname(event.onlineAddress),
})
"
>{{ urlToHostname(event.onlineAddress) }}</a
>
</event-metadata-block>
<event-metadata-sidebar
v-if="event && config"
:event="event"
:config="config"
/>
</div>
</aside>
<div class="event-description-comments">
@ -408,6 +315,14 @@
/>
</div>
</section>
<section class="integration-wrappers">
<component
v-for="(metadata, integration) in integrations"
:is="integration"
:key="integration"
:metadata="metadata"
/>
</section>
<section class="comments" ref="commentsObserver">
<a href="#comments">
<subtitle id="comments">{{ $t("Comments") }}</subtitle>
@ -531,80 +446,6 @@
</section>
</div>
</b-modal>
<b-modal
class="map-modal"
v-if="physicalAddress && physicalAddress.geom"
:active.sync="showMap"
has-modal-card
full-screen
>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="showMap = false" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</b-modal>
</div>
</div>
</template>
@ -618,8 +459,6 @@ import {
EventVisibility,
MemberRole,
ParticipantRole,
RoutingTransportationType,
RoutingType,
} from "@/types/enums";
import {
EVENT_PERSON_PARTICIPATION,
@ -636,7 +475,6 @@ import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor";
import { GRAPHQL_API_ENDPOINT } from "../../api/_entrypoint";
import DateCalendarIcon from "../../components/Event/DateCalendarIcon.vue";
import EventCard from "../../components/Event/EventCard.vue";
import EventFullDate from "../../components/Event/EventFullDate.vue";
import ReportModal from "../../components/Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
@ -644,7 +482,6 @@ import EventMixin from "../../mixins/event";
import IdentityPicker from "../Account/IdentityPicker.vue";
import ParticipationSection from "../../components/Participation/ParticipationSection.vue";
import RouteName from "../../router/name";
import { Address } from "../../types/address.model";
import CommentTree from "../../components/Comment/CommentTree.vue";
import "intersection-observer";
import { CONFIG } from "../../graphql/config";
@ -657,19 +494,18 @@ import {
import { IConfig } from "../../types/config.model";
import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
import EventMetadataSidebar from "../../components/Event/EventMetadataSidebar.vue";
import EventBanner from "../../components/Event/EventBanner.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";
// noinspection TypeScriptValidateTypes
@Component({
components: {
EventMetadataBlock,
Subtitle,
EventFullDate,
EventCard,
BIcon,
DateCalendarIcon,
@ -678,15 +514,25 @@ import { ApolloCache, FetchResult } from "@apollo/client/core";
ParticipationSection,
CommentTree,
Tag,
ActorCard,
PopoverActorCard,
EventBanner,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
EventMetadataSidebar,
ShareEventModal: () =>
import(
/* webpackChunkName: "shareEventModal" */ "../../components/Event/ShareEventModal.vue"
),
"integration-twitch": () =>
import(
/* webpackChunkName: "twitchIntegration" */ "../../components/Event/Integrations/Twitch.vue"
),
"integration-peertube": () =>
import(
/* webpackChunkName: "PeerTubeIntegration" */ "../../components/Event/Integrations/PeerTube.vue"
),
"integration-youtube": () =>
import(
/* webpackChunkName: "YouTubeIntegration" */ "../../components/Event/Integrations/YouTube.vue"
),
},
apollo: {
event: {
@ -783,8 +629,6 @@ export default class Event extends EventMixin {
oldParticipationRole!: string;
showMap = false;
isReportModalActive = false;
isShareModalActive = false;
@ -813,65 +657,6 @@ export default class Event extends EventMixin {
messageForConfirmation = "";
RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
const routingType = this.config.maps.routing.type;
/**
* build urls to routing map
*/
if (!this.RoutingParamType[routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
get eventTitle(): undefined | string {
if (!this.event) return undefined;
return this.event.title;
@ -1262,12 +1047,6 @@ export default class Event extends EventMixin {
);
}
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
async anonymousParticipationConfirmed(): Promise<boolean> {
return isParticipatingInThisEvent(this.uuid);
}
@ -1302,6 +1081,32 @@ export default class Event extends EventMixin {
}
return null;
}
metadataToComponent: Record<string, string> = {
"mz:live:twitch:url": "integration-twitch",
"mz:live:peertube:url": "integration-peertube",
"mz:live:youtube:url": "integration-youtube",
};
get integrations(): Record<string, IEventMetadataDescription> {
return this.event.metadata
.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
})
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
const component = this.metadataToComponent[metadata.key];
if (component !== undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[component] = metadata;
}
return acc;
}, {});
}
}
</script>
<style lang="scss" scoped>
@ -1402,60 +1207,6 @@ div.sidebar {
top: 50px;
padding: 1rem;
}
div.address-wrapper {
display: flex;
flex: 1;
flex-wrap: wrap;
div.address {
flex: 1;
.map-show-button {
cursor: pointer;
}
address {
font-style: normal;
flex-wrap: wrap;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
}
:not(.addressDescription) {
flex: 1;
min-width: 100%;
}
}
}
}
span.online-address {
display: flex;
}
}
::v-deep .metadata-organized-by {
.v-popover.popover .trigger {
width: 100%;
.media-content {
width: calc(100% - 32px - 1rem);
max-width: 80vw;
p.has-text-grey-dark {
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
div.event-description-comments {
@ -1547,29 +1298,6 @@ a.participations-link {
font-size: 1rem;
}
.map-modal {
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
}
}
.no-border {
border: 0;
cursor: auto;

View file

@ -118,7 +118,7 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { Route } from "vue-router";
import { ICurrentUser } from "@/types/current-user.model";
import { LoginError, LoginErrorCode } from "@/types/enums";
@ -269,6 +269,13 @@ export default class Login extends Vue {
}
}
}
@Watch("currentUser")
redirectToHomepageIfAlreadyLoggedIn(): Promise<Route> | void {
if (this.currentUser.isLoggedIn) {
return this.$router.push("/");
}
}
}
</script>

View file

@ -103,6 +103,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:updated_at, :datetime, description: "When the event was last updated")
field(:inserted_at, :datetime, description: "When the event was created")
field(:options, :event_options, description: "The event options")
field(:metadata, list_of(:event_metadata), description: "A key-value list of metadata")
end
@desc "The list of visibility options for an event"
@ -290,6 +291,26 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
)
end
enum :event_metadata_type do
value(:string, description: "A string")
value(:integer, description: "An integer")
value(:boolean, description: "A boolean")
end
object :event_metadata do
field(:key, :string, description: "The key for the metadata")
field(:title, :string, description: "The title for the metadata")
field(:value, :string, description: "The value for the metadata")
field(:type, :event_metadata_type, description: "The metadata type")
end
input_object :event_metadata_input do
field(:key, non_null(:string), description: "The key for the metadata")
field(:title, :string, description: "The title for the metadata")
field(:value, non_null(:string), description: "The value for the metadata")
field(:type, :event_metadata_type, description: "The metadata type")
end
@desc """
A event contact
"""
@ -372,6 +393,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:category, :string, default_value: "meeting", description: "The event's category")
arg(:physical_address, :address_input, description: "The event's physical address")
arg(:options, :event_options_input, description: "The event options")
arg(:metadata, list_of(:event_metadata_input), description: "The event metadata")
arg(:draft, :boolean,
default_value: false,
@ -419,6 +441,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:category, :string, description: "The event's category")
arg(:physical_address, :address_input, description: "The event's physical address")
arg(:options, :event_options_input, description: "The event options")
arg(:metadata, list_of(:event_metadata_input), description: "The event metadata")
arg(:draft, :boolean, description: "Whether or not the event is a draft")
arg(:contacts, list_of(:contact), default_value: [], description: "The events contacts")

View file

@ -16,6 +16,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{
EventMetadata,
EventOptions,
EventParticipantStats,
EventStatus,
@ -108,6 +109,7 @@ defmodule Mobilizon.Events.Event do
embeds_one(:options, EventOptions, on_replace: :delete)
embeds_one(:participant_stats, EventParticipantStats, on_replace: :update)
embeds_many(:metadata, EventMetadata, on_replace: :delete)
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
belongs_to(:physical_address, Address, on_replace: :nilify)
@ -151,6 +153,7 @@ defmodule Mobilizon.Events.Event do
defp common_changeset(%Changeset{} = changeset, attrs) do
changeset
|> cast_embed(:options)
|> cast_embed(:metadata)
|> put_assoc(:contacts, Map.get(attrs, :contacts, []))
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs)

View file

@ -0,0 +1,45 @@
defmodule Mobilizon.Events.EventMetadata do
@moduledoc """
Participation stats on event
"""
use Ecto.Schema
import Ecto.Changeset
import EctoEnum
defenum(EventMetadataTypeEnum, string: 0, integer: 1, boolean: 2)
@type t :: %__MODULE__{
key: String.t(),
value: String.t()
}
@required_attrs [
:key,
:value
]
@optional_attrs [
:title,
:type
]
@attrs @required_attrs ++ @optional_attrs
@primary_key false
@derive Jason.Encoder
embedded_schema do
field(:key, :string)
field(:title, :string)
field(:value, :string)
field(:type, EventMetadataTypeEnum, default: :string)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = event_metadata, attrs) do
event_metadata
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View file

@ -36,6 +36,11 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
"ugc"
])
# Rel attributes are separated by spaces
Meta.allow_tag_with_this_attribute_values(:a, "rel", [
"noopener noreferrer ugc"
])
Meta.allow_tag_with_these_attributes(:a, ["name", "title", "target"])
Meta.allow_tag_with_these_attributes(:abbr, ["title"])

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddMetadataForEvents do
use Ecto.Migration
def change do
alter table(:events) do
add(:metadata, :map)
end
end
end