From 16f90254a0308d36504e89399b8048b393d9b141 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Thu, 5 Aug 2021 16:17:57 +0200
Subject: [PATCH 1/5] Revert "Try to add --cache to kaniko"

This reverts commit 3eb56b57aa54cb0728e88fe0e650b36ceb03d5e9.
---
 .gitlab-ci.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 91d8d1c89..28f6f4443 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -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

From bfc9dd7ea3d2877d356cf9be7a6ada9fae33c106 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 9 Aug 2021 08:59:46 +0200
Subject: [PATCH 2/5] Redirect from Login page to Homepage if already logged-in

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/views/User/Login.vue | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/js/src/views/User/Login.vue b/js/src/views/User/Login.vue
index 7d68efe42..4965d98d2 100644
--- a/js/src/views/User/Login.vue
+++ b/js/src/views/User/Login.vue
@@ -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>
 

From 9243be24484245e3782b5f389942549048a586d7 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 9 Aug 2021 14:24:38 +0200
Subject: [PATCH 3/5] Fix apollo cache merge issue

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/apollo/utils.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/js/src/apollo/utils.ts b/js/src/apollo/utils.ts
index d8c3bdb2b..d47680ba5 100644
--- a/js/src/apollo/utils.ts
+++ b/js/src/apollo/utils.ts
@@ -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);
   }

From 33bf8334fe775cc224c18907d1c6280551c3c977 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 9 Aug 2021 14:24:54 +0200
Subject: [PATCH 4/5] Allow all rel values for event & post links in
 descriptions

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/Editor.vue               | 4 +++-
 lib/service/formatter/default_scrubbler.ex | 5 +++++
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/js/src/components/Editor.vue b/js/src/components/Editor.vue
index f0b5a0eac..3e6491b76 100644
--- a/js/src/components/Editor.vue
+++ b/js/src/components/Editor.vue
@@ -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,
         }),
diff --git a/lib/service/formatter/default_scrubbler.ex b/lib/service/formatter/default_scrubbler.ex
index 001207216..70372d5b8 100644
--- a/lib/service/formatter/default_scrubbler.ex
+++ b/lib/service/formatter/default_scrubbler.ex
@@ -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"])

From 5f3d1f89dff6cf43d1e6b47aeffc70bd89011274 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 9 Aug 2021 14:26:11 +0200
Subject: [PATCH 5/5] Allow to add metadata to an event

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/public/img/fediverse_monochrome.svg        |   1 +
 js/public/img/peertube_monochrome.svg         |   1 +
 js/public/img/sign_language_monochrome.svg    |   1 +
 .../components/Event/EventMetadataBlock.vue   |  20 +-
 js/src/components/Event/EventMetadataItem.vue | 140 ++++++
 js/src/components/Event/EventMetadataList.vue | 206 ++++++++
 .../components/Event/EventMetadataSidebar.vue | 450 ++++++++++++++++++
 .../Event/Integrations/PeerTube.vue           |  55 +++
 .../components/Event/Integrations/Twitch.vue  |  56 +++
 .../components/Event/Integrations/YouTube.vue |  56 +++
 js/src/graphql/event.ts                       |   8 +
 js/src/i18n/en_US.json                        |  52 +-
 js/src/i18n/fr_FR.json                        |  52 +-
 js/src/mixins/event.ts                        |   9 -
 js/src/services/EventMetadata.ts              | 212 +++++++++
 js/src/types/enums.ts                         |  24 +
 js/src/types/event-metadata.ts                |  23 +
 js/src/types/event.model.ts                   |  12 +
 js/src/views/Event/Edit.vue                   |  11 +
 js/src/views/Event/Event.vue                  | 382 +++------------
 lib/graphql/schema/event.ex                   |  23 +
 lib/mobilizon/events/event.ex                 |   3 +
 lib/mobilizon/events/event_metadata.ex        |  45 ++
 ...20210805142745_add_metadata_for_events.exs |   9 +
 24 files changed, 1512 insertions(+), 339 deletions(-)
 create mode 100644 js/public/img/fediverse_monochrome.svg
 create mode 100644 js/public/img/peertube_monochrome.svg
 create mode 100644 js/public/img/sign_language_monochrome.svg
 create mode 100644 js/src/components/Event/EventMetadataItem.vue
 create mode 100644 js/src/components/Event/EventMetadataList.vue
 create mode 100644 js/src/components/Event/EventMetadataSidebar.vue
 create mode 100644 js/src/components/Event/Integrations/PeerTube.vue
 create mode 100644 js/src/components/Event/Integrations/Twitch.vue
 create mode 100644 js/src/components/Event/Integrations/YouTube.vue
 create mode 100644 js/src/services/EventMetadata.ts
 create mode 100644 js/src/types/event-metadata.ts
 create mode 100644 lib/mobilizon/events/event_metadata.ex
 create mode 100644 priv/repo/migrations/20210805142745_add_metadata_for_events.exs

