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