diff --git a/js/public/img/fediverse_monochrome.svg b/js/public/img/fediverse_monochrome.svg
new file mode 100644
index 000000000..ada84802c
--- /dev/null
+++ b/js/public/img/fediverse_monochrome.svg
@@ -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>
\ No newline at end of file
diff --git a/js/public/img/peertube_monochrome.svg b/js/public/img/peertube_monochrome.svg
new file mode 100644
index 000000000..90a90f6f6
--- /dev/null
+++ b/js/public/img/peertube_monochrome.svg
@@ -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>
\ No newline at end of file
diff --git a/js/public/img/sign_language_monochrome.svg b/js/public/img/sign_language_monochrome.svg
new file mode 100644
index 000000000..33256bedf
--- /dev/null
+++ b/js/public/img/sign_language_monochrome.svg
@@ -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>
\ No newline at end of file
diff --git a/js/src/components/Event/EventMetadataBlock.vue b/js/src/components/Event/EventMetadataBlock.vue
index be72e52bf..48ab50fbf 100644
--- a/js/src/components/Event/EventMetadataBlock.vue
+++ b/js/src/components/Event/EventMetadataBlock.vue
@@ -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;
+      }
     }
   }
 }
diff --git a/js/src/components/Event/EventMetadataItem.vue b/js/src/components/Event/EventMetadataItem.vue
new file mode 100644
index 000000000..9a950b860
--- /dev/null
+++ b/js/src/components/Event/EventMetadataItem.vue
@@ -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>
diff --git a/js/src/components/Event/EventMetadataList.vue b/js/src/components/Event/EventMetadataList.vue
new file mode 100644
index 000000000..b5779def6
--- /dev/null
+++ b/js/src/components/Event/EventMetadataList.vue
@@ -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>
diff --git a/js/src/components/Event/EventMetadataSidebar.vue b/js/src/components/Event/EventMetadataSidebar.vue
new file mode 100644
index 000000000..af567ee76
--- /dev/null
+++ b/js/src/components/Event/EventMetadataSidebar.vue
@@ -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>
diff --git a/js/src/components/Event/Integrations/PeerTube.vue b/js/src/components/Event/Integrations/PeerTube.vue
new file mode 100644
index 000000000..db4ccaed2
--- /dev/null
+++ b/js/src/components/Event/Integrations/PeerTube.vue
@@ -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>
diff --git a/js/src/components/Event/Integrations/Twitch.vue b/js/src/components/Event/Integrations/Twitch.vue
new file mode 100644
index 000000000..a89f740c4
--- /dev/null
+++ b/js/src/components/Event/Integrations/Twitch.vue
@@ -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>
diff --git a/js/src/components/Event/Integrations/YouTube.vue b/js/src/components/Event/Integrations/YouTube.vue
new file mode 100644
index 000000000..2dc188691
--- /dev/null
+++ b/js/src/components/Event/Integrations/YouTube.vue
@@ -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>
diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts
index f563e8a97..600ea4db2 100644
--- a/js/src/graphql/event.ts
+++ b/js/src/graphql/event.ts
@@ -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
     }
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 55450901f..3d054a646 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -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"
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index cc125b8f9..58630776b 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -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"
 }
diff --git a/js/src/mixins/event.ts b/js/src/mixins/event.ts
index 213db15e3..5f0b28822 100644
--- a/js/src/mixins/event.ts
+++ b/js/src/mixins/event.ts
@@ -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;
-    }
-  }
 }
diff --git a/js/src/services/EventMetadata.ts b/js/src/services/EventMetadata.ts
new file mode 100644
index 000000000..a6b899d85
--- /dev/null
+++ b/js/src/services/EventMetadata.ts
@@ -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,
+  },
+];
diff --git a/js/src/types/enums.ts b/js/src/types/enums.ts
index f05e5262c..b5c738b7c 100644
--- a/js/src/types/enums.ts
+++ b/js/src/types/enums.ts
@@ -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",
+}
diff --git a/js/src/types/event-metadata.ts b/js/src/types/event-metadata.ts
new file mode 100644
index 000000000..776ed7fd7
--- /dev/null
+++ b/js/src/types/event-metadata.ts
@@ -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;
+}
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index 3dcf496e0..eb3c02469 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -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 }) => ({
diff --git a/js/src/views/Event/Edit.vue b/js/src/views/Event/Edit.vue
index 9144809a9..eaacac57b 100644
--- a/js/src/views/Event/Edit.vue
+++ b/js/src/views/Event/Edit.vue
@@ -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,
diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue
index 43844ee04..31e4fe785 100755
--- a/js/src/views/Event/Event.vue
+++ b/js/src/views/Event/Event.vue
@@ -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;
diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex
index e5a4446ad..e3eb688a3 100644
--- a/lib/graphql/schema/event.ex
+++ b/lib/graphql/schema/event.ex
@@ -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")
 
diff --git a/lib/mobilizon/events/event.ex b/lib/mobilizon/events/event.ex
index f2be4f461..d024cc7c9 100644
--- a/lib/mobilizon/events/event.ex
+++ b/lib/mobilizon/events/event.ex
@@ -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)
diff --git a/lib/mobilizon/events/event_metadata.ex b/lib/mobilizon/events/event_metadata.ex
new file mode 100644
index 000000000..70f5b273a
--- /dev/null
+++ b/lib/mobilizon/events/event_metadata.ex
@@ -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
diff --git a/priv/repo/migrations/20210805142745_add_metadata_for_events.exs b/priv/repo/migrations/20210805142745_add_metadata_for_events.exs
new file mode 100644
index 000000000..5a46eef64
--- /dev/null
+++ b/priv/repo/migrations/20210805142745_add_metadata_for_events.exs
@@ -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