From 48935e2168c014ce4968fde37cf5850e1216c927 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 26 Aug 2022 16:08:58 +0200
Subject: [PATCH] Add global search

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 config/config.exs                             |   8 +
 js/.eslintrc.js                               |   5 +-
 js/.gitignore                                 |   3 +
 js/get_union_json.ts                          |   4 +-
 js/package.json                               |   2 +
 js/playwright.config.ts                       | 107 ++++
 js/{src/assets => public/img}/logo.svg        |   0
 js/src/App.vue                                |  58 +-
 js/src/apollo/error-link.ts                   |   2 +-
 js/src/apollo/link.ts                         |  39 +-
 js/src/apollo/user.ts                         |   8 +-
 js/src/apollo/utils.ts                        |   7 +-
 js/src/assets/oruga-tailwindcss.css           |  78 ++-
 js/src/assets/tailwind.css                    |   6 +-
 js/src/components/Account/ActorCard.vue       |   2 +-
 .../Activity/DiscussionActivityItem.vue       |  10 +-
 .../components/Activity/EventActivityItem.vue |  10 +-
 .../components/Activity/GroupActivityItem.vue |  20 +-
 .../Activity/MemberActivityItem.vue           |  10 +-
 .../components/Activity/PostActivityItem.vue  |  10 +-
 .../Activity/ResourceActivityItem.vue         |  10 +-
 js/src/components/Activity/activity.scss      |   6 +-
 js/src/components/Address/AddressInfo.vue     |   2 +-
 js/src/components/Categories/constants.ts     | 101 ----
 js/src/components/Comment/CommentTree.vue     |   8 +-
 ...mment.story.vue => EventComment.story.vue} |  18 +-
 .../Comment/{Comment.vue => EventComment.vue} |   8 +-
 .../Discussion/DiscussionComment.vue          |   8 +-
 js/src/components/ErrorComponent.vue          |   2 +-
 js/src/components/Event/EventCard.story.vue   |  11 +-
 js/src/components/Event/EventCard.vue         | 122 +++-
 js/src/components/Event/EventFullDate.vue     |  41 +-
 js/src/components/Event/EventMap.vue          |   2 +-
 js/src/components/Event/EventMetadataList.vue |   4 +-
 .../components/Event/EventMetadataSidebar.vue |   2 +-
 .../components/Event/EventMinimalistCard.vue  |  21 +-
 .../Event/EventParticipationCard.vue          |  15 +-
 .../Event/FullAddressAutoComplete.vue         |  27 +-
 .../Event/GroupedMultiEventMinimalistCard.vue |   2 +-
 .../{Etherpad.vue => EtherpadIntegration.vue} |   0
 ...JitsiMeet.vue => JitsiMeetIntegration.vue} |   0
 .../{PeerTube.vue => PeerTubeIntegration.vue} |   4 -
 .../{Twitch.vue => TwitchIntegration.vue}     |   0
 .../{YouTube.vue => YouTubeIntegration.vue}   |   4 -
 js/src/components/Event/OrganizerPicker.vue   |   4 +-
 .../Event/OrganizerPickerWrapper.vue          |   8 +-
 .../Event/ParticipationButton.story.vue       |   2 +-
 js/src/components/Event/ShareEventModal.vue   |   2 +-
 js/src/components/Event/TagInput.story.vue    |   4 +-
 js/src/components/Event/TagInput.vue          |   8 +-
 js/src/components/Group/GroupCard.story.vue   |  16 +-
 js/src/components/Group/GroupCard.vue         |  99 +++-
 .../Group/GroupMemberCard.story.vue           |  28 +-
 js/src/components/Group/GroupMemberCard.vue   |  11 +-
 js/src/components/Group/InvitationCard.vue    |  33 +-
 ...Discussions.vue => DiscussionsSection.vue} |   0
 .../{Events.vue => EventsSection.vue}         |   0
 .../Sections/{Posts.vue => PostsSection.vue}  |   0
 .../{Resources.vue => ResourcesSection.vue}   |   0
 .../components/Group/SkeletonGroupResult.vue  |  18 +
 js/src/components/Home/CategoriesPreview.vue  |  11 +-
 js/src/components/Home/SearchFields.vue       |  45 +-
 .../components/Home/UnloggedIntroduction.vue  |   9 +-
 js/src/components/{Map.vue => LeafletMap.vue} |   0
 js/src/components/Local/CloseContent.vue      |   2 +-
 js/src/components/Local/CloseEvents.vue       |  32 +-
 js/src/components/Local/CloseGroups.vue       |  11 +-
 js/src/components/Local/LastEvents.vue        |   3 +-
 js/src/components/Local/OnlineEvents.vue      |  35 +-
 js/src/components/MobilizonLogo.vue           |   2 +-
 js/src/components/NavBar.vue                  | 102 +++-
 .../components/{Footer.vue => PageFooter.vue} |   0
 .../Participation/ConfirmParticipation.vue    |  26 +-
 .../Participation/ParticipationSection.vue    |  10 +-
 .../Participation/UnloggedParticipation.vue   |   4 +-
 js/src/components/PictureUpload.vue           |   2 +-
 js/src/components/Report/ReportCard.vue       |   2 +-
 js/src/components/Resource/FolderItem.vue     |   8 +-
 js/src/components/Resource/ResourceItem.vue   |   4 +-
 .../components/Resource/ResourceSelector.vue  |   2 +-
 .../Settings/NotificationsOnboarding.vue      |   4 +-
 .../components/Settings/SettingMenuItem.vue   |  13 +-
 .../Settings/SettingMenuSection.vue           |   4 +-
 js/src/components/Settings/SettingsMenu.vue   |   2 +-
 js/src/components/Share/DiasporaLogo.vue      |   4 +-
 js/src/components/Share/MastodonLogo.vue      |   2 +-
 js/src/components/Share/ShareModal.vue        |  22 +-
 js/src/components/Share/TelegramLogo.vue      |   2 +-
 js/src/components/Tag.vue                     |  21 +-
 .../components/{Editor.vue => TextEditor.vue} |  87 +--
 js/src/components/Todo/CompactTodo.vue        |   4 +-
 js/src/components/Todo/FullTodo.vue           |   2 +-
 .../{Breadcrumbs.vue => NavBreadcrumbs.vue}   |   5 +-
 .../components/Utils/RedirectWithAccount.vue  |   8 +-
 js/src/components/core/Button.vue             |  74 ---
 .../core/{Dialog.vue => CustomDialog.vue}     |   4 +-
 .../core/{Snackbar.vue => CustomSnackbar.vue} |   0
 js/src/components/core/Field.vue              |  33 --
 js/src/components/core/Input.vue              | 292 ----------
 js/src/components/core/LinkOrRouterLink.vue   |  39 ++
 js/src/components/core/MaterialIcon.vue       |   2 +
 js/src/components/core/Message.vue            |  57 --
 js/src/components/core/Switch.vue             |  27 -
 js/src/composition/apollo/actor.ts            |   2 +-
 js/src/composition/apollo/config.ts           |  10 +
 js/src/composition/apollo/group.ts            |  46 +-
 js/src/filters/datetime.ts                    |   9 +-
 js/src/graphql/config.ts                      |  20 +
 js/src/graphql/report.ts                      |   7 +
 js/src/graphql/search.ts                      |  57 +-
 js/src/i18n/en_US.json                        |   9 +-
 js/src/i18n/fr_FR.json                        |   9 +-
 js/src/main.ts                                |  43 +-
 js/src/oruga-config.ts                        |   9 +
 js/src/plugins/dialog.ts                      |   2 +-
 js/src/plugins/notifier.ts                    |   8 +-
 js/src/plugins/snackbar.ts                    |   4 +-
 js/src/router/actor.ts                        |   4 +-
 js/src/router/discussion.ts                   |  28 +-
 js/src/router/event.ts                        |   8 +-
 js/src/router/groups.ts                       |  12 +-
 js/src/router/index.ts                        |  13 +-
 js/src/router/settings.ts                     |  19 +-
 js/src/router/user.ts                         |   2 +-
 js/src/service-worker.ts                      |   4 +-
 js/src/services/push-subscription.ts          |  12 +-
 js/src/services/statistics/index.ts           |   4 +-
 js/src/services/statistics/sentry.ts          |   2 -
 js/src/types/actor/group.model.ts             |   2 +
 js/src/types/config.model.ts                  |   6 +
 js/src/types/enums.ts                         |   5 +
 js/src/types/event.model.ts                   |   2 +
 js/src/types/post.model.ts                    |   2 +-
 js/src/utils/auth.ts                          |   4 +-
 js/src/utils/datetime.ts                      |  23 +-
 js/src/views/About.vue                        |  11 +-
 ...boutInstance.vue => AboutInstanceView.vue} |   0
 .../About/{Glossary.vue => GlossaryView.vue}  |   0
 .../About/{Privacy.vue => PrivacyView.vue}    |   0
 .../views/About/{Rules.vue => RulesView.vue}  |   0
 .../views/About/{Terms.vue => TermsView.vue}  |   0
 .../{Register.vue => RegisterView.vue}        |   0
 .../views/Account/children/EditIdentity.vue   |   6 +-
 js/src/views/Admin/AdminGroupProfile.vue      |  98 ++--
 js/src/views/Admin/AdminProfile.vue           |  53 +-
 js/src/views/Admin/AdminUserProfile.vue       |  20 +-
 js/src/views/Admin/GroupProfiles.vue          |  32 +-
 .../Admin/{Instance.vue => InstanceView.vue}  |  25 +-
 .../{Instances.vue => InstancesView.vue}      |  66 ++-
 .../Admin/{Profiles.vue => ProfilesView.vue}  |  40 +-
 .../Admin/{Settings.vue => SettingsView.vue}  |   0
 .../views/Admin/{Users.vue => UsersView.vue}  |   0
 js/src/views/CategoriesView.vue               |  13 +-
 .../{Create.vue => CreateView.vue}            |   4 +-
 .../{Discussion.vue => DiscussionView.vue}    |   7 +-
 ...ssionsList.vue => DiscussionsListView.vue} |   1 +
 js/src/views/Event/{Edit.vue => EditView.vue} | 229 ++++----
 .../views/Event/{Event.vue => EventView.vue}  | 110 ++--
 js/src/views/Event/GroupEvents.vue            |   2 +-
 .../Event/{MyEvents.vue => MyEventsView.vue}  |   9 +-
 ...{Participants.vue => ParticipantsView.vue} | 129 ++---
 .../Group/{Create.vue => CreateView.vue}      |  91 +--
 js/src/views/Group/GroupMembers.vue           |  81 +--
 js/src/views/Group/GroupSettings.vue          | 157 ++---
 .../views/Group/{Group.vue => GroupView.vue}  |  85 +--
 .../Group/{Settings.vue => SettingsView.vue}  |   2 +-
 .../Group/{Timeline.vue => TimelineView.vue}  |  65 +--
 js/src/views/HomeView.vue                     |  67 +--
 .../views/{Interact.vue => InteractView.vue}  |   9 +-
 .../Moderation/{Logs.vue => LogsView.vue}     |   0
 js/src/views/Moderation/Report.vue            | 526 -----------------
 .../{ReportList.vue => ReportListView.vue}    |  34 +-
 js/src/views/Moderation/ReportView.vue        | 543 ++++++++++++++++++
 js/src/views/Posts/{Edit.vue => EditView.vue} |  48 +-
 js/src/views/Posts/{List.vue => ListView.vue} |   0
 js/src/views/Posts/{Post.vue => PostView.vue} |  29 +-
 js/src/views/Resources/ResourceFolder.vue     | 101 ++--
 js/src/views/SearchView.vue                   | 312 ++++++----
 js/src/views/Settings/NotificationsView.vue   |   6 +-
 js/src/views/Settings/PreferencesView.vue     |   2 +-
 js/src/views/Todos/TodoLists.vue              |   2 +-
 js/src/views/User/LoginView.vue               |  13 +-
 js/src/views/User/RegisterView.vue            |  49 +-
 js/src/views/User/SettingsOnboard.vue         |   2 +-
 js/tailwind.config.js                         |  55 +-
 js/tests/e2e/login.spec.ts                    |  73 +++
 .../__snapshots__/CommentTree.spec.ts.snap    |  60 +-
 .../ParticipationSection.spec.ts              |   4 +-
 .../__snapshots__/PostListItem.spec.ts.snap   |  28 +-
 .../__snapshots__/ReportModal.spec.ts.snap    |  18 +-
 .../__snapshots__/navbar.spec.ts.snap         |  26 +-
 js/tests/unit/specs/mocks/matchMedia.ts       |  16 +-
 js/vite.config.js                             |   1 +
 js/yarn.lock                                  |  33 ++
 lib/graphql/api/search.ex                     |  64 ++-
 lib/graphql/resolvers/address.ex              |   3 +-
 lib/graphql/resolvers/config.ex               |  12 +-
 lib/graphql/resolvers/followers.ex            |   4 +
 lib/graphql/resolvers/member.ex               |   4 +
 lib/graphql/schema/actor.ex                   |   4 +-
 lib/graphql/schema/actors/application.ex      |   4 +-
 lib/graphql/schema/actors/group.ex            |  15 +-
 lib/graphql/schema/actors/person.ex           |  13 +-
 lib/graphql/schema/config.ex                  |  11 +
 lib/graphql/schema/event.ex                   |   2 +-
 lib/graphql/schema/search.ex                  | 121 +++-
 lib/mobilizon/actors/actors.ex                |   4 +-
 lib/mobilizon/events/events.ex                |  15 +-
 lib/service/global_search/event_result.ex     |  18 +
 lib/service/global_search/global_search.ex    |  17 +
 lib/service/global_search/group_result.ex     |  19 +
 lib/service/global_search/provider.ex         |  40 ++
 lib/service/global_search/search_mobilizon.ex | 225 ++++++++
 lib/service/pictures/information.ex           |   3 +
 lib/service/pictures/unsplash.ex              |  10 +-
 lib/web/templates/page/index.html.heex        |   6 +-
 216 files changed, 3646 insertions(+), 2806 deletions(-)
 create mode 100644 js/playwright.config.ts
 rename js/{src/assets => public/img}/logo.svg (100%)
 rename js/src/components/Comment/{Comment.story.vue => EventComment.story.vue} (92%)
 rename js/src/components/Comment/{Comment.vue => EventComment.vue} (98%)
 rename js/src/components/Event/Integrations/{Etherpad.vue => EtherpadIntegration.vue} (100%)
 rename js/src/components/Event/Integrations/{JitsiMeet.vue => JitsiMeetIntegration.vue} (100%)
 rename js/src/components/Event/Integrations/{PeerTube.vue => PeerTubeIntegration.vue} (93%)
 rename js/src/components/Event/Integrations/{Twitch.vue => TwitchIntegration.vue} (100%)
 rename js/src/components/Event/Integrations/{YouTube.vue => YouTubeIntegration.vue} (93%)
 rename js/src/components/Group/Sections/{Discussions.vue => DiscussionsSection.vue} (100%)
 rename js/src/components/Group/Sections/{Events.vue => EventsSection.vue} (100%)
 rename js/src/components/Group/Sections/{Posts.vue => PostsSection.vue} (100%)
 rename js/src/components/Group/Sections/{Resources.vue => ResourcesSection.vue} (100%)
 create mode 100644 js/src/components/Group/SkeletonGroupResult.vue
 rename js/src/components/{Map.vue => LeafletMap.vue} (100%)
 rename js/src/components/{Footer.vue => PageFooter.vue} (100%)
 rename js/src/components/{Editor.vue => TextEditor.vue} (84%)
 rename js/src/components/Utils/{Breadcrumbs.vue => NavBreadcrumbs.vue} (94%)
 delete mode 100644 js/src/components/core/Button.vue
 rename js/src/components/core/{Dialog.vue => CustomDialog.vue} (97%)
 rename js/src/components/core/{Snackbar.vue => CustomSnackbar.vue} (100%)
 delete mode 100644 js/src/components/core/Field.vue
 delete mode 100644 js/src/components/core/Input.vue
 create mode 100644 js/src/components/core/LinkOrRouterLink.vue
 delete mode 100644 js/src/components/core/Message.vue
 delete mode 100644 js/src/components/core/Switch.vue
 rename js/src/views/About/{AboutInstance.vue => AboutInstanceView.vue} (100%)
 rename js/src/views/About/{Glossary.vue => GlossaryView.vue} (100%)
 rename js/src/views/About/{Privacy.vue => PrivacyView.vue} (100%)
 rename js/src/views/About/{Rules.vue => RulesView.vue} (100%)
 rename js/src/views/About/{Terms.vue => TermsView.vue} (100%)
 rename js/src/views/Account/{Register.vue => RegisterView.vue} (100%)
 rename js/src/views/Admin/{Instance.vue => InstanceView.vue} (90%)
 rename js/src/views/Admin/{Instances.vue => InstancesView.vue} (80%)
 rename js/src/views/Admin/{Profiles.vue => ProfilesView.vue} (82%)
 rename js/src/views/Admin/{Settings.vue => SettingsView.vue} (100%)
 rename js/src/views/Admin/{Users.vue => UsersView.vue} (100%)
 rename js/src/views/Discussions/{Create.vue => CreateView.vue} (97%)
 rename js/src/views/Discussions/{Discussion.vue => DiscussionView.vue} (98%)
 rename js/src/views/Discussions/{DiscussionsList.vue => DiscussionsListView.vue} (98%)
 rename js/src/views/Event/{Edit.vue => EditView.vue} (86%)
 rename js/src/views/Event/{Event.vue => EventView.vue} (94%)
 rename js/src/views/Event/{MyEvents.vue => MyEventsView.vue} (98%)
 rename js/src/views/Event/{Participants.vue => ParticipantsView.vue} (84%)
 rename js/src/views/Group/{Create.vue => CreateView.vue} (81%)
 rename js/src/views/Group/{Group.vue => GroupView.vue} (95%)
 rename js/src/views/Group/{Settings.vue => SettingsView.vue} (95%)
 rename js/src/views/Group/{Timeline.vue => TimelineView.vue} (86%)
 rename js/src/views/{Interact.vue => InteractView.vue} (93%)
 rename js/src/views/Moderation/{Logs.vue => LogsView.vue} (100%)
 delete mode 100644 js/src/views/Moderation/Report.vue
 rename js/src/views/Moderation/{ReportList.vue => ReportListView.vue} (83%)
 create mode 100644 js/src/views/Moderation/ReportView.vue
 rename js/src/views/Posts/{Edit.vue => EditView.vue} (92%)
 rename js/src/views/Posts/{List.vue => ListView.vue} (100%)
 rename js/src/views/Posts/{Post.vue => PostView.vue} (95%)
 create mode 100644 js/tests/e2e/login.spec.ts
 create mode 100644 lib/service/global_search/event_result.ex
 create mode 100644 lib/service/global_search/global_search.ex
 create mode 100644 lib/service/global_search/group_result.ex
 create mode 100644 lib/service/global_search/provider.ex
 create mode 100644 lib/service/global_search/search_mobilizon.ex

diff --git a/config/config.exs b/config/config.exs
index 7ef1b213c..3651b9d6a 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -365,6 +365,14 @@ config :mobilizon, Mobilizon.Service.Pictures.Unsplash,
   app_name: "Mobilizon",
   access_key: nil
 
+config :mobilizon, :search, global: [is_default_search: false, is_enabled: true]
+
+config :mobilizon, Mobilizon.Service.GlobalSearch,
+  service: Mobilizon.Service.GlobalSearch.SearchMobilizon
+
+config :mobilizon, Mobilizon.Service.GlobalSearch.SearchMobilizon,
+  endpoint: "https://search.joinmobilizon.org"
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
 import_config "#{config_env()}.exs"
diff --git a/js/.eslintrc.js b/js/.eslintrc.js
index 96907e6d5..c2b3c63fa 100644
--- a/js/.eslintrc.js
+++ b/js/.eslintrc.js
@@ -11,7 +11,7 @@ module.exports = {
   extends: [
     "eslint:recommended",
     "plugin:vue/vue3-essential",
-    "@vue/eslint-config-typescript",
+    "@vue/eslint-config-typescript/recommended",
     "plugin:prettier/recommended",
     "@vue/eslint-config-prettier",
   ],
@@ -24,12 +24,11 @@ module.exports = {
   },
 
   rules: {
-    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
     "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
     "no-underscore-dangle": [
       "error",
       {
-        allow: ["__typename"],
+        allow: ["__typename", "__schema"],
       },
     ],
     "@typescript-eslint/no-explicit-any": "off",
diff --git a/js/.gitignore b/js/.gitignore
index e8b645d0b..5c5176c48 100644
--- a/js/.gitignore
+++ b/js/.gitignore
@@ -24,3 +24,6 @@ yarn-error.log*
 *.njsproj
 *.sln
 *.sw?
+/test-results/
+/playwright-report/
+/playwright/.cache/
diff --git a/js/get_union_json.ts b/js/get_union_json.ts
index 287cc9982..9a36881d1 100644
--- a/js/get_union_json.ts
+++ b/js/get_union_json.ts
@@ -1,5 +1,5 @@
-const fetch = require("node-fetch");
-const fs = require("fs");
+import fetch from "node-fetch";
+import fs from "fs";
 
 fetch(`http://localhost:4000/api`, {
   method: "POST",
diff --git a/js/package.json b/js/package.json
index 60a992e05..94dc34c15 100644
--- a/js/package.json
+++ b/js/package.json
@@ -51,6 +51,7 @@
     "@vue-leaflet/vue-leaflet": "^0.6.1",
     "@vue/apollo-composable": "^4.0.0-alpha.17",
     "@vue/compiler-sfc": "^3.2.37",
+    "@vueuse/core": "^9.1.0",
     "@vueuse/head": "^0.7.9",
     "@vueuse/router": "^9.0.2",
     "@xiaoshuapp/draggable": "^4.1.0",
@@ -93,6 +94,7 @@
   "devDependencies": {
     "@histoire/plugin-vue": "^0.10.0",
     "@intlify/vite-plugin-vue-i18n": "^6.0.0",
+    "@playwright/test": "^1.25.1",
     "@rushstack/eslint-patch": "^1.1.4",
     "@tailwindcss/forms": "^0.5.2",
     "@tailwindcss/typography": "^0.5.4",
diff --git a/js/playwright.config.ts b/js/playwright.config.ts
new file mode 100644
index 000000000..c3da6c26e
--- /dev/null
+++ b/js/playwright.config.ts
@@ -0,0 +1,107 @@
+import type { PlaywrightTestConfig } from "@playwright/test";
+import { devices } from "@playwright/test";
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+  testDir: "./tests/e2e",
+  /* Maximum time one test can run for. */
+  timeout: 30 * 1000,
+  expect: {
+    /**
+     * Maximum time expect() should wait for the condition to be met.
+     * For example in `await expect(locator).toHaveText();`
+     */
+    timeout: 5000,
+  },
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Retry on CI only */
+  retries: process.env.CI ? 2 : 0,
+  /* Opt out of parallel tests on CI. */
+  workers: process.env.CI ? 1 : undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: "html",
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+    actionTimeout: 0,
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: "http://localhost:4005",
+
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: "on-first-retry",
+  },
+
+  /* Configure projects for major browsers */
+  projects: [
+    {
+      name: "chromium",
+      use: {
+        ...devices["Desktop Chrome"],
+      },
+    },
+
+    {
+      name: "firefox",
+      use: {
+        ...devices["Desktop Firefox"],
+      },
+    },
+
+    // {
+    //   name: 'webkit',
+    //   use: {
+    //     ...devices['Desktop Safari'],
+    //   },
+    // },
+
+    /* Test against mobile viewports. */
+    // {
+    //   name: 'Mobile Chrome',
+    //   use: {
+    //     ...devices['Pixel 5'],
+    //   },
+    // },
+    // {
+    //   name: 'Mobile Safari',
+    //   use: {
+    //     ...devices['iPhone 12'],
+    //   },
+    // },
+
+    /* Test against branded browsers. */
+    // {
+    //   name: 'Microsoft Edge',
+    //   use: {
+    //     channel: 'msedge',
+    //   },
+    // },
+    // {
+    //   name: 'Google Chrome',
+    //   use: {
+    //     channel: 'chrome',
+    //   },
+    // },
+  ],
+
+  /* Folder for test artifacts such as screenshots, videos, traces, etc. */
+  // outputDir: 'test-results/',
+
+  /* Run your local dev server before starting the tests */
+  // webServer: {
+  //   command: 'npm run start',
+  //   port: 3000,
+  // },
+};
+
+export default config;
diff --git a/js/src/assets/logo.svg b/js/public/img/logo.svg
similarity index 100%
rename from js/src/assets/logo.svg
rename to js/public/img/logo.svg
diff --git a/js/src/App.vue b/js/src/App.vue
index f6d844c87..175d22d41 100644
--- a/js/src/App.vue
+++ b/js/src/App.vue
@@ -32,17 +32,17 @@
 </template>
 
 <script lang="ts" setup>
-import NavBar from "./components/NavBar.vue";
+import NavBar from "@/components/NavBar.vue";
 import {
   AUTH_ACCESS_TOKEN,
   AUTH_USER_EMAIL,
   AUTH_USER_ID,
   AUTH_USER_ROLE,
-} from "./constants";
-import { UPDATE_CURRENT_USER_CLIENT } from "./graphql/user";
-import MobilizonFooter from "./components/Footer.vue";
+} from "@/constants";
+import { UPDATE_CURRENT_USER_CLIENT } from "@/graphql/user";
+import MobilizonFooter from "@/components/PageFooter.vue";
 import jwt_decode, { JwtPayload } from "jwt-decode";
-import { refreshAccessToken } from "./apollo/utils";
+import { refreshAccessToken } from "@/apollo/utils";
 import {
   reactive,
   ref,
@@ -52,25 +52,30 @@ import {
   onBeforeMount,
   inject,
   defineAsyncComponent,
+  computed,
+  watch,
 } from "vue";
-import { LocationType } from "./types/user-location.model";
-import { useMutation } from "@vue/apollo-composable";
-import { initializeCurrentActor } from "./utils/identity";
+import { LocationType } from "@/types/user-location.model";
+import { useMutation, useQuery } from "@vue/apollo-composable";
+import { initializeCurrentActor } from "@/utils/identity";
 import { useI18n } from "vue-i18n";
-import { Snackbar } from "./plugins/snackbar";
-import { Notifier } from "./plugins/notifier";
-import {
-  useIsDemoMode,
-  useServerProvidedLocation,
-} from "./composition/apollo/config";
+import { Snackbar } from "@/plugins/snackbar";
+import { Notifier } from "@/plugins/notifier";
+import { CONFIG } from "@/graphql/config";
+import { IConfig } from "@/types/config.model";
+import { useRouter } from "vue-router";
+
+const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
+
+const config = computed(() => configResult.value?.config);
 
 const ErrorComponent = defineAsyncComponent(
-  () => import("./components/ErrorComponent.vue")
+  () => import("@/components/ErrorComponent.vue")
 );
 
 const { t } = useI18n({ useScope: "global" });
 
-const { location } = useServerProvidedLocation();
+const location = computed(() => config.value?.location);
 
 const userLocation = reactive<LocationType>({
   lon: undefined,
@@ -251,16 +256,19 @@ const showOfflineNetworkWarning = (): void => {
 //   }, 0);
 // });
 
-// watch(config, async (configWatched: IConfig) => {
-//   if (configWatched) {
-//     const { statistics } = (await import("./services/statistics")) as {
-//       statistics: (config: IConfig, environment: Record<string, any>) => void;
-//     };
-//     statistics(configWatched, { router, version: configWatched.version });
-//   }
-// });
+const router = useRouter();
 
-const { isDemoMode } = useIsDemoMode();
+watch(config, async (configWatched: IConfig | undefined) => {
+  if (configWatched) {
+    const { statistics } = await import("@/services/statistics");
+    statistics(configWatched?.analytics, {
+      router,
+      version: configWatched.version,
+    });
+  }
+});
+
+const isDemoMode = computed(() => config.value?.demoMode);
 </script>
 
 <style lang="scss">
diff --git a/js/src/apollo/error-link.ts b/js/src/apollo/error-link.ts
index 49acce00d..faaf7f00a 100644
--- a/js/src/apollo/error-link.ts
+++ b/js/src/apollo/error-link.ts
@@ -83,7 +83,7 @@ const errorLink = onError(
       graphQLErrors.map(
         (graphQLError: GraphQLError & { status_code?: number }) => {
           if (graphQLError?.status_code !== 401) {
-            console.log(
+            console.debug(
               `[GraphQL error]: Message: ${graphQLError.message}, Location: ${graphQLError.locations}, Path: ${graphQLError.path}`
             );
           }
diff --git a/js/src/apollo/link.ts b/js/src/apollo/link.ts
index 3c3a4cb4a..5e63dd2d4 100644
--- a/js/src/apollo/link.ts
+++ b/js/src/apollo/link.ts
@@ -6,22 +6,35 @@ import { authMiddleware } from "./auth";
 import errorLink from "./error-link";
 import { uploadLink } from "./absinthe-upload-socket-link";
 
-// const link = split(
-//   // split based on operation type
-//   ({ query }) => {
-//     const definition = getMainDefinition(query);
-//     return (
-//       definition.kind === "OperationDefinition" &&
-//       definition.operation === "subscription"
-//     );
-//   },
-//   absintheSocketLink,
-//   uploadLink
-// );
+let link;
+
+// The Absinthe socket Apollo link relies on an old library
+// (@jumpn/utils-composite) which itself relies on an old
+// Babel version, which is incompatible with Histoire.
+// We just don't use the absinthe apollo socket link
+// in this case.
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+if (!import.meta.env.VITE_HISTOIRE_ENV) {
+  // const absintheSocketLink = await import("./absinthe-socket-link");
+
+  link = split(
+    // split based on operation type
+    ({ query }) => {
+      const definition = getMainDefinition(query);
+      return (
+        definition.kind === "OperationDefinition" &&
+        definition.operation === "subscription"
+      );
+    },
+    absintheSocketLink,
+    uploadLink
+  );
+}
 
 const retryLink = new RetryLink();
 
 export const fullLink = authMiddleware
   .concat(retryLink)
   .concat(errorLink)
-  .concat(uploadLink);
+  .concat(link ?? uploadLink);
diff --git a/js/src/apollo/user.ts b/js/src/apollo/user.ts
index a67cafc73..e7195918a 100644
--- a/js/src/apollo/user.ts
+++ b/js/src/apollo/user.ts
@@ -8,7 +8,7 @@ import { Resolvers } from "@apollo/client/core/types";
 export default function buildCurrentUserResolver(
   cache: ApolloCache<NormalizedCacheObject>
 ): Resolvers {
-  cache.writeQuery({
+  cache?.writeQuery({
     query: CURRENT_USER_CLIENT,
     data: {
       currentUser: {
@@ -21,7 +21,7 @@ export default function buildCurrentUserResolver(
     },
   });
 
-  cache.writeQuery({
+  cache?.writeQuery({
     query: CURRENT_ACTOR_CLIENT,
     data: {
       currentActor: {
@@ -34,7 +34,7 @@ export default function buildCurrentUserResolver(
     },
   });
 
-  cache.writeQuery({
+  cache?.writeQuery({
     query: CURRENT_USER_LOCATION_CLIENT,
     data: {
       currentUserLocation: {
@@ -70,8 +70,6 @@ export default function buildCurrentUserResolver(
           },
         };
 
-        console.debug("updating current user", data);
-
         localCache.writeQuery({ data, query: CURRENT_USER_CLIENT });
       },
       updateCurrentActor: (
diff --git a/js/src/apollo/utils.ts b/js/src/apollo/utils.ts
index c71339c2e..4c994d917 100644
--- a/js/src/apollo/utils.ts
+++ b/js/src/apollo/utils.ts
@@ -73,6 +73,9 @@ export const typePolicies: TypePolicies = {
   Config: {
     merge: true,
   },
+  Address: {
+    keyFields: ["id"],
+  },
   RootQueryType: {
     fields: {
       relayFollowers: paginatedLimitPagination<IFollower>(),
@@ -110,7 +113,7 @@ export async function refreshAccessToken(): Promise<boolean> {
     return false;
   }
 
-  console.log("Refreshing access token.");
+  console.debug("Refreshing access token.");
 
   return new Promise((resolve, reject) => {
     const { mutate, onDone, onError } = provideApolloClient(apolloClient)(() =>
@@ -130,7 +133,7 @@ export async function refreshAccessToken(): Promise<boolean> {
     });
 
     onError((err) => {
-      console.debug("Failed to refresh token");
+      console.debug("Failed to refresh token", err);
       reject(false);
     });
   });
diff --git a/js/src/assets/oruga-tailwindcss.css b/js/src/assets/oruga-tailwindcss.css
index ae2bf55e9..fa42f8c5d 100644
--- a/js/src/assets/oruga-tailwindcss.css
+++ b/js/src/assets/oruga-tailwindcss.css
@@ -1,11 +1,10 @@
 body {
-  @apply bg-body-background-color dark:bg-gray-700 dark:text-white;
+  @apply bg-body-background-color dark:bg-zinc-800 dark:text-white;
 }
 
 /* Button */
 .btn {
-  outline: none !important;
-  @apply font-bold py-2 px-4 bg-mbz-bluegreen dark:bg-violet-3 text-white rounded h-10;
+  @apply font-bold py-2 px-4 bg-mbz-bluegreen hover:bg-mbz-bluegreen-600 text-white rounded h-10 outline-none focus:ring ring-offset-1 ring-offset-slate-50 ring-blue-300;
 }
 .btn:hover {
   @apply text-slate-200;
@@ -28,11 +27,14 @@ body {
   @apply opacity-50 cursor-not-allowed;
 }
 .btn-danger {
-  @apply bg-mbz-danger;
+  @apply bg-mbz-danger hover:bg-mbz-danger/90;
 }
 .btn-success {
   @apply bg-mbz-success;
 }
+.btn-text {
+  @apply bg-transparent border-transparent text-black dark:text-white font-normal underline hover:bg-zinc-200 hover:text-black;
+}
 
 /* Field */
 .field {
@@ -62,7 +64,7 @@ body {
 
 /* Input */
 .input {
-  @apply appearance-none border w-full py-2 px-3 text-black leading-tight;
+  @apply appearance-none border w-full py-2 px-3 text-black leading-tight dark:bg-zinc-600 dark:placeholder:text-zinc-400 dark:text-zinc-50;
 }
 .input-danger {
   @apply border-red-500;
@@ -70,6 +72,10 @@ body {
 .input-icon-right {
   right: 0.5rem;
 }
+.input[type="text"]:disabled,
+.input[type="email"]:disabled {
+  @apply bg-zinc-200 dark:bg-zinc-400;
+}
 
 .icon-warning {
   @apply text-amber-600;
@@ -78,6 +84,12 @@ body {
 .icon-danger {
   @apply text-red-500;
 }
+.icon-success {
+  @apply text-mbz-success;
+}
+.icon-grey {
+  @apply text-gray-500;
+}
 
 .o-input__icon-left {
   @apply dark:text-black h-10 w-10;
@@ -111,25 +123,27 @@ body {
 }
 .dropdown-menu {
   min-width: 12em;
-  @apply bg-white dark:bg-gray-700 shadow-lg rounded text-start py-2;
+  @apply bg-white dark:bg-zinc-700 shadow-lg rounded text-start py-2;
 }
 .dropdown-item {
   @apply relative inline-flex gap-1 no-underline p-2 cursor-pointer w-full;
 }
 
 .dropdown-item-active {
-  /* @apply bg-violet-2; */
-  @apply bg-white;
+  @apply bg-white text-black;
+}
+.dropdown-button {
+  @apply inline-flex gap-1;
 }
 
 /* Checkbox */
 
 .checkbox {
-  @apply appearance-none bg-blue-500 border-blue-500;
+  @apply appearance-none bg-primary border-primary;
 }
 
 .checkbox-checked {
-  @apply bg-blue-500;
+  @apply bg-primary text-primary;
 }
 
 .checkbox-label {
@@ -139,7 +153,7 @@ body {
 /* Modal */
 
 .modal-content {
-  @apply bg-white dark:bg-gray-700 rounded px-2 py-4 w-full;
+  @apply bg-white dark:bg-zinc-800 rounded px-2 py-4 w-full;
 }
 
 /* Switch */
@@ -151,14 +165,18 @@ body {
   @apply pl-2;
 }
 
+.switch-check-checked {
+  @apply bg-primary;
+}
+
 /* Select */
 .select {
-  @apply dark:bg-white dark:text-black rounded pl-2 pr-6 border-2 border-transparent h-10 shadow-none;
+  @apply dark:bg-zinc-600 dark:placeholder:text-zinc-400 dark:text-zinc-50 rounded pl-2 pr-6 border-2 border-transparent h-10 shadow-none;
 }
 
 /* Radio */
 .form-radio {
-  @apply bg-none;
+  @apply bg-none text-primary accent-primary;
 }
 .radio-label {
   @apply pl-2;
@@ -171,7 +189,7 @@ button.menubar__button {
 
 /* Notification */
 .notification {
-  @apply p-7 bg-secondary text-black rounded;
+  @apply p-7 bg-mbz-yellow-alt-200 dark:bg-mbz-purple-600 text-black dark:text-white rounded;
 }
 
 .notification-primary {
@@ -187,18 +205,26 @@ button.menubar__button {
 }
 
 .notification-danger {
-  @apply bg-mbz-danger;
+  @apply bg-mbz-danger text-white;
 }
 
 /* Table */
 .table tr {
-  @apply odd:bg-white dark:odd:bg-gray-800 even:bg-gray-50 dark:even:bg-gray-900 border-b;
+  @apply odd:bg-white dark:odd:bg-zinc-600 even:bg-gray-50 dark:even:bg-zinc-700 border-b rounded;
 }
 
 .table-td {
   @apply py-4 px-2 whitespace-nowrap;
 }
 
+.table-th {
+  @apply p-2;
+}
+
+.table-root {
+  @apply mt-4;
+}
+
 /* Snackbar */
 .notification-dark {
   @apply text-white;
@@ -210,14 +236,14 @@ button.menubar__button {
   @apply flex items-center text-center justify-between;
 }
 .pagination-link {
-  @apply inline-flex items-center relative justify-center cursor-pointer rounded h-10 m-1 p-2 bg-white text-lg;
+  @apply inline-flex items-center relative justify-center cursor-pointer rounded h-10 m-1 p-2 bg-white dark:bg-zinc-300 text-lg text-black;
 }
 .pagination-list {
   @apply flex items-center text-center list-none flex-wrap grow shrink justify-start;
 }
 .pagination-next,
 .pagination-previous {
-  @apply px-3;
+  @apply px-3 dark:text-black;
 }
 .pagination-link-current {
   @apply bg-primary cursor-not-allowed pointer-events-none border-primary text-white;
@@ -236,3 +262,19 @@ button.menubar__button {
 .tabs-nav-item-active-boxed {
   @apply bg-white border-gray-300 text-primary;
 }
+
+/** Tooltip */
+.tooltip-content {
+  @apply bg-zinc-800 text-white dark:bg-zinc-300 dark:text-black rounded py-1 px-2;
+}
+.tooltip-arrow {
+  @apply text-zinc-800 dark:text-zinc-200;
+}
+.tooltip-content-success {
+  @apply bg-mbz-success text-white;
+}
+
+/** Tiptap editor */
+.menubar__button {
+  @apply hover:bg-[rgba(0,0,0,.05)];
+}
diff --git a/js/src/assets/tailwind.css b/js/src/assets/tailwind.css
index 580b93b4e..9d1ebab06 100644
--- a/js/src/assets/tailwind.css
+++ b/js/src/assets/tailwind.css
@@ -22,13 +22,9 @@
   }
 }
 
-a:hover {
-  color: inherit;
-}
-
 @layer components {
   .mbz-card {
-    @apply block bg-mbz-yellow hover:bg-mbz-yellow/90 text-violet-title dark:text-white dark:hover:text-white/90 rounded-lg dark:border-violet-title shadow-md dark:bg-gray-700 dark:hover:bg-gray-700/90 dark:text-white dark:hover:text-white;
+    @apply block bg-mbz-yellow-alt-300 hover:bg-mbz-yellow-alt-200 text-violet-title dark:text-white dark:hover:text-white rounded-lg dark:border-violet-title shadow-md dark:bg-mbz-purple dark:hover:dark:bg-mbz-purple-400 dark:text-white dark:hover:text-white;
   }
 }
 
diff --git a/js/src/components/Account/ActorCard.vue b/js/src/components/Account/ActorCard.vue
index 68dd1531b..4ae3e256f 100644
--- a/js/src/components/Account/ActorCard.vue
+++ b/js/src/components/Account/ActorCard.vue
@@ -25,7 +25,7 @@
       >
         {{ displayName(actor) }}
       </h5>
-      <p class="text-gray-500 truncate" v-if="actor.name">
+      <p class="text-gray-500 dark:text-gray-200 truncate" v-if="actor.name">
         <span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
       </p>
       <div
diff --git a/js/src/components/Activity/DiscussionActivityItem.vue b/js/src/components/Activity/DiscussionActivityItem.vue
index 82cc1390c..bc19ca904 100644
--- a/js/src/components/Activity/DiscussionActivityItem.vue
+++ b/js/src/components/Activity/DiscussionActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="'chat'" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="'chat'" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #discussion>
           <router-link
@@ -102,12 +102,12 @@ const iconColor = computed((): string | undefined => {
   switch (props.activity.subject) {
     case ActivityDiscussionSubject.DISCUSSION_CREATED:
     case ActivityDiscussionSubject.DISCUSSION_REPLIED:
-      return "is-success";
+      return "success";
     case ActivityDiscussionSubject.DISCUSSION_RENAMED:
     case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
-      return "is-grey";
+      return "grey";
     case ActivityDiscussionSubject.DISCUSSION_DELETED:
-      return "is-danger";
+      return "danger";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/EventActivityItem.vue b/js/src/components/Activity/EventActivityItem.vue
index 635d7e96d..184884f27 100644
--- a/js/src/components/Activity/EventActivityItem.vue
+++ b/js/src/components/Activity/EventActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="'calendar'" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="'calendar'" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #event>
           <router-link
@@ -93,11 +93,11 @@ const iconColor = computed((): string | undefined => {
   switch (props.activity.subject) {
     case ActivityEventSubject.EVENT_CREATED:
     case ActivityEventCommentSubject.COMMENT_POSTED:
-      return "is-success";
+      return "success";
     case ActivityEventSubject.EVENT_UPDATED:
-      return "is-grey";
+      return "grey";
     case ActivityEventSubject.EVENT_DELETED:
-      return "is-danger";
+      return "danger";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/GroupActivityItem.vue b/js/src/components/Activity/GroupActivityItem.vue
index 932f1faca..d76458a90 100644
--- a/js/src/components/Activity/GroupActivityItem.vue
+++ b/js/src/components/Activity/GroupActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="'cog'" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="'cog'" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #group>
           <router-link
@@ -28,13 +28,7 @@
           ></template
         ></i18n-t
       >
-      <i18n-t
-        :keypath="detail"
-        v-for="detail in details"
-        :key="detail"
-        tag="p"
-        class="has-text-grey-dark"
-      >
+      <i18n-t :keypath="detail" v-for="detail in details" :key="detail" tag="p">
         <template #profile>
           <popover-actor-card :actor="activity.author" :inline="true">
             <b>
@@ -63,9 +57,7 @@
           }}</b>
         </template>
       </i18n-t>
-      <small class="has-text-grey-dark activity-date">{{
-        formatTimeString(activity.insertedAt)
-      }}</small>
+      <small>{{ formatTimeString(activity.insertedAt) }}</small>
     </div>
   </div>
 </template>
@@ -110,9 +102,9 @@ const translation = computed((): string | undefined => {
 const iconColor = computed((): string | undefined => {
   switch (props.activity.subject) {
     case ActivityGroupSubject.GROUP_CREATED:
-      return "is-success";
+      return "success";
     case ActivityGroupSubject.GROUP_UPDATED:
-      return "is-grey";
+      return "grey";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/MemberActivityItem.vue b/js/src/components/Activity/MemberActivityItem.vue
index 2235143ce..275261cf5 100644
--- a/js/src/components/Activity/MemberActivityItem.vue
+++ b/js/src/components/Activity/MemberActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="icon" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="icon" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #member>
           <popover-actor-card
@@ -144,14 +144,14 @@ const iconColor = computed((): string | undefined => {
     case ActivityMemberSubject.MEMBER_JOINED:
     case ActivityMemberSubject.MEMBER_APPROVED:
     case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
-      return "is-success";
+      return "success";
     case ActivityMemberSubject.MEMBER_REQUEST:
     case ActivityMemberSubject.MEMBER_UPDATED:
-      return "is-grey";
+      return "grey";
     case ActivityMemberSubject.MEMBER_REMOVED:
     case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
     case ActivityMemberSubject.MEMBER_QUIT:
-      return "is-danger";
+      return "danger";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/PostActivityItem.vue b/js/src/components/Activity/PostActivityItem.vue
index c22e7b109..06f2cb1a4 100644
--- a/js/src/components/Activity/PostActivityItem.vue
+++ b/js/src/components/Activity/PostActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="'bullhorn'" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="'bullhorn'" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #post>
           <router-link
@@ -78,11 +78,11 @@ const translation = computed((): string | undefined => {
 const iconColor = computed((): string | undefined => {
   switch (props.activity.subject) {
     case ActivityPostSubject.POST_CREATED:
-      return "is-success";
+      return "success";
     case ActivityPostSubject.POST_UPDATED:
-      return "is-grey";
+      return "grey";
     case ActivityPostSubject.POST_DELETED:
-      return "is-danger";
+      return "danger";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/ResourceActivityItem.vue b/js/src/components/Activity/ResourceActivityItem.vue
index a9636e379..65b8e7d50 100644
--- a/js/src/components/Activity/ResourceActivityItem.vue
+++ b/js/src/components/Activity/ResourceActivityItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="activity-item">
-    <o-icon :icon="'link'" :type="iconColor" />
-    <div class="subject">
+    <o-icon :icon="'link'" :variant="iconColor" custom-size="24" />
+    <div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
       <i18n-t :keypath="translation" tag="p">
         <template #resource>
           <router-link v-if="activity.object" :to="path">{{
@@ -142,12 +142,12 @@ const translation = computed((): string | undefined => {
 const iconColor = computed((): string | undefined => {
   switch (props.activity.subject) {
     case ActivityResourceSubject.RESOURCE_CREATED:
-      return "is-success";
+      return "success";
     case ActivityResourceSubject.RESOURCE_MOVED:
     case ActivityResourceSubject.RESOURCE_UPDATED:
-      return "is-grey";
+      return "grey";
     case ActivityResourceSubject.RESOURCE_DELETED:
-      return "is-danger";
+      return "danger";
     default:
       return undefined;
   }
diff --git a/js/src/components/Activity/activity.scss b/js/src/components/Activity/activity.scss
index c12b82a2b..860de64ef 100644
--- a/js/src/components/Activity/activity.scss
+++ b/js/src/components/Activity/activity.scss
@@ -1,6 +1,6 @@
 .activity-item {
   display: flex;
-  span.icon {
+  span.o-icon {
     width: 2em;
     height: 2em;
     box-sizing: border-box;
@@ -10,8 +10,4 @@
     flex-shrink: 0;
 
   }
-
-  .subject {
-    padding: 0.25rem 0 0 0.5rem;
-  }
 }
diff --git a/js/src/components/Address/AddressInfo.vue b/js/src/components/Address/AddressInfo.vue
index 2aa03dea8..7d6456d3e 100644
--- a/js/src/components/Address/AddressInfo.vue
+++ b/js/src/components/Address/AddressInfo.vue
@@ -3,7 +3,7 @@
     <o-icon
       v-if="showIcon"
       :icon="poiInfos?.poiIcon.icon"
-      size="is-medium"
+      size="medium"
       class="icon"
     />
     <p>
diff --git a/js/src/components/Categories/constants.ts b/js/src/components/Categories/constants.ts
index c8411b71f..b176d62ab 100644
--- a/js/src/components/Categories/constants.ts
+++ b/js/src/components/Categories/constants.ts
@@ -1,104 +1,3 @@
-export const eventCategories = (t) => {
-  return [
-    {
-      id: "ARTS",
-      icon: "palette",
-    },
-    {
-      id: "BOOK_CLUBS",
-      icon: "favourite-book",
-    },
-    {
-      id: "BUSINESS",
-    },
-    {
-      id: "CAUSES",
-    },
-    {
-      id: "COMEDY",
-    },
-    {
-      id: "CRAFTS",
-    },
-    {
-      id: "FOOD_DRINK",
-    },
-    {
-      id: "HEALTH",
-    },
-    {
-      id: "MUSIC",
-    },
-    {
-      id: "AUTO_BOAT_AIR",
-    },
-    {
-      id: "COMMUNITY",
-    },
-    {
-      id: "FAMILY_EDUCATION",
-    },
-    {
-      id: "FASHION_BEAUTY",
-    },
-    {
-      id: "FILM_MEDIA",
-    },
-    {
-      id: "GAMES",
-    },
-    {
-      id: "LANGUAGE_CULTURE",
-    },
-    {
-      id: "LEARNING",
-    },
-    {
-      id: "LGBTQ",
-    },
-    {
-      id: "MOVEMENTS_POLITICS",
-    },
-    {
-      id: "NETWORKING",
-    },
-    {
-      id: "PARTY",
-    },
-    {
-      id: "PERFORMING_VISUAL_ARTS",
-    },
-    {
-      id: "PETS",
-    },
-    {
-      id: "PHOTOGRAPHY",
-    },
-    {
-      id: "OUTDOORS_ADVENTURE",
-    },
-    {
-      id: "SPIRITUALITY_RELIGION_BELIEFS",
-    },
-    {
-      id: "SCIENCE_TECH",
-    },
-    {
-      id: "SPORTS",
-    },
-    {
-      id: "THEATRE",
-    },
-    {
-      id: "MEETING",
-    },
-  ];
-};
-
-export const eventCategoryLabel = (category: string, t): string | undefined => {
-  return eventCategories(t).find(({ id }) => id === category)?.label;
-};
-
 export type CategoryPictureLicencingElement = { name: string; url: string };
 export type CategoryPictureLicencing = {
   author: CategoryPictureLicencingElement;
diff --git a/js/src/components/Comment/CommentTree.vue b/js/src/components/Comment/CommentTree.vue
index 1d1cf0b11..38ba89e24 100644
--- a/js/src/components/Comment/CommentTree.vue
+++ b/js/src/components/Comment/CommentTree.vue
@@ -85,7 +85,7 @@
 </template>
 
 <script lang="ts" setup>
-import Comment from "@/components/Comment/Comment.vue";
+import Comment from "@/components/Comment/EventComment.vue";
 import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
 import { CommentModeration } from "@/types/enums";
 import { CommentModel, IComment } from "../../types/comment.model";
@@ -122,7 +122,9 @@ const props = defineProps<{
   newComment?: IComment;
 }>();
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const newComment = ref<IComment>(props.newComment ?? new CommentModel());
 
@@ -284,7 +286,7 @@ const { mutate: deleteComment, onError: deleteCommentMutationError } =
           replies: updatedReplies,
           totalReplies: parentComment.totalReplies - 1,
         });
-        console.log("updatedComments", updatedComments);
+        console.debug("updatedComments", updatedComments);
       } else {
         // we have deleted a thread itself
         updatedComments = updatedComments.map((reply) => {
diff --git a/js/src/components/Comment/Comment.story.vue b/js/src/components/Comment/EventComment.story.vue
similarity index 92%
rename from js/src/components/Comment/Comment.story.vue
rename to js/src/components/Comment/EventComment.story.vue
index d17356358..b8a125449 100644
--- a/js/src/components/Comment/Comment.story.vue
+++ b/js/src/components/Comment/EventComment.story.vue
@@ -23,7 +23,7 @@
   </Story>
 </template>
 <script lang="ts" setup>
-import { IActor } from "@/types/actor";
+import { IPerson } from "@/types/actor";
 import { IComment } from "@/types/comment.model";
 import {
   ActorType,
@@ -34,7 +34,7 @@ import {
 } from "@/types/enums";
 import { IEvent } from "@/types/event.model";
 import { reactive } from "vue";
-import Comment from "./Comment.vue";
+import Comment from "./EventComment.vue";
 import FloatingVue from "floating-vue";
 import "floating-vue/dist/style.css";
 import { hstEvent } from "histoire/client";
@@ -51,7 +51,7 @@ const baseActorAvatar = {
   url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
 };
 
-const baseActor: IActor = {
+const baseActor: IPerson = {
   name: "Thomas Citharel",
   preferredUsername: "tcit",
   avatar: baseActorAvatar,
@@ -67,8 +67,8 @@ const baseEvent: IEvent = {
   uuid: "",
   title: "A very interesting event",
   description: "Things happen",
-  beginsOn: new Date(),
-  endsOn: new Date(),
+  beginsOn: new Date().toISOString(),
+  endsOn: new Date().toISOString(),
   physicalAddress: {
     description: "Somewhere",
     street: "",
@@ -88,7 +88,7 @@ const baseEvent: IEvent = {
   url: "",
   local: true,
   slug: "",
-  publishAt: new Date(),
+  publishAt: new Date().toISOString(),
   status: EventStatus.CONFIRMED,
   visibility: EventVisibility.PUBLIC,
   joinOptions: EventJoinOptions.FREE,
@@ -151,7 +151,7 @@ const comment = reactive<IComment>({
       text: "a reply!",
       id: "90",
       actor: baseActor,
-      updatedAt: new Date(),
+      updatedAt: new Date().toISOString(),
       url: "http://somewhere.tld",
       replies: [],
       totalReplies: 0,
@@ -162,7 +162,7 @@ const comment = reactive<IComment>({
       text: "a reply to another reply!",
       id: "92",
       actor: baseActor,
-      updatedAt: new Date(),
+      updatedAt: new Date().toISOString(),
       url: "http://somewhere.tld",
       replies: [],
       totalReplies: 0,
@@ -171,7 +171,7 @@ const comment = reactive<IComment>({
     },
   ],
   isAnnouncement: false,
-  updatedAt: new Date(),
+  updatedAt: new Date().toISOString(),
   url: "http://somewhere.tld",
 });
 </script>
diff --git a/js/src/components/Comment/Comment.vue b/js/src/components/Comment/EventComment.vue
similarity index 98%
rename from js/src/components/Comment/Comment.vue
rename to js/src/components/Comment/EventComment.vue
index 668c00661..09ddd0e73 100644
--- a/js/src/components/Comment/Comment.vue
+++ b/js/src/components/Comment/EventComment.vue
@@ -175,7 +175,7 @@
   </li>
 </template>
 <script lang="ts" setup>
-import EditorComponent from "@/components/Editor.vue";
+import EditorComponent from "@/components/TextEditor.vue";
 import { formatDistanceToNow } from "date-fns";
 import { CommentModeration } from "@/types/enums";
 import { CommentModel, IComment } from "../../types/comment.model";
@@ -200,7 +200,9 @@ import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
 import Reply from "vue-material-design-icons/Reply.vue";
 import type { Locale } from "date-fns";
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const props = withDefaults(
   defineProps<{
@@ -257,7 +259,7 @@ const replyToComment = (): void => {
   newComment.value.inReplyToComment = props.comment;
   newComment.value.originComment = props.comment.originComment ?? props.comment;
   newComment.value.actor = props.currentActor;
-  console.log(newComment.value);
+  console.debug(newComment.value);
   emit("create-comment", newComment.value);
   newComment.value = new CommentModel();
   replyTo.value = false;
diff --git a/js/src/components/Discussion/DiscussionComment.vue b/js/src/components/Discussion/DiscussionComment.vue
index 7f6df36d5..d53a754a1 100644
--- a/js/src/components/Discussion/DiscussionComment.vue
+++ b/js/src/components/Discussion/DiscussionComment.vue
@@ -1,5 +1,5 @@
 <template>
-  <article class="flex gap-2">
+  <article class="flex gap-2 bg-white dark:bg-transparent">
     <div class="">
       <figure class="" v-if="comment.actor && comment.actor.avatar">
         <img
@@ -32,7 +32,7 @@
             comment.actor.id === currentActor?.id
           "
         >
-          <o-dropdown aria-role="list">
+          <o-dropdown aria-role="list" position="bottom-left">
             <template #trigger>
               <o-icon role="button" icon="dots-horizontal" />
             </template>
@@ -133,7 +133,9 @@ import { formatDateTimeString } from "@/filters/datetime";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import type { Locale } from "date-fns";
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const props = defineProps<{
   modelValue: IComment;
diff --git a/js/src/components/ErrorComponent.vue b/js/src/components/ErrorComponent.vue
index 29b861108..7f755fd49 100644
--- a/js/src/components/ErrorComponent.vue
+++ b/js/src/components/ErrorComponent.vue
@@ -68,7 +68,7 @@
         </p>
 
         <details>
-          <summary class="is-size-5">{{ t("Technical details") }}</summary>
+          <summary>{{ t("Technical details") }}</summary>
           <p>{{ t("Error message") }}</p>
           <pre>{{ error }}</pre>
           <p>{{ t("Error stacktrace") }}</p>
diff --git a/js/src/components/Event/EventCard.story.vue b/js/src/components/Event/EventCard.story.vue
index 523f5b428..82020d4c8 100644
--- a/js/src/components/Event/EventCard.story.vue
+++ b/js/src/components/Event/EventCard.story.vue
@@ -14,6 +14,9 @@
     <Variant title="cancelled">
       <EventCard :event="cancelledEvent" />
     </Variant>
+    <Variant title="Row mode">
+      <EventCard :event="longEvent" mode="row" />
+    </Variant>
   </Story>
 </template>
 
@@ -53,8 +56,8 @@ const baseEvent: IEvent = {
   uuid: "",
   title: "A very interesting event",
   description: "Things happen",
-  beginsOn: new Date(),
-  endsOn: new Date(),
+  beginsOn: new Date().toISOString(),
+  endsOn: new Date().toISOString(),
   physicalAddress: {
     description: "Somewhere",
     street: "",
@@ -74,7 +77,7 @@ const baseEvent: IEvent = {
   url: "",
   local: true,
   slug: "",
-  publishAt: new Date(),
+  publishAt: new Date().toISOString(),
   status: EventStatus.CONFIRMED,
   visibility: EventVisibility.PUBLIC,
   joinOptions: EventJoinOptions.FREE,
@@ -130,7 +133,7 @@ const event = reactive<IEvent>(baseEvent);
 const longEvent = reactive<IEvent>({
   ...baseEvent,
   title:
-    "A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so.",
+    "A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so. But if it doesn't work, we really need to truncate it at some point. Definitively.",
 });
 
 const tentativeEvent = reactive<IEvent>({
diff --git a/js/src/components/Event/EventCard.vue b/js/src/components/Event/EventCard.vue
index 43c0ac830..1d595f24a 100644
--- a/js/src/components/Event/EventCard.vue
+++ b/js/src/components/Event/EventCard.vue
@@ -1,16 +1,25 @@
 <template>
-  <router-link
-    class="mbz-card max-w-xs shrink-0 w-[18rem] snap-center dark:bg-mbz-purple"
-    :to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
+  <LinkOrRouterLink
+    class="mbz-card snap-center dark:bg-mbz-purple"
+    :class="{
+      'sm:flex sm:items-start': mode === 'row',
+      'max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
+    }"
+    :to="to"
+    :isInternal="isInternal"
   >
-    <div class="bg-secondary rounded-lg">
+    <div
+      class="bg-secondary rounded-lg"
+      :class="{ 'sm:w-full sm:max-w-[20rem]': mode === 'row' }"
+    >
       <figure class="block relative pt-40">
         <lazy-image-wrapper
           :picture="event.picture"
           style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
         />
         <div
-          class="absolute top-3 right-0 ltr:-mr-1 rtl:-ml-1 z-10 max-w-xs no-underline flex flex-col gap-1"
+          class="absolute top-3 right-0 ltr:-mr-1 rtl:-ml-1 z-10 max-w-xs no-underline flex flex-col gap-1 items-end"
+          v-show="mode === 'column'"
           v-if="event.tags || event.status !== EventStatus.CONFIRMED"
         >
           <mobilizon-tag
@@ -30,30 +39,39 @@
             v-for="tag in (event.tags || []).slice(0, 3)"
             :key="tag.slug"
           >
-            <mobilizon-tag dir="auto">{{ tag.title }}</mobilizon-tag>
+            <mobilizon-tag dir="auto" :with-hash-tag="true">{{
+              tag.title
+            }}</mobilizon-tag>
           </router-link>
         </div>
       </figure>
     </div>
-    <div class="p-2">
+    <div class="p-2 flex-auto" :class="{ 'sm:flex-1': mode === 'row' }">
       <div class="relative flex flex-col h-full">
-        <div class="-mt-3 h-0 flex mb-3 ltr:ml-0 rtl:mr-0 items-end self-start">
+        <div
+          class="-mt-3 h-0 flex mb-3 ltr:ml-0 rtl:mr-0 items-end self-start"
+          :class="{ 'sm:hidden': mode === 'row' }"
+        >
           <date-calendar-icon
             :small="true"
             v-if="!mergedOptions.hideDate"
             :date="event.beginsOn.toString()"
           />
         </div>
-        <div class="w-full flex flex-col justify-between">
-          <h3
-            class="text-lg leading-5 line-clamp-3 font-bold text-violet-3 dark:text-white"
-            :title="event.title"
+        <span
+          class="text-gray-700 dark:text-white font-semibold hidden"
+          :class="{ 'sm:block': mode === 'row' }"
+          >{{ formatDateTimeWithCurrentLocale }}</span
+        >
+        <div class="w-full flex flex-col justify-between h-full">
+          <h2
+            class="mt-0 mb-2 text-2xl line-clamp-3 font-bold text-violet-3 dark:text-white"
             dir="auto"
             :lang="event.language"
           >
             {{ event.title }}
-          </h3>
-          <div class="pt-3">
+          </h2>
+          <div class="">
             <div
               class="flex items-center text-violet-3 dark:text-white"
               dir="auto"
@@ -68,7 +86,7 @@
                 />
               </figure>
               <account-circle v-else />
-              <span class="text-sm font-semibold ltr:pl-2 rtl:pr-2">
+              <span class="font-semibold ltr:pl-2 rtl:pr-2">
                 {{ organizerDisplayName(event) }}
               </span>
             </div>
@@ -84,11 +102,38 @@
               <Video />
               <span class="ltr:pl-2 rtl:pr-2">{{ $t("Online") }}</span>
             </div>
+            <div
+              class="mt-1 no-underline gap-1 items-center hidden"
+              :class="{ 'sm:flex': mode === 'row' }"
+              v-if="event.tags || event.status !== EventStatus.CONFIRMED"
+            >
+              <mobilizon-tag
+                variant="info"
+                v-if="event.status === EventStatus.TENTATIVE"
+              >
+                {{ $t("Tentative") }}
+              </mobilizon-tag>
+              <mobilizon-tag
+                variant="danger"
+                v-if="event.status === EventStatus.CANCELLED"
+              >
+                {{ $t("Cancelled") }}
+              </mobilizon-tag>
+              <router-link
+                :to="{ name: RouteName.TAG, params: { tag: tag.title } }"
+                v-for="tag in (event.tags || []).slice(0, 3)"
+                :key="tag.slug"
+              >
+                <mobilizon-tag :with-hash-tag="true" dir="auto">{{
+                  tag.title
+                }}</mobilizon-tag>
+              </router-link>
+            </div>
           </div>
         </div>
       </div>
     </div>
-  </router-link>
+  </LinkOrRouterLink>
 </template>
 
 <script lang="ts" setup>
@@ -104,17 +149,29 @@ import { EventStatus } from "@/types/enums";
 import RouteName from "../../router/name";
 import InlineAddress from "@/components/Address/InlineAddress.vue";
 
-import { computed } from "vue";
-import MobilizonTag from "../Tag.vue";
+import { computed, inject } from "vue";
+import MobilizonTag from "@/components/Tag.vue";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import Video from "vue-material-design-icons/Video.vue";
+import { formatDateTimeForEvent } from "@/utils/datetime";
+import type { Locale } from "date-fns";
+import LinkOrRouterLink from "../core/LinkOrRouterLink.vue";
 
-const props = defineProps<{ event: IEvent; options?: IEventCardOptions }>();
+const props = withDefaults(
+  defineProps<{
+    event: IEvent;
+    options?: IEventCardOptions;
+    mode?: "row" | "column";
+  }>(),
+  { mode: "column" }
+);
 const defaultOptions: IEventCardOptions = {
   hideDate: false,
   loggedPerson: false,
   hideDetails: false,
   organizerActor: null,
+  isRemoteEvent: false,
+  isLoggedIn: true,
 };
 
 const mergedOptions = computed<IEventCardOptions>(() => ({
@@ -132,4 +189,31 @@ const mergedOptions = computed<IEventCardOptions>(() => ({
 const actorAvatarURL = computed<string | null>(() =>
   organizerAvatarUrl(props.event)
 );
+
+const dateFnsLocale = inject<Locale>("dateFnsLocale");
+
+const formatDateTimeWithCurrentLocale = computed(() => {
+  if (!dateFnsLocale) return;
+  return formatDateTimeForEvent(new Date(props.event.beginsOn), dateFnsLocale);
+});
+
+const isInternal = computed(() => {
+  return (
+    mergedOptions.value.isRemoteEvent &&
+    mergedOptions.value.isLoggedIn === false
+  );
+});
+
+const to = computed(() => {
+  if (mergedOptions.value.isRemoteEvent) {
+    if (mergedOptions.value.isLoggedIn === false) {
+      return props.event.url;
+    }
+    return {
+      name: RouteName.INTERACT,
+      query: { uri: encodeURI(props.event.url) },
+    };
+  }
+  return { name: RouteName.EVENT, params: { uuid: props.event.uuid } };
+});
 </script>
diff --git a/js/src/components/Event/EventFullDate.vue b/js/src/components/Event/EventFullDate.vue
index 1190ae537..1958f5792 100644
--- a/js/src/components/Event/EventFullDate.vue
+++ b/js/src/components/Event/EventFullDate.vue
@@ -14,7 +14,7 @@
   </p>
   <p v-else-if="isSameDay() && showStartTime && showEndTime">
     <span>{{
-      $t("On {date} from {startTime} to {endTime}", {
+      t("On {date} from {startTime} to {endTime}", {
         date: formatDate(beginsOn),
         startTime: formatTime(beginsOn, timezoneToShow),
         endTime: formatTime(endsOn, timezoneToShow),
@@ -31,27 +31,24 @@
   </p>
   <p v-else-if="isSameDay() && showStartTime && !showEndTime">
     {{
-      $t("On {date} starting at {startTime}", {
+      t("On {date} starting at {startTime}", {
         date: formatDate(beginsOn),
         startTime: formatTime(beginsOn),
       })
     }}
   </p>
   <p v-else-if="isSameDay()">
-    {{ $t("On {date}", { date: formatDate(beginsOn) }) }}
+    {{ t("On {date}", { date: formatDate(beginsOn) }) }}
   </p>
   <p v-else-if="endsOn && showStartTime && showEndTime">
     <span>
       {{
-        $t(
-          "From the {startDate} at {startTime} to the {endDate} at {endTime}",
-          {
-            startDate: formatDate(beginsOn),
-            startTime: formatTime(beginsOn, timezoneToShow),
-            endDate: formatDate(endsOn),
-            endTime: formatTime(endsOn, timezoneToShow),
-          }
-        )
+        t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
+          startDate: formatDate(beginsOn),
+          startTime: formatTime(beginsOn, timezoneToShow),
+          endDate: formatDate(endsOn),
+          endTime: formatTime(endsOn, timezoneToShow),
+        })
       }}
     </span>
     <br />
@@ -66,7 +63,7 @@
   <p v-else-if="endsOn && showStartTime">
     <span>
       {{
-        $t("From the {startDate} at {startTime} to the {endDate}", {
+        t("From the {startDate} at {startTime} to the {endDate}", {
           startDate: formatDate(beginsOn),
           startTime: formatTime(beginsOn, timezoneToShow),
           endDate: formatDate(endsOn),
@@ -169,22 +166,22 @@ const differentFromUserTimezone = computed((): boolean => {
 const singleTimeZone = computed((): string => {
   if (showLocalTimezone.value) {
     return t("Local time ({timezone})", {
-      timezone: timezoneToShow,
-    }) as string;
+      timezone: timezoneToShow.value,
+    });
   }
   return t("Time in your timezone ({timezone})", {
-    timezone: timezoneToShow,
-  }) as string;
+    timezone: timezoneToShow.value,
+  });
 });
 
 const multipleTimeZones = computed((): string => {
   if (showLocalTimezone.value) {
-    return t("Local time ({timezone})", {
-      timezone: timezoneToShow,
-    }) as string;
+    return t("Local times ({timezone})", {
+      timezone: timezoneToShow.value,
+    });
   }
   return t("Times in your timezone ({timezone})", {
-    timezone: timezoneToShow,
-  }) as string;
+    timezone: timezoneToShow.value,
+  });
 });
 </script>
diff --git a/js/src/components/Event/EventMap.vue b/js/src/components/Event/EventMap.vue
index 9f0c00641..0a3d050b9 100644
--- a/js/src/components/Event/EventMap.vue
+++ b/js/src/components/Event/EventMap.vue
@@ -87,7 +87,7 @@ const RoutingParamType = {
   },
 };
 
-const MapLeaflet = import("../../components/Map.vue");
+const MapLeaflet = import("@/components/LeafletMap.vue");
 
 const props = defineProps<{
   address: IAddress;
diff --git a/js/src/components/Event/EventMetadataList.vue b/js/src/components/Event/EventMetadataList.vue
index ff02f740b..d3fb4abd0 100644
--- a/js/src/components/Event/EventMetadataList.vue
+++ b/js/src/components/Event/EventMetadataList.vue
@@ -136,10 +136,10 @@ const metadata = computed({
       };
     }) as any[];
   },
-  set(metadata: IEventMetadataDescription[]) {
+  set(newMetadata: IEventMetadataDescription[]) {
     emit(
       "update:modelValue",
-      metadata.filter((elem) => elem)
+      newMetadata.filter((elem) => elem)
     );
   },
 });
diff --git a/js/src/components/Event/EventMetadataSidebar.vue b/js/src/components/Event/EventMetadataSidebar.vue
index 64c349f5a..3a3d37f07 100644
--- a/js/src/components/Event/EventMetadataSidebar.vue
+++ b/js/src/components/Event/EventMetadataSidebar.vue
@@ -10,7 +10,7 @@
         <div class="address" v-if="physicalAddress">
           <address-info :address="physicalAddress" />
           <o-button
-            type="is-text"
+            variant="text"
             class="map-show-button"
             @click="$emit('showMapModal', true)"
             v-if="physicalAddress.geom"
diff --git a/js/src/components/Event/EventMinimalistCard.vue b/js/src/components/Event/EventMinimalistCard.vue
index 22c9624da..044a5f297 100644
--- a/js/src/components/Event/EventMinimalistCard.vue
+++ b/js/src/components/Event/EventMinimalistCard.vue
@@ -22,27 +22,23 @@
         :lang="event.language"
         dir="auto"
       >
-        <b-tag
+        <tag
           variant="info"
           class="mr-1"
           v-if="event.status === EventStatus.TENTATIVE"
         >
           {{ $t("Tentative") }}
-        </b-tag>
-        <b-tag
+        </tag>
+        <tag
           variant="danger"
           class="mr-1"
           v-if="event.status === EventStatus.CANCELLED"
         >
           {{ $t("Cancelled") }}
-        </b-tag>
-        <b-tag
-          class="mr-2"
-          variant="warning"
-          size="is-medium"
-          v-if="event.draft"
-          >{{ $t("Draft") }}</b-tag
-        >
+        </tag>
+        <tag class="mr-2" variant="warning" size="medium" v-if="event.draft">{{
+          $t("Draft")
+        }}</tag>
         {{ event.title }}
       </h3>
       <inline-address
@@ -99,7 +95,7 @@
         </span>
         <span v-if="event.participantStats.notApproved > 0">
           <o-button
-            type="is-text"
+            variant="text"
             @click="
               gotToWithCheck(participation, {
                 name: RouteName.PARTICIPATIONS,
@@ -134,6 +130,7 @@ import InlineAddress from "@/components/Address/InlineAddress.vue";
 import Video from "vue-material-design-icons/Video.vue";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
+import Tag from "@/components/Tag.vue";
 
 withDefaults(
   defineProps<{
diff --git a/js/src/components/Event/EventParticipationCard.vue b/js/src/components/Event/EventParticipationCard.vue
index 327b48d7c..c86c3c9d3 100644
--- a/js/src/components/Event/EventParticipationCard.vue
+++ b/js/src/components/Event/EventParticipationCard.vue
@@ -1,12 +1,17 @@
 <template>
-  <article class="bg-white dark:bg-mbz-purple mb-5 mt-4 pb-2 md:p-0">
-    <div class="bg-yellow-2 flex p-2 text-violet-title rounded-t-lg" dir="auto">
+  <article
+    class="bg-white dark:bg-mbz-purple dark:hover:bg-mbz-purple-400 mb-5 mt-4 pb-2 md:p-0 rounded-t-lg"
+  >
+    <div
+      class="bg-mbz-yellow-alt-100 flex p-2 text-violet-title rounded-t-lg"
+      dir="auto"
+    >
       <figure
         class="image is-24x24 ltr:pr-1 rtl:pl-1"
         v-if="participation.actor.avatar"
       >
         <img
-          class="is-rounded"
+          class="rounded"
           :src="participation.actor.avatar.url"
           alt=""
           height="24"
@@ -157,7 +162,7 @@
               </span>
               <o-button
                 v-if="participation.event.participantStats.notApproved > 0"
-                type="is-text"
+                variant="text"
                 @click="
                   gotToWithCheck(participation, {
                     name: RouteName.PARTICIPATIONS,
@@ -330,7 +335,7 @@ const defaultOptions: IEventCardOptions = {
 const props = withDefaults(
   defineProps<{
     participation: IParticipant;
-    options: IEventCardOptions;
+    options?: IEventCardOptions;
   }>(),
   {
     options: () => ({
diff --git a/js/src/components/Event/FullAddressAutoComplete.vue b/js/src/components/Event/FullAddressAutoComplete.vue
index db01e5153..dec60e5b3 100644
--- a/js/src/components/Event/FullAddressAutoComplete.vue
+++ b/js/src/components/Event/FullAddressAutoComplete.vue
@@ -7,14 +7,11 @@
         :message="fieldErrors"
         :type="{ 'is-danger': fieldErrors }"
         class="!-mt-2"
+        :labelClass="labelClass"
       >
         <template #label>
           {{ actualLabel }}
-          <span
-            class="is-size-6 has-text-weight-normal"
-            v-if="gettingLocation"
-            >{{ t("Getting location") }}</span
-          >
+          <span v-if="gettingLocation">{{ t("Getting location") }}</span>
         </template>
         <p class="control" v-if="canShowLocateMeButton">
           <o-loading
@@ -54,7 +51,7 @@
           </template>
           <template #empty>
             <span v-if="isFetching">{{ t("Searching…") }}</span>
-            <div v-else-if="queryText.length >= 3" class="is-enabled">
+            <div v-else-if="queryText.length >= 3" class="enabled">
               <span>{{
                 t('No results for "{queryText}"', { queryText })
               }}</span>
@@ -121,12 +118,16 @@ import { useGeocodingAutocomplete } from "@/composition/apollo/config";
 import { ADDRESS } from "@/graphql/address";
 import { useReverseGeocode } from "@/composition/apollo/address";
 import { useLazyQuery } from "@vue/apollo-composable";
-const MapLeaflet = defineAsyncComponent(() => import("../Map.vue"));
+const MapLeaflet = defineAsyncComponent(
+  () => import("@/components/LeafletMap.vue")
+);
 
 const props = withDefaults(
   defineProps<{
     modelValue: IAddress | null;
+    defaultText?: string | null;
     label?: string;
+    labelClass?: string;
     userTimezone?: string;
     disabled?: boolean;
     hideMap?: boolean;
@@ -134,7 +135,8 @@ const props = withDefaults(
     placeholder?: string;
   }>(),
   {
-    label: "",
+    labelClass: "",
+    defaultText: "",
     disabled: false,
     hideMap: false,
     hideSelected: false,
@@ -204,7 +206,7 @@ const checkCurrentPosition = (e: LatLng): boolean => {
 const { t, locale } = useI18n({ useScope: "global" });
 
 const actualLabel = computed((): string => {
-  return props.label ?? (t("Find an address") as string);
+  return props.label ?? t("Find an address");
 });
 
 // eslint-disable-next-line class-methods-use-this
@@ -253,11 +255,14 @@ const asyncData = async (query: string): Promise<void> => {
 
 const queryText = computed({
   get() {
-    return selected.value ? addressFullName(selected.value) : "";
+    return (
+      (selected.value ? addressFullName(selected.value) : props.defaultText) ??
+      ""
+    );
   },
   set(text) {
     if (text === "" && selected.value?.id) {
-      console.log("doing reset");
+      console.debug("doing reset");
       resetAddress();
     }
   },
diff --git a/js/src/components/Event/GroupedMultiEventMinimalistCard.vue b/js/src/components/Event/GroupedMultiEventMinimalistCard.vue
index 516f1ddc6..01a90c6d2 100644
--- a/js/src/components/Event/GroupedMultiEventMinimalistCard.vue
+++ b/js/src/components/Event/GroupedMultiEventMinimalistCard.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="events-wrapper">
     <div class="flex flex-col gap-4" v-for="key of keys" :key="key">
-      <h2 class="is-size-5 month-name">
+      <h2 class="month-name">
         {{ monthName(groupEvents(key)[0]) }}
       </h2>
       <event-minimalist-card
diff --git a/js/src/components/Event/Integrations/Etherpad.vue b/js/src/components/Event/Integrations/EtherpadIntegration.vue
similarity index 100%
rename from js/src/components/Event/Integrations/Etherpad.vue
rename to js/src/components/Event/Integrations/EtherpadIntegration.vue
diff --git a/js/src/components/Event/Integrations/JitsiMeet.vue b/js/src/components/Event/Integrations/JitsiMeetIntegration.vue
similarity index 100%
rename from js/src/components/Event/Integrations/JitsiMeet.vue
rename to js/src/components/Event/Integrations/JitsiMeetIntegration.vue
diff --git a/js/src/components/Event/Integrations/PeerTube.vue b/js/src/components/Event/Integrations/PeerTubeIntegration.vue
similarity index 93%
rename from js/src/components/Event/Integrations/PeerTube.vue
rename to js/src/components/Event/Integrations/PeerTubeIntegration.vue
index 3a133a0c9..b530ee3eb 100644
--- a/js/src/components/Event/Integrations/PeerTube.vue
+++ b/js/src/components/Event/Integrations/PeerTubeIntegration.vue
@@ -27,10 +27,6 @@ const videoDetails = computed((): { host: string; uuid: string } | null => {
   }
   return null;
 });
-
-const origin = computed((): string => {
-  return window.location.hostname;
-});
 </script>
 <style lang="scss" scoped>
 .peertube {
diff --git a/js/src/components/Event/Integrations/Twitch.vue b/js/src/components/Event/Integrations/TwitchIntegration.vue
similarity index 100%
rename from js/src/components/Event/Integrations/Twitch.vue
rename to js/src/components/Event/Integrations/TwitchIntegration.vue
diff --git a/js/src/components/Event/Integrations/YouTube.vue b/js/src/components/Event/Integrations/YouTubeIntegration.vue
similarity index 93%
rename from js/src/components/Event/Integrations/YouTube.vue
rename to js/src/components/Event/Integrations/YouTubeIntegration.vue
index ff569bc14..26946813d 100644
--- a/js/src/components/Event/Integrations/YouTube.vue
+++ b/js/src/components/Event/Integrations/YouTubeIntegration.vue
@@ -28,10 +28,6 @@ const videoID = computed((): string | null => {
   }
   return null;
 });
-
-const origin = computed((): string => {
-  return window.location.hostname;
-});
 </script>
 <style lang="scss" scoped>
 .youtube {
diff --git a/js/src/components/Event/OrganizerPicker.vue b/js/src/components/Event/OrganizerPicker.vue
index 5d76a10da..ac0d3f860 100644
--- a/js/src/components/Event/OrganizerPicker.vue
+++ b/js/src/components/Event/OrganizerPicker.vue
@@ -34,9 +34,9 @@
           class="flex flex-wrap p-3 bg-white hover:bg-gray-50 dark:bg-violet-3 dark:hover:bg-violet-3/60 border border-gray-300 rounded-lg cursor-pointer peer-checked:ring-primary peer-checked:ring-2 peer-checked:border-transparent"
           :for="`availableActor-${availableActor?.id}`"
         >
-          <figure class="" v-if="availableActor?.avatar">
+          <figure class="h-12 w-12" v-if="availableActor?.avatar">
             <img
-              class="rounded"
+              class="rounded-full h-full w-full object-cover"
               :src="availableActor.avatar.url"
               alt=""
               width="48"
diff --git a/js/src/components/Event/OrganizerPickerWrapper.vue b/js/src/components/Event/OrganizerPickerWrapper.vue
index df2b16d81..9ed7b28dc 100644
--- a/js/src/components/Event/OrganizerPickerWrapper.vue
+++ b/js/src/components/Event/OrganizerPickerWrapper.vue
@@ -12,9 +12,9 @@
     >
       <div class="flex gap-1 p-4">
         <div class="">
-          <figure class="" v-if="selectedActor.avatar">
+          <figure class="h-12 w-12" v-if="selectedActor.avatar">
             <img
-              class="rounded"
+              class="rounded-full h-full w-full object-cover"
               :src="selectedActor.avatar.url"
               :alt="selectedActor.avatar.alt ?? ''"
               height="48"
@@ -207,7 +207,7 @@ const props = withDefaults(
   { inline: true, contacts: () => [] }
 );
 
-const emit = defineEmits(["update:modelValue", "update:Contacts"]);
+const emit = defineEmits(["update:modelValue", "update:contacts"]);
 
 const selectedActor = computed({
   get(): IActor | undefined {
@@ -252,7 +252,7 @@ const actualContacts = computed({
   },
   set(contactsIds: (string | undefined)[]) {
     emit(
-      "update:Contacts",
+      "update:contacts",
       actorMembers.value.filter(({ id }) => contactsIds.includes(id))
     );
   },
diff --git a/js/src/components/Event/ParticipationButton.story.vue b/js/src/components/Event/ParticipationButton.story.vue
index 2f4a2d8d6..e1e16b511 100644
--- a/js/src/components/Event/ParticipationButton.story.vue
+++ b/js/src/components/Event/ParticipationButton.story.vue
@@ -68,7 +68,7 @@
 </template>
 
 <script lang="ts" setup>
-import { IActor, IPerson } from "@/types/actor";
+import { IPerson } from "@/types/actor";
 import { EventJoinOptions, ParticipantRole } from "@/types/enums";
 import { IEvent } from "@/types/event.model";
 import ParticipationButton from "./ParticipationButton.vue";
diff --git a/js/src/components/Event/ShareEventModal.vue b/js/src/components/Event/ShareEventModal.vue
index 5a587d125..bec5c4a95 100644
--- a/js/src/components/Event/ShareEventModal.vue
+++ b/js/src/components/Event/ShareEventModal.vue
@@ -37,7 +37,7 @@ import { useI18n } from "vue-i18n";
 import { IEvent } from "@/types/event.model";
 import ShareModal from "@/components/Share/ShareModal.vue";
 
-const props = withDefaults(
+withDefaults(
   defineProps<{
     event: IEvent;
     eventCapacityOK?: boolean;
diff --git a/js/src/components/Event/TagInput.story.vue b/js/src/components/Event/TagInput.story.vue
index d6976e1c8..ed30e63e4 100644
--- a/js/src/components/Event/TagInput.story.vue
+++ b/js/src/components/Event/TagInput.story.vue
@@ -16,8 +16,8 @@ import TagInput from "./TagInput.vue";
 
 const tags = reactive<ITag[]>([{ title: "Hello", slug: "hello" }]);
 
-const fetchTags = async (text: string) =>
-  new Promise<ITag[]>((resolve, reject) => {
+const fetchTags = async () =>
+  new Promise<ITag[]>((resolve) => {
     resolve([{ title: "Welcome", slug: "welcome" }]);
   });
 </script>
diff --git a/js/src/components/Event/TagInput.vue b/js/src/components/Event/TagInput.vue
index 54a34bb20..dc88546e5 100644
--- a/js/src/components/Event/TagInput.vue
+++ b/js/src/components/Event/TagInput.vue
@@ -3,7 +3,7 @@
     <template #label>
       {{ $t("Add some tags") }}
       <o-tooltip
-        type="dark"
+        variant="dark"
         :label="
           $t('You can add tags by hitting the Enter key or by adding a comma')
         "
@@ -77,9 +77,9 @@ const tagsStrings = computed({
   get(): string[] {
     return props.modelValue.map((tag: ITag) => tag.title);
   },
-  set(tagsStrings: string[]) {
-    console.debug("tagsStrings", tagsStrings);
-    const tagEntities = tagsStrings.map((tag: string | ITag) => {
+  set(newTagsStrings: string[]) {
+    console.debug("tagsStrings", newTagsStrings);
+    const tagEntities = newTagsStrings.map((tag: string | ITag) => {
       if (typeof tag !== "string") {
         return tag;
       }
diff --git a/js/src/components/Group/GroupCard.story.vue b/js/src/components/Group/GroupCard.story.vue
index 46acd9f74..9539e818c 100644
--- a/js/src/components/Group/GroupCard.story.vue
+++ b/js/src/components/Group/GroupCard.story.vue
@@ -15,14 +15,17 @@
         <GroupCard :group="groupWithFollowersOrMembers" />
       </div>
     </Variant>
+    <Variant title="Row mode">
+      <GroupCard :group="groupWithFollowersOrMembers" mode="row" />
+    </Variant>
   </Story>
 </template>
 
 <script lang="ts" setup>
-import { IActor } from "@/types/actor";
+import { IGroup } from "@/types/actor";
 import GroupCard from "./GroupCard.vue";
 
-const basicGroup: IActor = {
+const basicGroup: IGroup = {
   name: "Framasoft",
   preferredUsername: "framasoft",
   avatar: null,
@@ -34,7 +37,7 @@ const basicGroup: IActor = {
   followers: { total: 0, elements: [] },
 };
 
-const groupWithMedia = {
+const groupWithMedia: IGroup = {
   ...basicGroup,
   banner: {
     url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
@@ -44,9 +47,14 @@ const groupWithMedia = {
   },
 };
 
-const groupWithFollowersOrMembers = {
+const groupWithFollowersOrMembers: IGroup = {
   ...groupWithMedia,
   members: { total: 2, elements: [] },
   followers: { total: 5, elements: [] },
+  summary:
+    "You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:h-full to apply the h-full utility at only medium screen sizes and above.",
+  physicalAddress: {
+    description: "Nantes",
+  },
 };
 </script>
diff --git a/js/src/components/Group/GroupCard.vue b/js/src/components/Group/GroupCard.vue
index 564ac2b78..2ac0cadd5 100644
--- a/js/src/components/Group/GroupCard.vue
+++ b/js/src/components/Group/GroupCard.vue
@@ -1,29 +1,31 @@
 <template>
-  <router-link
-    :to="{
-      name: RouteName.GROUP,
-      params: { preferredUsername: usernameWithDomain(group) },
+  <LinkOrRouterLink
+    :to="to"
+    :isInternal="isInternal"
+    class="mbz-card shrink-0 dark:bg-mbz-purple dark:text-white rounded-lg shadow-lg my-4 flex items-center flex-col"
+    :class="{
+      'sm:flex-row': mode === 'row',
+      'max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
     }"
-    class="card flex flex-col shrink-0 w-[18rem] bg-white dark:bg-mbz-purple dark:text-white rounded-lg shadow-lg"
   >
-    <figure class="rounded-t-lg flex justify-center h-40">
-      <lazy-image-wrapper :picture="group.banner" :rounded="true" />
-    </figure>
-    <div class="py-2 pl-2">
+    <div class="flex-none p-2 md:p-4">
+      <figure class="" v-if="group.avatar">
+        <img
+          class="rounded-full"
+          :src="group.avatar.url"
+          alt=""
+          height="128"
+          width="128"
+        />
+      </figure>
+      <AccountGroup v-else :size="128" />
+    </div>
+    <div
+      class="py-2 px-2 md:px-4 flex flex-col h-full justify-between w-full"
+      :class="{ 'sm:flex-1': mode === 'row' }"
+    >
       <div class="flex gap-1 mb-2">
-        <div class="">
-          <figure class="" v-if="group.avatar">
-            <img
-              class="rounded-xl"
-              :src="group.avatar.url"
-              alt=""
-              height="64"
-              width="64"
-            />
-          </figure>
-          <AccountGroup v-else :size="64" />
-        </div>
-        <div class="px-1 overflow-hidden">
+        <div class="px-1 overflow-hidden flex-auto">
           <h3
             class="text-2xl leading-5 line-clamp-3 font-bold text-violet-3 dark:text-white"
             dir="auto"
@@ -46,7 +48,10 @@
           v-if="group.physicalAddress && addressFullName(group.physicalAddress)"
           :physicalAddress="group.physicalAddress"
         />
-        <p class="flex gap-1">
+        <p
+          class="flex gap-1"
+          v-if="group?.members?.total && group?.followers?.total"
+        >
           <Account />
           {{
             t(
@@ -58,14 +63,28 @@
             )
           }}
         </p>
+        <p
+          class="flex gap-1"
+          v-else-if="group?.membersCount || group?.followersCount"
+        >
+          <Account />
+          {{
+            t(
+              "{count} members or followers",
+              {
+                count: (group.membersCount ?? 0) + (group.followersCount ?? 0),
+              },
+              (group.membersCount ?? 0) + (group.followersCount ?? 0)
+            )
+          }}
+        </p>
       </div>
     </div>
-  </router-link>
+  </LinkOrRouterLink>
 </template>
 
 <script lang="ts" setup>
 import { displayName, IGroup, usernameWithDomain } from "@/types/actor";
-import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
 import RouteName from "../../router/name";
 import InlineAddress from "@/components/Address/InlineAddress.vue";
 import { addressFullName } from "@/types/address.model";
@@ -74,16 +93,40 @@ import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
 import Account from "vue-material-design-icons/Account.vue";
 import { htmlToText } from "@/utils/html";
 import { computed } from "vue";
+import LinkOrRouterLink from "../core/LinkOrRouterLink.vue";
 
 const props = withDefaults(
   defineProps<{
     group: IGroup;
-    showSummary: boolean;
+    showSummary?: boolean;
+    isRemoteGroup?: boolean;
+    isLoggedIn?: boolean;
+    mode?: "row" | "column";
   }>(),
-  { showSummary: true }
+  { showSummary: true, isRemoteGroup: false, isLoggedIn: true, mode: "column" }
 );
 
 const { t } = useI18n({ useScope: "global" });
 
-const saneSummary = computed(() => htmlToText(props.group.summary));
+const saneSummary = computed(() => htmlToText(props.group.summary ?? ""));
+
+const isInternal = computed(() => {
+  return props.isRemoteGroup && props.isLoggedIn === false;
+});
+
+const to = computed(() => {
+  if (props.isRemoteGroup) {
+    if (props.isLoggedIn === false) {
+      return props.group.url;
+    }
+    return {
+      name: RouteName.INTERACT,
+      query: { uri: encodeURI(props.group.url) },
+    };
+  }
+  return {
+    name: RouteName.GROUP,
+    params: { preferredUsername: usernameWithDomain(props.group) },
+  };
+});
 </script>
diff --git a/js/src/components/Group/GroupMemberCard.story.vue b/js/src/components/Group/GroupMemberCard.story.vue
index 92ecf05c4..461d2177c 100644
--- a/js/src/components/Group/GroupMemberCard.story.vue
+++ b/js/src/components/Group/GroupMemberCard.story.vue
@@ -73,19 +73,19 @@ const adminMember: IMember = {
   role: MemberRole.ADMINISTRATOR,
 };
 
-const groupWithMedia = {
-  ...basicGroup,
-  banner: {
-    url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
-  },
-  avatar: {
-    url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
-  },
-};
+// const groupWithMedia = {
+//   ...basicGroup,
+//   banner: {
+//     url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
+//   },
+//   avatar: {
+//     url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
+//   },
+// };
 
-const groupWithFollowersOrMembers = {
-  ...groupWithMedia,
-  members: { total: 2, elements: [] },
-  followers: { total: 5, elements: [] },
-};
+// const groupWithFollowersOrMembers = {
+//   ...groupWithMedia,
+//   members: { total: 2, elements: [] },
+//   followers: { total: 5, elements: [] },
+// };
 </script>
diff --git a/js/src/components/Group/GroupMemberCard.vue b/js/src/components/Group/GroupMemberCard.vue
index c0f536c83..8c52ea202 100644
--- a/js/src/components/Group/GroupMemberCard.vue
+++ b/js/src/components/Group/GroupMemberCard.vue
@@ -41,19 +41,19 @@
               }"
             >
               <h2 class="mt-0">{{ member.parent.name }}</h2>
-              <div class="flex flex-col">
+              <div class="flex flex-col items-start">
                 <span class="text-sm">{{
                   `@${usernameWithDomain(member.parent)}`
                 }}</span>
                 <tag
                   variant="info"
                   v-if="member.role === MemberRole.ADMINISTRATOR"
-                  >{{ $t("Administrator") }}</tag
+                  >{{ t("Administrator") }}</tag
                 >
                 <tag
                   variant="info"
                   v-else-if="member.role === MemberRole.MODERATOR"
-                  >{{ $t("Moderator") }}</tag
+                  >{{ t("Moderator") }}</tag
                 >
               </div>
             </router-link>
@@ -77,7 +77,7 @@
             @click="emit('leave')"
           >
             <ExitToApp />
-            {{ $t("Leave") }}
+            {{ t("Leave") }}
           </o-dropdown-item>
         </o-dropdown>
       </div>
@@ -96,10 +96,13 @@ import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import Tag from "@/components/Tag.vue";
 import { htmlToText } from "@/utils/html";
+import { useI18n } from "vue-i18n";
 
 defineProps<{
   member: IMember;
 }>();
 
 const emit = defineEmits(["leave"]);
+
+const { t } = useI18n({ useScope: "global" });
 </script>
diff --git a/js/src/components/Group/InvitationCard.vue b/js/src/components/Group/InvitationCard.vue
index abfb33d81..f3a2888ed 100644
--- a/js/src/components/Group/InvitationCard.vue
+++ b/js/src/components/Group/InvitationCard.vue
@@ -1,7 +1,7 @@
 <template>
-  <div class="card">
-    <div class="card-content media">
-      <div class="media-content">
+  <div class="">
+    <div class="">
+      <div class="">
         <div class="prose dark:prose-invert">
           <i18n-t
             tag="p"
@@ -12,12 +12,18 @@
             </template>
           </i18n-t>
         </div>
-        <div class="media subfield">
-          <div class="media-left">
-            <figure class="image is-48x48" v-if="member.parent.avatar">
-              <img class="is-rounded" :src="member.parent.avatar.url" alt="" />
+        <div class="">
+          <div class="">
+            <figure v-if="member.parent.avatar">
+              <img
+                class="rounded"
+                :src="member.parent.avatar.url"
+                alt=""
+                height="48"
+                width="48"
+              />
             </figure>
-            <o-icon v-else size="large" icon="account-group" />
+            <AccountGroup :size="48" v-else />
           </div>
           <div class="media-content">
             <div class="level">
@@ -31,8 +37,8 @@
                       },
                     }"
                   >
-                    <h3 class="is-size-5">{{ member.parent.name }}</h3>
-                    <p class="is-size-7 has-text-grey-dark">
+                    <h3 class="">{{ member.parent.name }}</h3>
+                    <p class="">
                       <span v-if="member.parent.domain">
                         {{
                           `@${member.parent.preferredUsername}@${member.parent.domain}`
@@ -45,8 +51,8 @@
                   </router-link>
                 </div>
               </div>
-              <div class="level-right">
-                <div class="level-item">
+              <div class="">
+                <div class="">
                   <o-button
                     variant="success"
                     @click="$emit('accept', member.id)"
@@ -54,7 +60,7 @@
                     {{ $t("Accept") }}
                   </o-button>
                 </div>
-                <div class="level-item">
+                <div class="">
                   <o-button
                     variant="danger"
                     @click="$emit('reject', member.id)"
@@ -75,6 +81,7 @@
 import { usernameWithDomain } from "@/types/actor";
 import { IMember } from "@/types/actor/member.model";
 import RouteName from "../../router/name";
+import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
 
 defineProps<{
   member: IMember;
diff --git a/js/src/components/Group/Sections/Discussions.vue b/js/src/components/Group/Sections/DiscussionsSection.vue
similarity index 100%
rename from js/src/components/Group/Sections/Discussions.vue
rename to js/src/components/Group/Sections/DiscussionsSection.vue
diff --git a/js/src/components/Group/Sections/Events.vue b/js/src/components/Group/Sections/EventsSection.vue
similarity index 100%
rename from js/src/components/Group/Sections/Events.vue
rename to js/src/components/Group/Sections/EventsSection.vue
diff --git a/js/src/components/Group/Sections/Posts.vue b/js/src/components/Group/Sections/PostsSection.vue
similarity index 100%
rename from js/src/components/Group/Sections/Posts.vue
rename to js/src/components/Group/Sections/PostsSection.vue
diff --git a/js/src/components/Group/Sections/Resources.vue b/js/src/components/Group/Sections/ResourcesSection.vue
similarity index 100%
rename from js/src/components/Group/Sections/Resources.vue
rename to js/src/components/Group/Sections/ResourcesSection.vue
diff --git a/js/src/components/Group/SkeletonGroupResult.vue b/js/src/components/Group/SkeletonGroupResult.vue
new file mode 100644
index 000000000..73a863393
--- /dev/null
+++ b/js/src/components/Group/SkeletonGroupResult.vue
@@ -0,0 +1,18 @@
+<template>
+  <div
+    class="bg-white dark:bg-slate-800 shadow rounded-md max-w-sm w-full mx-auto"
+  >
+    <div class="animate-pulse flex flex-col space-3-4 items-center">
+      <div
+        class="object-cover h-40 w-40 rounded-full bg-slate-700 p-2 md:p-4"
+      />
+
+      <div
+        class="flex gap-3 flex self-start flex-col justify-between p-2 md:p-4 w-full"
+      >
+        <div class="h-5 bg-slate-700"></div>
+        <div class="h-3 bg-slate-700"></div>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/js/src/components/Home/CategoriesPreview.vue b/js/src/components/Home/CategoriesPreview.vue
index 7a51b6615..495272f4a 100644
--- a/js/src/components/Home/CategoriesPreview.vue
+++ b/js/src/components/Home/CategoriesPreview.vue
@@ -28,8 +28,7 @@ import { CATEGORY_STATISTICS } from "@/graphql/statistics";
 import { useI18n } from "vue-i18n";
 import shuffle from "lodash/shuffle";
 import { categoriesWithPictures } from "../Categories/constants";
-import { IConfig } from "@/types/config.model";
-import { CONFIG } from "@/graphql/config";
+import { useEventCategories } from "@/composition/apollo/config";
 
 const { t } = useI18n({ useScope: "global" });
 
@@ -40,14 +39,10 @@ const categoryStats = computed(
   () => categoryStatsResult.value?.categoryStatistics ?? []
 );
 
-const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
-
-const config = computed(() => configResult.value?.config);
-
-const eventCategories = computed(() => config.value?.eventCategories ?? []);
+const { eventCategories } = useEventCategories();
 
 const eventCategoryLabel = (categoryId: string): string | undefined => {
-  return eventCategories.value.find(({ id }) => categoryId == id)?.label;
+  return eventCategories.value?.find(({ id }) => categoryId == id)?.label;
 };
 
 const promotedCategories = computed((): CategoryStatsModel[] => {
diff --git a/js/src/components/Home/SearchFields.vue b/js/src/components/Home/SearchFields.vue
index 5d1c1f43d..5a0c005cb 100644
--- a/js/src/components/Home/SearchFields.vue
+++ b/js/src/components/Home/SearchFields.vue
@@ -1,14 +1,17 @@
 <template>
   <form
     id="search-anchor"
-    class="container mx-auto my-3 px-2 flex flex-wrap flex-col sm:flex-row items-stretch gap-2 text-center items-center justify-center dark:text-slate-100"
+    class="container mx-auto my-3 flex flex-wrap flex-col sm:flex-row items-stretch gap-2 text-center items-center justify-center dark:text-slate-100"
     role="search"
-    @submit.prevent="emit('submit')"
+    @submit.prevent="submit"
   >
+    <label class="sr-only" for="search_field_input">{{
+      t("Keyword, event title, group name, etc.")
+    }}</label>
     <o-input
-      class="flex-1"
       v-model="search"
       :placeholder="t('Keyword, event title, group name, etc.')"
+      id="search_field_input"
       autofocus
       autocapitalize="off"
       autocomplete="off"
@@ -21,8 +24,10 @@
       v-model="location"
       :hide-map="true"
       :hide-selected="true"
+      :default-text="locationDefaultText"
+      labelClass="sr-only"
     />
-    <o-button type="submit" icon-left="magnify">
+    <o-button native-type="submit" icon-left="magnify">
       <template v-if="search">{{ t("Go!") }}</template>
       <template v-else>{{ t("Explore!") }}</template>
     </o-button>
@@ -35,23 +40,28 @@ import { AddressSearchType } from "@/types/enums";
 import { computed } from "vue";
 import { useI18n } from "vue-i18n";
 import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
+import { useRouter } from "vue-router";
+import RouteName from "@/router/name";
 
 const props = defineProps<{
-  location: IAddress;
+  location: IAddress | null;
+  locationDefaultText?: string | null;
   search: string;
 }>();
 
+const router = useRouter();
+
 const emit = defineEmits<{
-  (event: "update:location", location: IAddress): void;
+  (event: "update:location", location: IAddress | null): void;
   (event: "update:search", newSearch: string): void;
   (event: "submit"): void;
 }>();
 
 const location = computed({
-  get(): IAddress {
+  get(): IAddress | null {
     return props.location;
   },
-  set(newLocation: IAddress) {
+  set(newLocation: IAddress | null) {
     emit("update:location", newLocation);
   },
 });
@@ -65,6 +75,25 @@ const search = computed({
   },
 });
 
+const submit = () => {
+  emit("submit");
+  const lat = location.value?.geom
+    ? parseFloat(location.value?.geom?.split(";")?.[1])
+    : undefined;
+  const lon = location.value?.geom
+    ? parseFloat(location.value?.geom?.split(";")?.[0])
+    : undefined;
+  router.push({
+    name: RouteName.SEARCH,
+    query: {
+      locationName: location.value?.locality ?? location.value?.region,
+      lat,
+      lon,
+      search: search.value,
+    },
+  });
+};
+
 const { t } = useI18n({ useScope: "global" });
 </script>
 <style scoped>
diff --git a/js/src/components/Home/UnloggedIntroduction.vue b/js/src/components/Home/UnloggedIntroduction.vue
index 39fd0c73c..bd95e33c9 100644
--- a/js/src/components/Home/UnloggedIntroduction.vue
+++ b/js/src/components/Home/UnloggedIntroduction.vue
@@ -26,7 +26,7 @@
         >{{ t("Create an account") }}</o-button
       >
       <!-- We don't invite to find other instances yet -->
-      <!-- <o-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ t('Find an instance') }}</o-button> -->
+      <!-- <o-button v-else variant="link" tag="a" href="https://joinmastodon.org">{{ t('Find an instance') }}</o-button> -->
       <router-link
         :to="{ name: RouteName.ABOUT }"
         class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-violet-title focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
@@ -41,7 +41,12 @@ import { IConfig } from "@/types/config.model";
 import RouteName from "@/router/name";
 import { useI18n } from "vue-i18n";
 
-defineProps<{ config: IConfig }>();
+defineProps<{
+  config: Pick<
+    IConfig,
+    "name" | "description" | "slogan" | "registrationsOpen"
+  >;
+}>();
 
 const { t } = useI18n({ useScope: "global" });
 </script>
diff --git a/js/src/components/Map.vue b/js/src/components/LeafletMap.vue
similarity index 100%
rename from js/src/components/Map.vue
rename to js/src/components/LeafletMap.vue
diff --git a/js/src/components/Local/CloseContent.vue b/js/src/components/Local/CloseContent.vue
index 2b539d1d7..0d614d003 100644
--- a/js/src/components/Local/CloseContent.vue
+++ b/js/src/components/Local/CloseContent.vue
@@ -28,7 +28,7 @@
     </div>
     <div class="overflow-hidden">
       <div
-        class="relative w-full snap-x snap-always snap-mandatory overflow-x-auto flex pb-6 gap-x-5 gap-y-8"
+        class="relative w-full snap-x snap-always snap-mandatory overflow-x-auto flex pb-6 gap-x-5 gap-y-8 p-1"
         ref="scrollContainer"
         @scroll="scrollHandler"
       >
diff --git a/js/src/components/Local/CloseEvents.vue b/js/src/components/Local/CloseEvents.vue
index 46a8f6479..bd6808b46 100644
--- a/js/src/components/Local/CloseEvents.vue
+++ b/js/src/components/Local/CloseEvents.vue
@@ -29,7 +29,7 @@
       <more-content
         v-if="userLocationName && userLocation?.lat && userLocation?.lon"
         :to="{
-          name: 'SEARCH',
+          name: RouteName.SEARCH,
           query: {
             locationName: userLocationName,
             lat: userLocation.lat?.toString(),
@@ -63,6 +63,8 @@ import { Paginate } from "@/types/paginate";
 import SkeletonEventResult from "../Event/SkeletonEventResult.vue";
 import { useI18n } from "vue-i18n";
 import { coordsToGeoHash } from "@/utils/location";
+import { roundToNearestMinute } from "@/utils/datetime";
+import RouteName from "@/router/name";
 
 const props = defineProps<{ userLocation: LocationType }>();
 const emit = defineEmits(["doGeoLoc"]);
@@ -77,17 +79,27 @@ const userLocationName = computed(() => {
 });
 const suggestGeoloc = computed(() => props.userLocation?.isIPLocation);
 
+const geoHash = computed(() =>
+  coordsToGeoHash(props.userLocation.lat, props.userLocation.lon)
+);
+
 const { result: eventsResult, loading: loadingEvents } = useQuery<{
   searchEvents: Paginate<IEvent>;
-}>(SEARCH_EVENTS, () => ({
-  location: coordsToGeoHash(props.userLocation.lat, props.userLocation.lon),
-  beginsOn: new Date(),
-  endsOn: undefined,
-  radius: 25,
-  eventPage: 1,
-  limit: EVENT_PAGE_LIMIT,
-  type: "IN_PERSON",
-}));
+}>(
+  SEARCH_EVENTS,
+  () => ({
+    location: geoHash.value,
+    beginsOn: roundToNearestMinute(new Date()),
+    endsOn: undefined,
+    radius: 25,
+    eventPage: 1,
+    limit: EVENT_PAGE_LIMIT,
+    type: "IN_PERSON",
+  }),
+  () => ({
+    enabled: geoHash.value !== undefined,
+  })
+);
 
 const events = computed(
   () => eventsResult.value?.searchEvents ?? { elements: [], total: 0 }
diff --git a/js/src/components/Local/CloseGroups.vue b/js/src/components/Local/CloseGroups.vue
index 7b492c13e..f11bcbfc0 100644
--- a/js/src/components/Local/CloseGroups.vue
+++ b/js/src/components/Local/CloseGroups.vue
@@ -18,12 +18,12 @@
       </template>
     </template>
     <template #content>
-      <!-- <skeleton-group-result
+      <skeleton-group-result
         v-for="i in [...Array(6).keys()]"
         class="scroll-ml-6 snap-center shrink-0 w-[18rem] my-4"
         :key="i"
         v-show="loadingGroups"
-      /> -->
+      />
       <group-card
         v-for="group in selectedGroups"
         :key="group.id"
@@ -37,7 +37,7 @@
       <more-content
         v-if="userLocationName"
         :to="{
-          name: 'SEARCH',
+          name: RouteName.SEARCH,
           query: {
             locationName: userLocationName,
             lat: userLocation.lat?.toString(),
@@ -59,9 +59,9 @@
 </template>
 
 <script lang="ts" setup>
-// import SkeletonGroupResult from "../../components/result/SkeletonGroupResult.vue";
+import SkeletonGroupResult from "@/components/Group/SkeletonGroupResult.vue";
 import sampleSize from "lodash/sampleSize";
-import { LocationType } from "../../types/user-location.model";
+import { LocationType } from "@/types/user-location.model";
 import MoreContent from "./MoreContent.vue";
 import CloseContent from "./CloseContent.vue";
 import { IGroup } from "@/types/actor";
@@ -72,6 +72,7 @@ import { computed } from "vue";
 import GroupCard from "@/components/Group/GroupCard.vue";
 import { coordsToGeoHash } from "@/utils/location";
 import { useI18n } from "vue-i18n";
+import RouteName from "@/router/name";
 
 const props = defineProps<{ userLocation: LocationType }>();
 const emit = defineEmits(["doGeoLoc"]);
diff --git a/js/src/components/Local/LastEvents.vue b/js/src/components/Local/LastEvents.vue
index 91ad29861..c2b936231 100644
--- a/js/src/components/Local/LastEvents.vue
+++ b/js/src/components/Local/LastEvents.vue
@@ -33,7 +33,7 @@
       />
       <more-content
         :to="{
-          name: 'SEARCH',
+          name: RouteName.SEARCH,
           query: {
             contentType: 'EVENTS',
           },
@@ -57,6 +57,7 @@ import SkeletonEventResult from "../Event/SkeletonEventResult.vue";
 import { EventSortField, SortDirection } from "@/types/enums";
 import { FETCH_EVENTS } from "@/graphql/event";
 import { useI18n } from "vue-i18n";
+import RouteName from "@/router/name";
 
 defineProps<{
   instanceName: string;
diff --git a/js/src/components/Local/OnlineEvents.vue b/js/src/components/Local/OnlineEvents.vue
index 8196e9886..fe3a0a56d 100644
--- a/js/src/components/Local/OnlineEvents.vue
+++ b/js/src/components/Local/OnlineEvents.vue
@@ -1,7 +1,8 @@
 <template>
   <close-content
+    class="container mx-auto px-2"
     :suggest-geoloc="false"
-    v-show="loadingEvents || events.length > 0"
+    v-show="loadingEvents || (events?.elements && events?.elements.length > 0)"
   >
     <template #title>
       {{ $t("Online upcoming events") }}
@@ -15,7 +16,7 @@
       />
       <event-card
         class="scroll-ml-6 snap-center shrink-0 first:pl-8 last:pr-8 w-[18rem]"
-        v-for="event in events"
+        v-for="event in events?.elements"
         :key="event.id"
         :event="event"
         view-mode="column"
@@ -24,7 +25,7 @@
       />
       <more-content
         :to="{
-          name: 'SEARCH',
+          name: RouteName.SEARCH,
           query: {
             contentType: 'EVENTS',
             isOnline: 'true',
@@ -50,25 +51,27 @@
 
 <script lang="ts" setup>
 import { computed } from "vue";
-import SkeletonEventResult from "../result/SkeletonEventResult.vue";
+import SkeletonEventResult from "@/components/Event/SkeletonEventResult.vue";
 import MoreContent from "./MoreContent.vue";
 import CloseContent from "./CloseContent.vue";
 import { SEARCH_EVENTS } from "@/graphql/search";
-import EventCard from "../../components/Event/EventCard.vue";
+import EventCard from "@/components/Event/EventCard.vue";
 import { useQuery } from "@vue/apollo-composable";
+import RouteName from "@/router/name";
+import { Paginate } from "@/types/paginate";
+import { IEvent } from "@/types/event.model";
 
 const EVENT_PAGE_LIMIT = 12;
 
-const { result: searchEventResult, loading: loadingEvents } = useQuery(
-  SEARCH_EVENTS,
-  () => ({
-    beginsOn: new Date(),
-    endsOn: undefined,
-    eventPage: 1,
-    limit: EVENT_PAGE_LIMIT,
-    type: "ONLINE",
-  })
-);
+const { result: searchEventResult, loading: loadingEvents } = useQuery<{
+  searchEvents: Paginate<IEvent>;
+}>(SEARCH_EVENTS, () => ({
+  beginsOn: new Date(),
+  endsOn: undefined,
+  eventPage: 1,
+  limit: EVENT_PAGE_LIMIT,
+  type: "ONLINE",
+}));
 
-const events = computed(() => searchEventResult.value.searchEvents);
+const events = computed(() => searchEventResult.value?.searchEvents);
 </script>
diff --git a/js/src/components/MobilizonLogo.vue b/js/src/components/MobilizonLogo.vue
index fc4c3253d..c179e0e89 100644
--- a/js/src/components/MobilizonLogo.vue
+++ b/js/src/components/MobilizonLogo.vue
@@ -1,6 +1,6 @@
 <template>
   <svg
-    class="bg-white dark:bg-gray-900 dark:fill-white"
+    class="bg-white dark:bg-zinc-900 dark:fill-white"
     :class="{ 'bg-gray-900': invert }"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 248.16 46.78"
diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue
index 5b1e3e827..8bd7a185a 100644
--- a/js/src/components/NavBar.vue
+++ b/js/src/components/NavBar.vue
@@ -1,11 +1,11 @@
 <template>
-  <nav class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-gray-900">
+  <nav class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-900">
     <div class="container mx-auto flex flex-wrap items-center mx-auto gap-4">
       <router-link :to="{ name: RouteName.HOME }" class="flex items-center">
         <MobilizonLogo class="w-40" />
       </router-link>
       <div class="flex items-center md:order-2 ml-auto" v-if="currentActor?.id">
-        <o-dropdown>
+        <o-dropdown position="bottom-left">
           <template #trigger>
             <button
               type="button"
@@ -14,33 +14,80 @@
               aria-expanded="false"
             >
               <span class="sr-only">{{ t("Open user menu") }}</span>
-              <figure class="" v-if="currentActor?.avatar">
+              <figure class="h-8 w-8" v-if="currentActor?.avatar">
                 <img
-                  class="rounded-full"
+                  class="rounded-full w-full h-full object-cover"
                   alt=""
                   :src="currentActor?.avatar.url"
                   width="32"
                   height="32"
                 />
               </figure>
-              <AccountCircle :size="32" />
+              <AccountCircle v-else :size="32" />
             </button>
           </template>
 
           <!-- Dropdown menu -->
           <div
-            class="z-50 mt-4 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600"
+            class="z-50 text-base list-none bg-white rounded divide-y divide-gray-100 dark:bg-zinc-700 dark:divide-gray-600 max-w-xs"
             position="bottom-left"
           >
             <o-dropdown-item aria-role="listitem">
-              <div class="">
-                <span class="block text-sm text-gray-900 dark:text-white">{{
+              <div class="px-4">
+                <span class="block text-sm text-zinc-900 dark:text-white">{{
                   displayName(currentActor)
                 }}</span>
                 <span
-                  class="block text-sm font-medium text-gray-500 truncate dark:text-gray-400"
-                  >{{ currentUser?.role }}</span
+                  class="block text-sm font-medium text-zinc-500 truncate dark:text-zinc-400"
+                  v-if="currentUser?.role === ICurrentUserRole.ADMINISTRATOR"
+                  >{{ t("Administrator") }}</span
                 >
+                <span
+                  class="block text-sm font-medium text-zinc-500 truncate dark:text-zinc-400"
+                  v-if="currentUser?.role === ICurrentUserRole.MODERATOR"
+                  >{{ t("Moderator") }}</span
+                >
+              </div>
+            </o-dropdown-item>
+            <o-dropdown-item
+              v-for="identity in identities"
+              :active="identity.id === currentActor.id"
+              :key="identity.id"
+              tabindex="0"
+              @click="
+                setIdentity({
+                  preferredUsername: identity.preferredUsername,
+                })
+              "
+              @keyup.enter="
+                setIdentity({
+                  preferredUsername: identity.preferredUsername,
+                })
+              "
+            >
+              <div class="flex gap-1 items-center">
+                <div class="flex-none">
+                  <figure class="" v-if="identity.avatar">
+                    <img
+                      class="rounded-full h-8 w-8"
+                      loading="lazy"
+                      :src="identity.avatar.url"
+                      alt=""
+                      height="32"
+                      width="32"
+                    />
+                  </figure>
+                  <AccountCircle v-else :size="32" />
+                </div>
+
+                <div
+                  class="text-base text-zinc-700 dark:text-zinc-100 flex flex-col flex-auto overflow-hidden items-start"
+                >
+                  <p class="truncate">{{ displayName(identity) }}</p>
+                  <p class="truncate text-sm" v-if="identity.name">
+                    @{{ identity.preferredUsername }}
+                  </p>
+                </div>
               </div>
             </o-dropdown-item>
             <o-dropdown-item
@@ -49,7 +96,7 @@
               :to="{ name: RouteName.SETTINGS }"
             >
               <span
-                class="block py-2 px-4 text-sm text-gray-700 dark:text-gray-200 dark:hover:text-white"
+                class="block py-2 px-4 text-sm text-zinc-700 dark:text-zinc-200 dark:hover:text-white"
                 >{{ t("My account") }}</span
               >
             </o-dropdown-item>
@@ -60,7 +107,7 @@
               :to="{ name: RouteName.ADMIN_DASHBOARD }"
             >
               <span
-                class="block py-2 px-4 text-sm text-gray-700 dark:text-gray-200 dark:hover:text-white"
+                class="block py-2 px-4 text-sm text-zinc-700 dark:text-zinc-200 dark:hover:text-white"
                 >{{ t("Administration") }}</span
               >
             </o-dropdown-item>
@@ -70,7 +117,7 @@
               @keyup.enter="logout"
             >
               <span
-                class="block py-2 px-4 text-sm text-gray-700 dark:text-gray-200 dark:hover:text-white"
+                class="block py-2 px-4 text-sm text-zinc-700 dark:text-zinc-200 dark:hover:text-white"
                 >{{ t("Log out") }}</span
               >
             </o-dropdown-item>
@@ -80,7 +127,7 @@
       <button
         @click="showMobileMenu = !showMobileMenu"
         type="button"
-        class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
+        class="inline-flex items-center p-2 ml-1 text-sm text-zinc-500 rounded-lg md:hidden hover:bg-zinc-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:focus:ring-gray-600"
         aria-controls="mobile-menu-2"
         aria-expanded="false"
       >
@@ -105,33 +152,33 @@
         :class="{ hidden: !showMobileMenu }"
       >
         <ul
-          class="flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:text-sm md:font-medium"
+          class="flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:font-lightbold"
         >
           <li v-if="currentActor?.id">
             <router-link
               :to="{ name: RouteName.MY_EVENTS }"
-              class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
+              class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
               >{{ t("My events") }}</router-link
             >
           </li>
           <li v-if="currentActor?.id">
             <router-link
               :to="{ name: RouteName.MY_GROUPS }"
-              class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
+              class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
               >{{ t("My groups") }}</router-link
             >
           </li>
           <li v-if="!currentActor?.id">
             <router-link
               :to="{ name: RouteName.LOGIN }"
-              class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
+              class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
               >{{ t("Login") }}</router-link
             >
           </li>
           <li v-if="!currentActor?.id">
             <router-link
               :to="{ name: RouteName.REGISTER }"
-              class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
+              class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
               >{{ t("Register") }}</router-link
             >
           </li>
@@ -327,6 +374,9 @@ import {
   useCurrentActorClient,
   useCurrentUserIdentities,
 } from "@/composition/apollo/actor";
+import { useMutation } from "@vue/apollo-composable";
+import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
+import { changeIdentity } from "@/utils/identity";
 // import { useRestrictions } from "@/composition/apollo/config";
 
 const { currentUser } = useCurrentUserClient();
@@ -400,11 +450,17 @@ watch(identities, () => {
 //   await router.push({ name: RouteName.HOME });
 // };
 
-// const { onDone, mutate: setIdentity } = useMutation(UPDATE_DEFAULT_ACTOR);
+const { onDone, mutate: setIdentity } = useMutation<{
+  changeDefaultActor: { id: string; defaultActor: { id: string } };
+}>(UPDATE_DEFAULT_ACTOR);
 
-// onDone(() => {
-//   changeIdentity(identity);
-// });
+onDone(({ data }) => {
+  const identity = identities.value?.find(
+    ({ id }) => id === data?.changeDefaultActor?.defaultActor?.id
+  );
+  if (!identity) return;
+  changeIdentity(identity);
+});
 
 // const hideCreateEventsButton = computed((): boolean => {
 //   return !!restrictions.value?.onlyGroupsCanCreateEvents;
diff --git a/js/src/components/Footer.vue b/js/src/components/PageFooter.vue
similarity index 100%
rename from js/src/components/Footer.vue
rename to js/src/components/PageFooter.vue
diff --git a/js/src/components/Participation/ConfirmParticipation.vue b/js/src/components/Participation/ConfirmParticipation.vue
index a53ced696..935daad0a 100644
--- a/js/src/components/Participation/ConfirmParticipation.vue
+++ b/js/src/components/Participation/ConfirmParticipation.vue
@@ -1,16 +1,16 @@
 <template>
   <section class="container mx-auto">
     <h1 class="title" v-if="loading">
-      {{ $t("Your participation request is being validated") }}
+      {{ t("Your participation request is being validated") }}
     </h1>
     <div v-else>
       <div v-if="failed && participation === undefined">
         <o-notification
-          :title="$t('Error while validating participation request')"
+          :title="t('Error while validating participation request')"
           variant="danger"
         >
           {{
-            $t(
+            t(
               "Either the participation request has already been validated, either the validation token is incorrect."
             )
           }}
@@ -18,27 +18,25 @@
       </div>
       <div v-else>
         <h1 class="title">
-          {{ $t("Your participation request has been validated") }}
+          {{ t("Your participation request has been validated") }}
         </h1>
         <p
           class="prose dark:prose-invert"
           v-if="participation?.event.joinOptions == EventJoinOptions.RESTRICTED"
         >
           {{
-            $t("Your participation still has to be approved by the organisers.")
+            t("Your participation still has to be approved by the organisers.")
           }}
         </p>
         <div v-if="failed">
           <o-notification
             :title="
-              $t(
-                'Error while updating participation status inside this browser'
-              )
+              t('Error while updating participation status inside this browser')
             "
             variant="warning"
           >
             {{
-              $t(
+              t(
                 "We couldn't save your participation inside this browser. Not to worry, you have successfully confirmed your participation, we just couldn't save it's status in this browser because of a technical issue."
               )
             }}
@@ -46,15 +44,15 @@
         </div>
         <div class="columns has-text-centered">
           <div class="column">
-            <router-link
-              native-type="button"
-              tag="a"
-              class="button is-primary is-large"
+            <o-button
+              tag="router-link"
+              variant="primary"
+              size="large"
               :to="{
                 name: RouteName.EVENT,
                 params: { uuid: participation?.event.uuid },
               }"
-              >{{ $t("Go to the event page") }}</router-link
+              >{{ t("Go to the event page") }}</o-button
             >
           </div>
         </div>
diff --git a/js/src/components/Participation/ParticipationSection.vue b/js/src/components/Participation/ParticipationSection.vue
index 5dceeeb5b..a0ef1d248 100644
--- a/js/src/components/Participation/ParticipationSection.vue
+++ b/js/src/components/Participation/ParticipationSection.vue
@@ -26,10 +26,7 @@
           <template #popper>
             {{ t("Click for more information") }}
           </template>
-          <span
-            class="is-clickable"
-            @click="isAnonymousParticipationModalOpen = true"
-          >
+          <span @click="isAnonymousParticipationModalOpen = true">
             <InformationOutline :size="16" />
           </span>
         </VTooltip>
@@ -102,7 +99,8 @@
           </p>
           <div class="buttons" v-if="isSecureContext()">
             <o-button
-              type="is-danger is-outlined"
+              variant="danger"
+              outlined
               @click="clearEventParticipationData"
             >
               {{ t("Clear participation data for this event") }}
@@ -197,7 +195,7 @@ const isEventNotAlreadyPassed = computed((): boolean => {
   return new Date(endDate.value) > new Date();
 });
 
-const endDate = computed((): Date => {
+const endDate = computed((): string => {
   return props.event.endsOn !== null &&
     props.event.endsOn > props.event.beginsOn
     ? props.event.endsOn
diff --git a/js/src/components/Participation/UnloggedParticipation.vue b/js/src/components/Participation/UnloggedParticipation.vue
index e04dd36dc..be78ce52b 100644
--- a/js/src/components/Participation/UnloggedParticipation.vue
+++ b/js/src/components/Participation/UnloggedParticipation.vue
@@ -33,7 +33,7 @@
                 }}
               </small>
               <o-tooltip
-                type="is-dark"
+                variant="dark"
                 :label="
                   $t(
                     'Mobilizon is a federated network. You can interact with this event from a different server.'
@@ -90,7 +90,7 @@
           </div>
         </div>
         <div class="has-text-centered">
-          <o-button tag="a" type="is-text" @click="router.go(-1)">{{
+          <o-button tag="a" variant="text" @click="router.go(-1)">{{
             $t("Back to previous page")
           }}</o-button>
         </div>
diff --git a/js/src/components/PictureUpload.vue b/js/src/components/PictureUpload.vue
index f15f20c4f..56e8a5179 100644
--- a/js/src/components/PictureUpload.vue
+++ b/js/src/components/PictureUpload.vue
@@ -41,7 +41,7 @@
         </o-upload>
       </o-field>
       <o-button
-        type="is-text"
+        variant="text"
         v-if="imageSrc"
         @click="removeOrClearPicture"
         @keyup.enter="removeOrClearPicture"
diff --git a/js/src/components/Report/ReportCard.vue b/js/src/components/Report/ReportCard.vue
index 07d331d70..136493bd0 100644
--- a/js/src/components/Report/ReportCard.vue
+++ b/js/src/components/Report/ReportCard.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="" v-if="report">
+  <div class="dark:bg-zinc-700 p-2 rounded" v-if="report">
     <div class="flex gap-1">
       <figure class="" v-if="report.reported.avatar">
         <img
diff --git a/js/src/components/Resource/FolderItem.vue b/js/src/components/Resource/FolderItem.vue
index 82cc0af5a..a28695060 100644
--- a/js/src/components/Resource/FolderItem.vue
+++ b/js/src/components/Resource/FolderItem.vue
@@ -9,7 +9,7 @@
         },
       }"
     >
-      <div class="preview">
+      <div class="preview text-mbz-purple dark:text-mbz-purple-300">
         <Folder :size="48" />
       </div>
       <div class="body">
@@ -39,7 +39,7 @@
 </template>
 <script lang="ts" setup>
 import { useRouter } from "vue-router";
-import Draggable, { ChangeEvent } from "@xiaoshuapp/draggable";
+// import Draggable, { ChangeEvent } from "@xiaoshuapp/draggable";
 // import { SnackbarProgrammatic as Snackbar } from "buefy";
 import { IResource } from "@/types/resource";
 import RouteName from "@/router/name";
@@ -110,8 +110,8 @@ onMovedResource(({ data }) => {
 onMovedResourceError((e) => {
   // Snackbar.open({
   //   message: e.message,
-  //   type: "is-danger",
-  //   position: "is-bottom",
+  //   variant: "danger",
+  //   position: "bottom",
   // });
   return undefined;
 });
diff --git a/js/src/components/Resource/ResourceItem.vue b/js/src/components/Resource/ResourceItem.vue
index 856732910..c5c5a5193 100644
--- a/js/src/components/Resource/ResourceItem.vue
+++ b/js/src/components/Resource/ResourceItem.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="flex flex-1 items-center w-full" dir="auto">
     <a :href="resource.resourceUrl" target="_blank">
-      <div class="preview">
+      <div class="preview text-mbz-purple dark:text-mbz-purple-300">
         <div
           v-if="
             resource.type &&
@@ -79,7 +79,7 @@ const emit = defineEmits<{
   (e: "delete", resourceID: string): void;
 }>();
 
-const list = ref([]);
+// const list = ref([]);
 
 const urlHostname = computed((): string | undefined => {
   if (props.resource?.resourceUrl) {
diff --git a/js/src/components/Resource/ResourceSelector.vue b/js/src/components/Resource/ResourceSelector.vue
index 4691dd76f..9c6c4ecc2 100644
--- a/js/src/components/Resource/ResourceSelector.vue
+++ b/js/src/components/Resource/ResourceSelector.vue
@@ -69,7 +69,7 @@
       />
     </article>
     <div class="flex gap-2 mt-2">
-      <o-button type="is-text" @click="emit('close-move-modal')">{{
+      <o-button variant="text" @click="emit('close-move-modal')">{{
         $t("Cancel")
       }}</o-button>
       <o-button
diff --git a/js/src/components/Settings/NotificationsOnboarding.vue b/js/src/components/Settings/NotificationsOnboarding.vue
index 873bf2176..cf4acc01c 100644
--- a/js/src/components/Settings/NotificationsOnboarding.vue
+++ b/js/src/components/Settings/NotificationsOnboarding.vue
@@ -56,8 +56,8 @@ const updateSetting = async (
   } catch (e: any) {
     // Snackbar.open({
     //   message: e.message,
-    //   type: "is-danger",
-    //   position: "is-bottom",
+    //   variant: "danger",
+    //   position: "bottom",
     // });
   }
 };
diff --git a/js/src/components/Settings/SettingMenuItem.vue b/js/src/components/Settings/SettingMenuItem.vue
index 6cb113e14..0d63caa32 100644
--- a/js/src/components/Settings/SettingMenuItem.vue
+++ b/js/src/components/Settings/SettingMenuItem.vue
@@ -1,5 +1,12 @@
 <template>
-  <li class="setting-menu-item" :class="{ active: isActive }">
+  <li
+    class="setting-menu-item"
+    :class="{
+      'cursor-pointer bg-mbz-yellow-alt-500 dark:bg-mbz-purple-500': isActive,
+      'bg-mbz-yellow-alt-100 hover:bg-mbz-yellow-alt-200 dark:bg-mbz-purple-300 dark:hover:bg-mbz-purple-400 dark:text-white':
+        !isActive,
+    }"
+  >
     <router-link v-if="to" :to="to">
       <span>{{ title }}</span>
     </router-link>
@@ -31,7 +38,7 @@ const isActive = computed((): boolean => {
 <style lang="scss" scoped>
 li.setting-menu-item {
   font-size: 1.05rem;
-  background-color: #fff1de;
+  // background-color: #fff1de;
   margin: auto;
 
   span {
@@ -47,7 +54,7 @@ li.setting-menu-item {
   &:hover,
   &.active {
     cursor: pointer;
-    background-color: lighten(#fea72b, 10%);
+    // background-color: lighten(#fea72b, 10%);
   }
 }
 </style>
diff --git a/js/src/components/Settings/SettingMenuSection.vue b/js/src/components/Settings/SettingMenuSection.vue
index 091f45be5..173839567 100644
--- a/js/src/components/Settings/SettingMenuSection.vue
+++ b/js/src/components/Settings/SettingMenuSection.vue
@@ -1,5 +1,7 @@
 <template>
-  <li class="bg-yellow-1 text-violet-2 text-xl">
+  <li
+    class="bg-mbz-yellow-alt-300 text-violet-2 dark:bg-mbz-purple-500 dark:text-zinc-100 text-xl"
+  >
     <router-link
       class="cursor-pointer my-2 mx-0 py-2 px-3 font-medium block no-underline"
       v-if="to"
diff --git a/js/src/components/Settings/SettingsMenu.vue b/js/src/components/Settings/SettingsMenu.vue
index d98cea4d8..90e3ecbb9 100644
--- a/js/src/components/Settings/SettingsMenu.vue
+++ b/js/src/components/Settings/SettingsMenu.vue
@@ -1,5 +1,5 @@
 <template>
-  <aside>
+  <aside class="mb-6">
     <ul>
       <SettingMenuSection
         :title="t('Account')"
diff --git a/js/src/components/Share/DiasporaLogo.vue b/js/src/components/Share/DiasporaLogo.vue
index bf3e0d3e8..a04c0959a 100644
--- a/js/src/components/Share/DiasporaLogo.vue
+++ b/js/src/components/Share/DiasporaLogo.vue
@@ -1,5 +1,5 @@
 <template>
-  <span class="icon has-text-primary is-large">
+  <span class="text-black dark:text-white dark:fill-white">
     <svg
       version="1.1"
       viewBox="0 0 65.131 65.131"
@@ -12,7 +12,7 @@
       />
       <path
         d="m23.631 51.953c-2.348-1.5418-6.9154-5.1737-7.0535-5.6088-0.06717-0.21164 0.45125-0.99318 3.3654-5.0734 2.269-3.177 3.7767-5.3581 3.7767-5.4637 0-0.03748-1.6061-0.60338-3.5691-1.2576-6.1342-2.0442-8.3916-2.9087-8.5288-3.2663-0.03264-0.08506 0.09511-0.68598 0.28388-1.3354 0.643-2.212 2.7038-8.4123 2.7959-8.4123 0.05052 0 2.6821 0.85982 5.848 1.9107 3.1659 1.0509 5.897 1.9222 6.0692 1.9362 0.3089 0.02514 0.31402 0.01925 0.38295-0.44107 0.09851-0.65784 0.26289-5.0029 0.2633-6.9599 1.87e-4 -0.90267 0.02801-2.5298 0.06184-3.6158l0.0615-1.9746h10.392l0.06492 4.4556c0.06287 4.3148 0.18835 7.8236 0.29865 8.3513 0.0295 0.14113 0.11236 0.2566 0.18412 0.2566 0.07176 0 1.6955-0.50861 3.6084-1.1303 4.5213-1.4693 6.2537-2.0038 7.3969-2.2822 0.87349-0.21269 0.94061-0.21704 1.0505-0.06806 0.45169 0.61222 3.3677 9.2365 3.1792 9.4025-0.33681 0.29628-2.492 1.1048-6.9823 2.6194-5.3005 1.7879-5.1321 1.7279-5.1321 1.8283 0 0.13754 0.95042 1.522 3.5468 5.1666 1.3162 1.8475 2.6802 3.7905 3.0311 4.3176l0.63804 0.95842-0.27216 0.28519c-1.1112 1.1644-7.3886 5.8693-7.8309 5.8693-0.22379 0-1.2647-1.2321-2.9284-3.4663-0.90374-1.2137-2.264-3.0402-3.0228-4.059-0.75878-1.0188-1.529-2.0203-1.7116-2.2256l-0.33201-0.37324-0.32674 0.37324c-0.43918 0.50169-2.226 2.867-3.8064 5.0388-2.1662 2.9767-3.6326 4.8055-3.8532 4.8055-0.05161 0-0.4788-0.25278-0.94931-0.56173z"
-        fill="#fff"
+        fill="transparent"
         stroke-width=".093311"
       />
     </svg>
diff --git a/js/src/components/Share/MastodonLogo.vue b/js/src/components/Share/MastodonLogo.vue
index 8c35f12fd..400985264 100644
--- a/js/src/components/Share/MastodonLogo.vue
+++ b/js/src/components/Share/MastodonLogo.vue
@@ -1,5 +1,5 @@
 <template>
-  <span class="icon has-text-primary is-large">
+  <span class="text-primary dark:text-white">
     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976">
       <title>Mastodon logo</title>
       <path
diff --git a/js/src/components/Share/ShareModal.vue b/js/src/components/Share/ShareModal.vue
index 03556d426..8c7efa1b9 100644
--- a/js/src/components/Share/ShareModal.vue
+++ b/js/src/components/Share/ShareModal.vue
@@ -5,13 +5,13 @@
     </header>
 
     <section class="flex">
-      <div class="">
+      <div class="w-full">
         <slot></slot>
         <o-field :label="inputLabel" label-for="url-text">
           <o-input id="url-text" ref="URLInput" :modelValue="url" expanded />
           <p class="control">
             <o-tooltip
-              :label="$t('URL copied to clipboard')"
+              :label="t('URL copied to clipboard')"
               :active="showCopiedTooltip"
               always
               variant="success"
@@ -23,7 +23,7 @@
                 native-type="button"
                 @click="copyURL"
                 @keyup.enter="copyURL"
-                :title="$t('Copy URL to clipboard')"
+                :title="t('Copy URL to clipboard')"
               />
             </o-tooltip>
           </p>
@@ -34,7 +34,7 @@
             target="_blank"
             rel="nofollow noopener"
             title="Twitter"
-            ><Twitter :size="48"
+            ><Twitter :size="48" class="dark:text-white"
           /></a>
           <a
             :href="mastodonShare"
@@ -50,14 +50,14 @@
             target="_blank"
             rel="nofollow noopener"
             title="Facebook"
-            ><Facebook :size="48"
+            ><Facebook :size="48" class="dark:text-white"
           /></a>
           <a
             :href="whatsAppShare"
             target="_blank"
             rel="nofollow noopener"
             title="WhatsApp"
-            ><Whatsapp :size="48"
+            ><Whatsapp :size="48" class="dark:text-white"
           /></a>
           <a
             :href="telegramShare"
@@ -73,7 +73,7 @@
             target="_blank"
             rel="nofollow noopener"
             title="LinkedIn"
-            ><LinkedIn :size="48"
+            ><LinkedIn :size="48" class="dark:text-white"
           /></a>
           <a
             :href="diasporaShare"
@@ -90,7 +90,7 @@
             rel="nofollow noopener"
             title="Email"
           >
-            <Email :size="48" />
+            <Email :size="48" class="dark:text-white" />
           </a>
         </div>
       </div>
@@ -118,6 +118,7 @@ import {
   twitterShareUrl,
   whatsAppShareUrl,
 } from "@/utils/share";
+import { useI18n } from "vue-i18n";
 
 const props = withDefaults(
   defineProps<{
@@ -129,7 +130,9 @@ const props = withDefaults(
   {}
 );
 
-const URLInput = ref<HTMLElement | null>(null);
+const { t } = useI18n({ useScope: "global" });
+
+const URLInput = ref<{ $refs: { input: HTMLInputElement } } | null>(null);
 
 const showCopiedTooltip = ref(false);
 
@@ -159,7 +162,6 @@ const mastodonShare = computed((): string | undefined =>
 );
 
 const copyURL = (): void => {
-  console.log("URLInput", URLInput.value);
   URLInput.value?.$refs.input.select();
   document.execCommand("copy");
   showCopiedTooltip.value = true;
diff --git a/js/src/components/Share/TelegramLogo.vue b/js/src/components/Share/TelegramLogo.vue
index 493eba846..aed851fe3 100644
--- a/js/src/components/Share/TelegramLogo.vue
+++ b/js/src/components/Share/TelegramLogo.vue
@@ -1,5 +1,5 @@
 <template>
-  <span class="icon has-text-primary is-large">
+  <span class="text-primary dark:text-white dark:fill-white">
     <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
       <title>Telegram</title>
       <path
diff --git a/js/src/components/Tag.vue b/js/src/components/Tag.vue
index 5b8656180..cde862ae6 100644
--- a/js/src/components/Tag.vue
+++ b/js/src/components/Tag.vue
@@ -1,7 +1,11 @@
 <template>
   <span
     class="rounded-md my-1 truncate text-sm text-violet-title px-2 py-1"
-    :class="[typeClasses, capitalize]"
+    :class="[
+      typeClasses,
+      capitalize,
+      withHashTag ? `before:content-['#']` : '',
+    ]"
   >
     <slot />
   </span>
@@ -11,10 +15,11 @@ import { computed } from "vue";
 
 const props = withDefaults(
   defineProps<{
-    variant?: "info" | "danger" | "warning" | "light";
-    capitalize: boolean;
+    variant?: "info" | "danger" | "warning" | "light" | "primary";
+    capitalize?: boolean;
+    withHashTag?: boolean;
   }>(),
-  { variant: "light", capitalize: false }
+  { variant: "light", capitalize: false, withHashTag: false }
 );
 
 const typeClasses = computed(() => {
@@ -23,7 +28,7 @@ const typeClasses = computed(() => {
     case "light":
       return "bg-purple-3 dark:text-violet-3";
     case "info":
-      return "bg-mbz-info dark:text-white";
+      return "bg-mbz-info dark:text-black";
     case "warning":
       return "bg-yellow-1";
     case "danger":
@@ -33,9 +38,7 @@ const typeClasses = computed(() => {
 </script>
 
 <style lang="scss" scoped>
-span.tag {
-  &:not(.category)::before {
-    content: "#";
-  }
+span.withHashTag::before {
+  content: "#";
 }
 </style>
diff --git a/js/src/components/Editor.vue b/js/src/components/TextEditor.vue
similarity index 84%
rename from js/src/components/Editor.vue
rename to js/src/components/TextEditor.vue
index 51d0cf419..2a91c2cdb 100644
--- a/js/src/components/Editor.vue
+++ b/js/src/components/TextEditor.vue
@@ -7,7 +7,7 @@
       :data-actor-id="currentActor && currentActor.id"
     >
       <div
-        class="menubar bar-is-hidden"
+        class="mb-2 menubar bar-is-hidden"
         v-if="isDescriptionMode"
         :editor="editor"
       >
@@ -16,9 +16,9 @@
           :class="{ 'is-active': editor.isActive('bold') }"
           @click="editor?.chain().focus().toggleBold().run()"
           type="button"
-          :title="$t('Bold')"
+          :title="t('Bold')"
         >
-          <o-icon icon="format-bold" />
+          <FormatBold :size="24" />
         </button>
 
         <button
@@ -26,9 +26,9 @@
           :class="{ 'is-active': editor.isActive('italic') }"
           @click="editor?.chain().focus().toggleItalic().run()"
           type="button"
-          :title="$t('Italic')"
+          :title="t('Italic')"
         >
-          <o-icon icon="format-italic" />
+          <FormatItalic :size="24" />
         </button>
 
         <button
@@ -36,9 +36,9 @@
           :class="{ 'is-active': editor.isActive('underline') }"
           @click="editor?.chain().focus().toggleUnderline().run()"
           type="button"
-          :title="$t('Underline')"
+          :title="t('Underline')"
         >
-          <o-icon icon="format-underline" />
+          <FormatUnderline :size="24" />
         </button>
 
         <button
@@ -47,9 +47,9 @@
           :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
           @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
           type="button"
-          :title="$t('Heading Level 1')"
+          :title="t('Heading Level 1')"
         >
-          <o-icon icon="format-header-1" />
+          <FormatHeader1 :size="24" />
         </button>
 
         <button
@@ -58,9 +58,9 @@
           :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
           @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
           type="button"
-          :title="$t('Heading Level 2')"
+          :title="t('Heading Level 2')"
         >
-          <o-icon icon="format-header-2" />
+          <FormatHeader2 :size="24" />
         </button>
 
         <button
@@ -69,9 +69,9 @@
           :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
           @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
           type="button"
-          :title="$t('Heading Level 3')"
+          :title="t('Heading Level 3')"
         >
-          <o-icon icon="format-header-3" />
+          <FormatHeader3 :size="24" />
         </button>
 
         <button
@@ -79,9 +79,9 @@
           @click="showLinkMenu()"
           :class="{ 'is-active': editor.isActive('link') }"
           type="button"
-          :title="$t('Add link')"
+          :title="t('Add link')"
         >
-          <o-icon icon="link" />
+          <LinkIcon :size="24" />
         </button>
 
         <button
@@ -89,9 +89,9 @@
           class="menubar__button"
           @click="editor?.chain().focus().unsetLink().run()"
           type="button"
-          :title="$t('Remove link')"
+          :title="t('Remove link')"
         >
-          <o-icon icon="link-off" />
+          <LinkOff :size="24" />
         </button>
 
         <button
@@ -99,9 +99,9 @@
           v-if="!isBasicMode"
           @click="showImagePrompt()"
           type="button"
-          :title="$t('Add picture')"
+          :title="t('Add picture')"
         >
-          <o-icon icon="image" />
+          <Image :size="24" />
         </button>
 
         <button
@@ -110,9 +110,9 @@
           :class="{ 'is-active': editor.isActive('bulletList') }"
           @click="editor?.chain().focus().toggleBulletList().run()"
           type="button"
-          :title="$t('Bullet list')"
+          :title="t('Bullet list')"
         >
-          <o-icon icon="format-list-bulleted" />
+          <FormatListBulleted :size="24" />
         </button>
 
         <button
@@ -121,9 +121,9 @@
           :class="{ 'is-active': editor.isActive('orderedList') }"
           @click="editor?.chain().focus().toggleOrderedList().run()"
           type="button"
-          :title="$t('Ordered list')"
+          :title="t('Ordered list')"
         >
-          <o-icon icon="format-list-numbered" />
+          <FormatListNumbered :size="24" />
         </button>
 
         <button
@@ -132,9 +132,9 @@
           :class="{ 'is-active': editor.isActive('blockquote') }"
           @click="editor?.chain().focus().toggleBlockquote().run()"
           type="button"
-          :title="$t('Quote')"
+          :title="t('Quote')"
         >
-          <o-icon icon="format-quote-close" />
+          <FormatQuoteClose :size="24" />
         </button>
 
         <button
@@ -142,9 +142,9 @@
           class="menubar__button"
           @click="editor?.chain().focus().undo().run()"
           type="button"
-          :title="$t('Undo')"
+          :title="t('Undo')"
         >
-          <o-icon icon="undo" />
+          <Undo :size="24" />
         </button>
 
         <button
@@ -152,9 +152,9 @@
           class="menubar__button"
           @click="editor?.chain().focus().redo().run()"
           type="button"
-          :title="$t('Redo')"
+          :title="t('Redo')"
         >
-          <o-icon icon="redo" />
+          <Redo :size="24" />
         </button>
       </div>
 
@@ -169,10 +169,10 @@
           :class="{ 'is-active': editor.isActive('bold') }"
           @click="editor?.chain().focus().toggleBold().run()"
           type="button"
-          :title="$t('Bold')"
+          :title="t('Bold')"
         >
-          <o-icon icon="format-bold" />
-          <span class="visually-hidden">{{ $t("Bold") }}</span>
+          <FormatBold :size="24" />
+          <span class="visually-hidden">{{ t("Bold") }}</span>
         </button>
 
         <button
@@ -180,10 +180,10 @@
           :class="{ 'is-active': editor.isActive('italic') }"
           @click="editor?.chain().focus().toggleItalic().run()"
           type="button"
-          :title="$t('Italic')"
+          :title="t('Italic')"
         >
-          <o-icon icon="format-italic" />
-          <span class="visually-hidden">{{ $t("Italic") }}</span>
+          <FormatItalic :size="24" />
+          <span class="visually-hidden">{{ t("Italic") }}</span>
         </button>
       </bubble-menu>
 
@@ -223,6 +223,20 @@ import { Dialog } from "@/plugins/dialog";
 import { useI18n } from "vue-i18n";
 import { useMutation } from "@vue/apollo-composable";
 import { Notifier } from "@/plugins/notifier";
+import FormatBold from "vue-material-design-icons/FormatBold.vue";
+import FormatItalic from "vue-material-design-icons/FormatItalic.vue";
+import FormatUnderline from "vue-material-design-icons/FormatUnderline.vue";
+import FormatHeader1 from "vue-material-design-icons/FormatHeader1.vue";
+import FormatHeader2 from "vue-material-design-icons/FormatHeader2.vue";
+import FormatHeader3 from "vue-material-design-icons/FormatHeader3.vue";
+import LinkIcon from "vue-material-design-icons/Link.vue";
+import LinkOff from "vue-material-design-icons/LinkOff.vue";
+import Image from "vue-material-design-icons/Image.vue";
+import FormatListBulleted from "vue-material-design-icons/FormatListBulleted.vue";
+import FormatListNumbered from "vue-material-design-icons/FormatListNumbered.vue";
+import FormatQuoteClose from "vue-material-design-icons/FormatQuoteClose.vue";
+import Undo from "vue-material-design-icons/Undo.vue";
+import Redo from "vue-material-design-icons/Redo.vue";
 
 const props = withDefaults(
   defineProps<{
@@ -259,7 +273,7 @@ const isBasicMode = computed((): boolean => {
 });
 
 const insertMention = (obj: { range: any; attrs: any }) => {
-  console.log("initialize Mention");
+  console.debug("initialize Mention");
 };
 
 const observer = ref<MutationObserver | null>(null);
@@ -421,7 +435,6 @@ onBeforeUnmount(() => {
 @import "./Editor/style.scss";
 
 .menubar {
-  margin-bottom: 1rem;
   transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
 
   &__button {
diff --git a/js/src/components/Todo/CompactTodo.vue b/js/src/components/Todo/CompactTodo.vue
index d7a2c334c..e91dec72f 100644
--- a/js/src/components/Todo/CompactTodo.vue
+++ b/js/src/components/Todo/CompactTodo.vue
@@ -55,8 +55,8 @@ onDone(() => {
 onError((e) => {
   // Snackbar.open({
   //   message: e.message,
-  //   type: "is-danger",
-  //   position: "is-bottom",
+  //   variant: "danger",
+  //   position: "bottom",
   // });
 });
 </script>
diff --git a/js/src/components/Todo/FullTodo.vue b/js/src/components/Todo/FullTodo.vue
index cf94ee1ff..ca8295c76 100644
--- a/js/src/components/Todo/FullTodo.vue
+++ b/js/src/components/Todo/FullTodo.vue
@@ -85,7 +85,7 @@ updateTodoError((e) => {
   snackbar?.open({
     message: e.message,
     variant: "danger",
-    position: "is-bottom",
+    position: "bottom",
   });
 });
 
diff --git a/js/src/components/Utils/Breadcrumbs.vue b/js/src/components/Utils/NavBreadcrumbs.vue
similarity index 94%
rename from js/src/components/Utils/Breadcrumbs.vue
rename to js/src/components/Utils/NavBreadcrumbs.vue
index 29af21881..616edc040 100644
--- a/js/src/components/Utils/Breadcrumbs.vue
+++ b/js/src/components/Utils/NavBreadcrumbs.vue
@@ -1,5 +1,5 @@
 <template>
-  <nav class="flex mb-3" :aria-label="$t('Breadcrumbs')">
+  <nav class="flex mb-3" :aria-label="t('Breadcrumbs')">
     <ol class="inline-flex items-center space-x-1 md:space-x-3 flex-wrap">
       <li
         class="inline-flex items-center"
@@ -57,6 +57,7 @@
   </nav>
 </template>
 <script lang="ts" setup>
+import { useI18n } from "vue-i18n";
 import { RouteLocationRaw } from "vue-router";
 
 type LinkElement = RouteLocationRaw & { text: string };
@@ -64,4 +65,6 @@ type LinkElement = RouteLocationRaw & { text: string };
 defineProps<{
   links: LinkElement[];
 }>();
+
+const { t } = useI18n({ useScope: "global" });
 </script>
diff --git a/js/src/components/Utils/RedirectWithAccount.vue b/js/src/components/Utils/RedirectWithAccount.vue
index 00e78a4b4..755744db7 100644
--- a/js/src/components/Utils/RedirectWithAccount.vue
+++ b/js/src/components/Utils/RedirectWithAccount.vue
@@ -6,7 +6,7 @@
           <div class="column has-text-centered">
             <o-button
               variant="primary"
-              size="is-medium"
+              size="medium"
               tag="router-link"
               :to="{
                 name: RouteName.LOGIN,
@@ -46,7 +46,7 @@
           </div>
         </div>
         <div class="has-text-centered">
-          <o-button tag="a" type="is-text" @click="$router.go(-1)">{{
+          <o-button tag="a" variant="text" @click="$router.go(-1)">{{
             $t("Back to previous page")
           }}</o-button>
         </div>
@@ -74,9 +74,9 @@ const host = computed((): string => {
 });
 
 const redirectToInstance = async (): Promise<void> => {
-  const [, host] = remoteActorAddress.value.split("@", 2);
+  const [, hostname] = remoteActorAddress.value.split("@", 2);
   const remoteInteractionURI = await webFingerFetch(
-    host,
+    hostname,
     remoteActorAddress.value
   );
   window.open(remoteInteractionURI);
diff --git a/js/src/components/core/Button.vue b/js/src/components/core/Button.vue
deleted file mode 100644
index 895c8bc72..000000000
--- a/js/src/components/core/Button.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<template>
-  <component
-    :is="computedTag"
-    class="button"
-    v-bind="attrs"
-    :type="computedTag === 'button' ? nativeType : undefined"
-    :class="[
-      size,
-      type,
-      //   {
-      //     'is-rounded': rounded,
-      //     'is-loading': loading,
-      //     'is-outlined': outlined,
-      //     'is-fullwidth': expanded,
-      //     'is-inverted': inverted,
-      //     'is-focused': focused,
-      //     'is-active': active,
-      //     'is-hovered': hovered,
-      //     'is-selected': selected,
-      //   },
-    ]"
-    v-on="attrs"
-  >
-    <!-- <o-icon
-      v-if="iconLeft"
-      :pack="iconPack"
-      :icon="iconLeft"
-      :size="iconSize"
-    /> -->
-    <span v-if="label">{{ label }}</span>
-    <span v-else-if="$slots.default">
-      <slot />
-    </span>
-    <!-- <o-icon
-      v-if="iconRight"
-      :pack="iconPack"
-      :icon="iconRight"
-      :size="iconSize"
-    /> -->
-  </component>
-</template>
-
-<script lang="ts" setup>
-import { computed, useAttrs } from "vue";
-
-const props = withDefaults(
-  defineProps<{
-    type?: string;
-    size?: string;
-    label?: string;
-    nativeType?: "button" | "submit" | "reset";
-    tag?: "button" | "a" | "router-link";
-  }>(),
-  { tag: "button" }
-);
-
-const attrs = useAttrs();
-
-const computedTag = computed(() => {
-  if (attrs.disabled !== undefined && attrs.disabled !== false) {
-    return "button";
-  }
-  return props.tag;
-});
-
-const iconSize = computed(() => {
-  if (!props.size || props.size === "is-medium") {
-    return "is-small";
-  } else if (props.size === "is-large") {
-    return "is-medium";
-  }
-  return props.size;
-});
-</script>
diff --git a/js/src/components/core/Dialog.vue b/js/src/components/core/CustomDialog.vue
similarity index 97%
rename from js/src/components/core/Dialog.vue
rename to js/src/components/core/CustomDialog.vue
index 3fbc828de..29765d57b 100644
--- a/js/src/components/core/Dialog.vue
+++ b/js/src/components/core/CustomDialog.vue
@@ -63,8 +63,8 @@ const props = withDefaults(
     canCancel?: boolean;
     confirmText?: string;
     cancelText?: string;
-    onConfirm: (prompt?: string) => {};
-    onCancel?: (source: string) => {};
+    onConfirm: (prompt?: string) => any;
+    onCancel?: (source: string) => any;
     ariaLabel?: string;
     ariaModal?: boolean;
     ariaRole?: string;
diff --git a/js/src/components/core/Snackbar.vue b/js/src/components/core/CustomSnackbar.vue
similarity index 100%
rename from js/src/components/core/Snackbar.vue
rename to js/src/components/core/CustomSnackbar.vue
diff --git a/js/src/components/core/Field.vue b/js/src/components/core/Field.vue
deleted file mode 100644
index d840f39e3..000000000
--- a/js/src/components/core/Field.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<template>
-  <label :for="labelFor" class="block mb-2">
-    <span class="font-bold mb-2 block">
-      {{ label }}
-    </span>
-    <slot :type="type" />
-    <template v-if="Array.isArray(message) && message.length > 0">
-      <p v-for="msg in message" :key="msg" :class="classNames">
-        {{ msg }}
-      </p>
-    </template>
-    <p v-else-if="typeof message === 'string'" :class="classNames">
-      {{ message }}
-    </p>
-  </label>
-</template>
-<script lang="ts" setup>
-import { computed } from "vue";
-
-const props = defineProps<{
-  label: string;
-  type?: string;
-  message?: string | string[];
-  labelFor?: string;
-}>();
-
-const classNames = computed(() => {
-  switch (props.type) {
-    case "is-danger":
-      return "text-red-600";
-  }
-});
-</script>
diff --git a/js/src/components/core/Input.vue b/js/src/components/core/Input.vue
deleted file mode 100644
index 88392c125..000000000
--- a/js/src/components/core/Input.vue
+++ /dev/null
@@ -1,292 +0,0 @@
-<template>
-  <div class="control" :class="rootClasses">
-    <input
-      v-if="type !== 'textarea'"
-      ref="input"
-      class="input"
-      :class="[inputClasses, customClass]"
-      :type="newType"
-      :autocomplete="autocomplete"
-      :maxlength="maxLength"
-      :value="computedValue"
-      v-bind="$attrs"
-      @input="onInput"
-      @blur="onBlur"
-      @focus="onFocus"
-    />
-
-    <textarea
-      v-else
-      ref="textarea"
-      class="textarea"
-      :class="[inputClasses, customClass]"
-      :maxlength="maxLength"
-      :value="computedValue"
-      v-bind="$attrs"
-      @input="onInput"
-      @blur="onBlur"
-      @focus="onFocus"
-    />
-
-    <!-- <o-icon
-      v-if="icon"
-      class="is-left"
-      :icon="icon"
-      :size="iconSize"
-      @click.native="emit('icon-click', $event)"
-    />
-
-    <o-icon
-      v-if="!loading && hasIconRight"
-      class="is-right"
-      :class="{ 'is-clickable': passwordReveal }"
-      :icon="rightIcon"
-      :size="iconSize"
-      :type="rightIconType"
-      both
-      @click.native="rightIconClick"
-    /> -->
-
-    <small
-      v-if="maxLength && hasCounter && type !== 'number'"
-      class="help counter"
-      :class="{ 'is-invisible': !isFocused }"
-    >
-      {{ valueLength }} / {{ maxLength }}
-    </small>
-  </div>
-</template>
-<script setup lang="ts">
-import { computed, nextTick, ref, watch } from "vue";
-
-const props = withDefaults(
-  defineProps<{
-    icon?: string;
-    modelValue: number | string;
-    size?: string;
-    type?: string;
-    passwordReveal?: boolean;
-    iconRight?: string;
-    rounded?: boolean;
-    loading?: boolean;
-    customClass?: string;
-    maxLength?: number | string;
-    hasCounter?: boolean;
-    autocomplete?: "on" | "off";
-    statusType?: string;
-  }>(),
-  {
-    type: "text",
-    rounded: false,
-    loading: false,
-    customClass: "",
-    hasCounter: false,
-    autocomplete: "on",
-  }
-);
-
-const emit = defineEmits(["update:modelValue", "icon-click", "blur", "focus"]);
-
-const newValue = ref(props.modelValue);
-const newType = ref(props.type);
-const isPasswordVisible = ref(false);
-const isValid = ref(true);
-const isFocused = ref(false);
-
-const computedValue = computed({
-  get() {
-    return newValue.value;
-  },
-  set(value) {
-    newValue.value = value;
-    emit("update:modelValue", value);
-  },
-});
-
-const rootClasses = computed(() => {
-  return [
-    iconPosition,
-    props.size,
-    // {
-    //     'is-expanded': this.expanded,
-    //     'is-loading': this.loading,
-    //     'is-clearfix': !this.hasMessage
-    // }
-  ];
-});
-const inputClasses = computed(() => {
-  return [props.statusType, props.size, { "is-rounded": props.rounded }];
-});
-
-const hasIconRight = computed(() => {
-  return (
-    props.passwordReveal || props.loading || statusTypeIcon || props.iconRight
-  );
-});
-const rightIcon = computed(() => {
-  if (props.passwordReveal) {
-    return passwordVisibleIcon;
-  } else if (props.iconRight) {
-    return props.iconRight;
-  }
-  return statusTypeIcon;
-});
-const rightIconType = computed(() => {
-  if (props.passwordReveal) {
-    return "is-primary";
-  }
-});
-/**
- * Position of the icon or if it's both sides.
- */
-const iconPosition = computed(() => {
-  let iconClasses = "";
-  if (props.icon) {
-    iconClasses += "has-icons-left ";
-  }
-  if (hasIconRight.value) {
-    iconClasses += "has-icons-right";
-  }
-  return iconClasses;
-});
-/**
- * Icon name (MDI) based on the type.
- */
-const statusTypeIcon = computed(() => {
-  switch (props.statusType) {
-    case "is-success":
-      return "check";
-    case "is-danger":
-      return "alert-circle";
-    case "is-info":
-      return "information";
-    case "is-warning":
-      return "alert";
-  }
-});
-/**
- * Current password-reveal icon name.
- */
-const passwordVisibleIcon = computed(() => {
-  return !isPasswordVisible.value ? "eye" : "eye-off";
-});
-/**
- * Get value length
- */
-const valueLength = computed(() => {
-  if (typeof computedValue.value === "string") {
-    return Array.from(computedValue.value).length;
-  } else if (typeof computedValue.value === "number") {
-    return computedValue.value.toString().length;
-  }
-  return 0;
-});
-
-/**
- * Fix icon size for inputs, large was too big
- */
-const iconSize = computed(() => {
-  switch (props.size) {
-    case "is-small":
-      return props.size;
-    case "is-medium":
-      return;
-    case "is-large":
-      return "is-medium";
-  }
-});
-
-watch(props, () => {
-  newValue.value = props.modelValue;
-});
-
-/**
- * Toggle the visibility of a password-reveal input
- * by changing the type and focus the input right away.
- */
-const togglePasswordVisibility = async () => {
-  isPasswordVisible.value = !isPasswordVisible.value;
-  newType.value = isPasswordVisible.value ? "text" : "password";
-  await nextTick();
-  await focus();
-};
-const rightIconClick = (event: Event) => {
-  if (props.passwordReveal) {
-    togglePasswordVisibility();
-  }
-};
-const onInput = (event: Event) => {
-  const value = event.target?.value;
-  updateValue(value);
-};
-const updateValue = (value: string) => {
-  computedValue.value = value;
-  !isValid.value && checkHtml5Validity();
-};
-
-/**
- * Check HTML5 validation, set isValid property.
- * If validation fail, send 'is-danger' type,
- * and error message to parent if it's a Field.
- */
-const checkHtml5Validity = () => {
-  const el = getElement();
-  if (el === undefined) return;
-
-  if (!el.value?.checkValidity()) {
-    // setInvalid();
-    isValid.value = false;
-  } else {
-    // setValidity(null, null);
-    isValid.value = true;
-  }
-
-  return isValid.value;
-};
-
-// const setInvalid = () => {
-//   let type = "is-danger";
-//   let message = validationMessage || getElement().validationMessage;
-//   setValidity(type, message);
-// };
-
-// const setValidity = async (type, message) => {
-//   await nextTick();
-//   if (this.parentField) {
-//     // Set type only if not defined
-//     if (!this.parentField.type) {
-//       this.parentField.newType = type;
-//     }
-//     // Set message only if not defined
-//     if (!this.parentField.message) {
-//       this.parentField.newMessage = message;
-//     }
-//   }
-// };
-
-const input = ref<HTMLInputElement | null>(null);
-const textarea = ref<HTMLInputElement | null>(null);
-
-const getElement = () => {
-  return props.type === "input" ? input : textarea;
-};
-
-const focus = async () => {
-  const el = getElement();
-  if (el.value === undefined) return;
-
-  await nextTick();
-  if (el.value) el.value?.focus();
-};
-
-const onBlur = ($event: FocusEvent) => {
-  isFocused.value = false;
-  emit("blur", $event);
-  checkHtml5Validity();
-};
-
-const onFocus = ($event: FocusEvent) => {
-  isFocused.value = true;
-  emit("focus", $event);
-};
-</script>
diff --git a/js/src/components/core/LinkOrRouterLink.vue b/js/src/components/core/LinkOrRouterLink.vue
new file mode 100644
index 000000000..b624fa68e
--- /dev/null
+++ b/js/src/components/core/LinkOrRouterLink.vue
@@ -0,0 +1,39 @@
+<template>
+  <a
+    v-if="isInternal"
+    :target="newTab ? '_blank' : undefined"
+    :href="href"
+    rel="noopener noreferrer"
+    v-bind="$attrs"
+  >
+    <slot />
+  </a>
+  <router-link :to="to" v-bind="$attrs" v-else>
+    <slot />
+  </router-link>
+</template>
+<script lang="ts">
+// use normal <script> to declare options
+export default {
+  inheritAttrs: false,
+};
+</script>
+<script lang="ts" setup>
+import { computed } from "vue";
+
+const props = withDefaults(
+  defineProps<{
+    to: { name: string; params?: any; query?: any } | string;
+    isInternal?: boolean;
+    newTab?: boolean;
+  }>(),
+  { isInternal: true, newTab: true }
+);
+
+const href = computed(() => {
+  if (typeof props.to === "string" || props.to instanceof String) {
+    return props.to as string;
+  }
+  return undefined;
+});
+</script>
diff --git a/js/src/components/core/MaterialIcon.vue b/js/src/components/core/MaterialIcon.vue
index 2fe0fbc6f..042242afb 100644
--- a/js/src/components/core/MaterialIcon.vue
+++ b/js/src/components/core/MaterialIcon.vue
@@ -229,6 +229,8 @@ const icons: Record<string, () => Promise<any>> = {
     import(`../../../node_modules/vue-material-design-icons/CloudQuestion.vue`),
   Filter: () =>
     import(`../../../node_modules/vue-material-design-icons/Filter.vue`),
+  CheckCircle: () =>
+    import(`../../../node_modules/vue-material-design-icons/CheckCircle.vue`),
 };
 
 const props = withDefaults(
diff --git a/js/src/components/core/Message.vue b/js/src/components/core/Message.vue
deleted file mode 100644
index 695fde01f..000000000
--- a/js/src/components/core/Message.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<template>
-  <div
-    id="alert-2"
-    class="flex p-4 mb-4 bg-red-100 rounded-lg dark:bg-red-200"
-    role="alert"
-  >
-    <svg
-      class="flex-shrink-0 w-5 h-5 text-red-700 dark:text-red-800"
-      fill="currentColor"
-      viewBox="0 0 20 20"
-      xmlns="http://www.w3.org/2000/svg"
-    >
-      <path
-        fill-rule="evenodd"
-        d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
-        clip-rule="evenodd"
-      ></path>
-    </svg>
-    <div class="ml-3 text-sm font-medium text-red-700 dark:text-red-800">
-      <slot />
-    </div>
-    <button
-      v-if="closable"
-      type="button"
-      class="ml-auto -mx-1.5 -my-1.5 bg-red-100 text-red-500 rounded-lg focus:ring-2 focus:ring-red-400 p-1.5 hover:bg-red-200 inline-flex h-8 w-8 dark:bg-red-200 dark:text-red-600 dark:hover:bg-red-300"
-      data-dismiss-target="#alert-2"
-      aria-label="Close"
-    >
-      <span class="sr-only">{{ t("Close") }}</span>
-      <svg
-        class="w-5 h-5"
-        fill="currentColor"
-        viewBox="0 0 20 20"
-        xmlns="http://www.w3.org/2000/svg"
-      >
-        <path
-          fill-rule="evenodd"
-          d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
-          clip-rule="evenodd"
-        ></path>
-      </svg>
-    </button>
-  </div>
-</template>
-<script lang="ts" setup>
-import { useI18n } from "vue-i18n";
-
-withDefaults(
-  defineProps<{
-    type: "is-danger";
-    closable?: boolean;
-  }>(),
-  { closable: true }
-);
-
-const { t } = useI18n({ useScope: "global" });
-</script>
diff --git a/js/src/components/core/Switch.vue b/js/src/components/core/Switch.vue
deleted file mode 100644
index 69e6914b3..000000000
--- a/js/src/components/core/Switch.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-  <Switch
-    v-model="modelValue"
-    :class="disabled ? 'bg-teal-700' : 'bg-teal-900'"
-    class="relative inline-flex h-6 w-11 items-center rounded-full"
-  >
-    <span class="sr-only"><slot /></span>
-    <span
-      :class="disabled ? 'translate-x-1' : 'translate-x-6'"
-      class="inline-block h-4 w-4 transform rounded-full bg-white"
-    />
-  </Switch>
-</template>
-<script lang="ts" setup>
-import { Switch } from "@headlessui/vue";
-withDefaults(
-  defineProps<{
-    modelValue: boolean;
-    disabled?: boolean;
-    required?: boolean;
-  }>(),
-  {
-    disabled: false,
-    required: false,
-  }
-);
-</script>
diff --git a/js/src/composition/apollo/actor.ts b/js/src/composition/apollo/actor.ts
index 3fe3be7f7..ca2463452 100644
--- a/js/src/composition/apollo/actor.ts
+++ b/js/src/composition/apollo/actor.ts
@@ -61,7 +61,7 @@ export function usePersonStatusGroup(
   subscribeToMore(() => ({
     document: GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
     variables: {
-      id: currentActor.value?.id,
+      actorId: currentActor.value?.id,
       group: unref(groupFederatedUsername),
     },
   }));
diff --git a/js/src/composition/apollo/config.ts b/js/src/composition/apollo/config.ts
index 009c8952c..d5349698b 100644
--- a/js/src/composition/apollo/config.ts
+++ b/js/src/composition/apollo/config.ts
@@ -13,6 +13,7 @@ import {
   MAPS_TILES,
   RESOURCE_PROVIDERS,
   RESTRICTIONS,
+  SEARCH_CONFIG,
   TIMEZONES,
   UPLOAD_LIMITS,
 } from "@/graphql/config";
@@ -192,3 +193,12 @@ export function useAnalytics() {
   const analytics = computed(() => result.value?.config.analytics);
   return { analytics, error, loading };
 }
+
+export function useSearchConfig() {
+  const { result, error, loading, onResult } = useQuery<{
+    config: Pick<IConfig, "search">;
+  }>(SEARCH_CONFIG);
+
+  const searchConfig = computed(() => result.value?.config.search);
+  return { searchConfig, error, loading, onResult };
+}
diff --git a/js/src/composition/apollo/group.ts b/js/src/composition/apollo/group.ts
index 90417c530..1e2f0f5b0 100644
--- a/js/src/composition/apollo/group.ts
+++ b/js/src/composition/apollo/group.ts
@@ -7,7 +7,8 @@ import {
   UPDATE_GROUP,
 } from "@/graphql/group";
 import { IGroup, IPerson } from "@/types/actor";
-import { MemberRole } from "@/types/enums";
+import { IAddress } from "@/types/address.model";
+import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
 import { IMediaUploadWrapper } from "@/types/media.model";
 import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
 import { useMutation, useQuery } from "@vue/apollo-composable";
@@ -31,9 +32,7 @@ export function useGroup(
   name: string | undefined | Ref<string | undefined>,
   options: useGroupOptions = {}
 ) {
-  console.debug("using group with", name);
-
-  const { result, error, loading, onError, refetch } = useQuery<
+  const { result, error, loading, onResult, onError, refetch } = useQuery<
     {
       group: IGroup;
     },
@@ -59,20 +58,22 @@ export function useGroup(
     () => ({ enabled: unref(name) !== undefined })
   );
   const group = computed(() => result.value?.group);
-  return { group, error, loading, onError, refetch };
+  return { group, error, loading, onResult, onError, refetch };
 }
 
-export function useCreateGroup(variables: {
-  preferredUsername: string;
-  name: string;
-  summary?: string;
-  avatar?: IMediaUploadWrapper;
-  banner?: IMediaUploadWrapper;
-}) {
+export function useCreateGroup() {
   const { currentActor } = useCurrentActorClient();
 
-  return useMutation(CREATE_GROUP, () => ({
-    variables,
+  return useMutation<
+    { createGroup: IGroup },
+    {
+      preferredUsername: string;
+      name: string;
+      summary?: string;
+      avatar?: IMediaUploadWrapper;
+      banner?: IMediaUploadWrapper;
+    }
+  >(CREATE_GROUP, () => ({
     update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
       const query = {
         query: PERSON_MEMBERSHIPS,
@@ -96,10 +97,19 @@ export function useCreateGroup(variables: {
   }));
 }
 
-export function useUpdateGroup(variables: any) {
-  return useMutation<{ updateGroup: IGroup }>(UPDATE_GROUP, () => ({
-    variables,
-  }));
+export function useUpdateGroup() {
+  return useMutation<
+    { updateGroup: IGroup },
+    {
+      id: string;
+      name?: string;
+      summary?: string;
+      openness?: Openness;
+      visibility?: GroupVisibility;
+      physicalAddress?: IAddress;
+      manuallyApprovesFollowers?: boolean;
+    }
+  >(UPDATE_GROUP);
 }
 
 export function useDeleteGroup(variables: { groupId: string }) {
diff --git a/js/src/filters/datetime.ts b/js/src/filters/datetime.ts
index aaa24cf61..c58799b69 100644
--- a/js/src/filters/datetime.ts
+++ b/js/src/filters/datetime.ts
@@ -1,4 +1,3 @@
-import { DateTimeFormatOptions } from "vue-i18n";
 import { i18n } from "../utils/i18n";
 
 function parseDateTime(value: string): Date {
@@ -24,7 +23,7 @@ function formatTimeString(value: string, timeZone?: string): string {
 
 // TODO: These can be removed in favor of dateStyle/timeStyle when those two have sufficient support
 // https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_datetimeformat_datestyle
-const LONG_DATE_FORMAT_OPTIONS: DateTimeFormatOptions = {
+const LONG_DATE_FORMAT_OPTIONS: any = {
   weekday: undefined,
   year: "numeric",
   month: "long",
@@ -33,13 +32,13 @@ const LONG_DATE_FORMAT_OPTIONS: DateTimeFormatOptions = {
   minute: undefined,
 };
 
-const LONG_TIME_FORMAT_OPTIONS: DateTimeFormatOptions = {
+const LONG_TIME_FORMAT_OPTIONS: any = {
   weekday: "long",
   hour: "numeric",
   minute: "numeric",
 };
 
-const SHORT_DATE_FORMAT_OPTIONS: DateTimeFormatOptions = {
+const SHORT_DATE_FORMAT_OPTIONS: any = {
   weekday: undefined,
   year: "numeric",
   month: "short",
@@ -48,7 +47,7 @@ const SHORT_DATE_FORMAT_OPTIONS: DateTimeFormatOptions = {
   minute: undefined,
 };
 
-const SHORT_TIME_FORMAT_OPTIONS: DateTimeFormatOptions = {
+const SHORT_TIME_FORMAT_OPTIONS: any = {
   weekday: "short",
   hour: "numeric",
   minute: "numeric",
diff --git a/js/src/graphql/config.ts b/js/src/graphql/config.ts
index bada33e9d..c999fcd69 100644
--- a/js/src/graphql/config.ts
+++ b/js/src/graphql/config.ts
@@ -104,6 +104,12 @@ export const CONFIG = gql`
           type
         }
       }
+      search {
+        global {
+          isEnabled
+          isDefault
+        }
+      }
     }
   }
 `;
@@ -155,6 +161,7 @@ export const ABOUT = gql`
       name
       description
       longDescription
+      slogan
       contact
       languages
       registrationsOpen
@@ -411,3 +418,16 @@ export const ANALYTICS = gql`
     }
   }
 `;
+
+export const SEARCH_CONFIG = gql`
+  query SearchConfig {
+    config {
+      search {
+        global {
+          isEnabled
+          isDefault
+        }
+      }
+    }
+  }
+`;
diff --git a/js/src/graphql/report.ts b/js/src/graphql/report.ts
index d25436182..acf0c5de1 100644
--- a/js/src/graphql/report.ts
+++ b/js/src/graphql/report.ts
@@ -49,10 +49,17 @@ const REPORT_FRAGMENT = gql`
       uuid
       title
       description
+      beginsOn
       picture {
         id
         url
       }
+      organizerActor {
+        ...ActorFragment
+      }
+      attributedTo {
+        ...ActorFragment
+      }
     }
     comments {
       id
diff --git a/js/src/graphql/search.ts b/js/src/graphql/search.ts
index 082afe078..f5b8ea14f 100644
--- a/js/src/graphql/search.ts
+++ b/js/src/graphql/search.ts
@@ -4,6 +4,22 @@ import { ADDRESS_FRAGMENT } from "./address";
 import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
 import { TAG_FRAGMENT } from "./tags";
 
+export const GROUP_RESULT_FRAGMENT = gql`
+  fragment GroupResultFragment on GroupSearchResult {
+    id
+    avatar {
+      id
+      url
+    }
+    type
+    preferredUsername
+    name
+    domain
+    summary
+    url
+  }
+`;
+
 export const SEARCH_EVENTS_AND_GROUPS = gql`
   query SearchEventsAndGroups(
     $location: String
@@ -13,6 +29,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
     $type: EventType
     $categoryOneOf: [String]
     $statusOneOf: [EventStatus]
+    $languageOneOf: [String]
+    $searchTarget: SearchTarget
     $beginsOn: DateTime
     $endsOn: DateTime
     $eventPage: Int
@@ -27,6 +45,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
       type: $type
       categoryOneOf: $categoryOneOf
       statusOneOf: $statusOneOf
+      languageOneOf: $languageOneOf
+      searchTarget: $searchTarget
       beginsOn: $beginsOn
       endsOn: $endsOn
       page: $eventPage
@@ -42,6 +62,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
           id
           url
         }
+        url
         status
         tags {
           ...TagFragment
@@ -56,7 +77,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
           ...ActorFragment
         }
         options {
-          ...EventOptions
+          isOnline
         }
         __typename
       }
@@ -65,31 +86,41 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
       term: $term
       location: $location
       radius: $radius
+      languageOneOf: $languageOneOf
+      searchTarget: $searchTarget
       page: $groupPage
       limit: $limit
     ) {
       total
       elements {
-        ...ActorFragment
+        __typename
+        id
+        avatar {
+          id
+          url
+        }
+        type
+        preferredUsername
+        name
+        domain
+        summary
+        url
+        ...GroupResultFragment
         banner {
           id
           url
         }
-        members(roles: "member,moderator,administrator,creator") {
-          total
-        }
-        followers(approved: true) {
-          total
-        }
+        followersCount
+        membersCount
         physicalAddress {
           ...AdressFragment
         }
       }
     }
   }
-  ${EVENT_OPTIONS_FRAGMENT}
   ${TAG_FRAGMENT}
   ${ADDRESS_FRAGMENT}
+  ${GROUP_RESULT_FRAGMENT}
   ${ACTOR_FRAGMENT}
 `;
 
@@ -176,12 +207,8 @@ export const SEARCH_GROUPS = gql`
           id
           url
         }
-        members(roles: "member,moderator,administrator,creator") {
-          total
-        }
-        followers(approved: true) {
-          total
-        }
+        membersCount
+        followersCount
         physicalAddress {
           ...AdressFragment
         }
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 28603c091..f825e2727 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -1158,6 +1158,7 @@
   "When the post is private, you'll need to share the link around.": "When the post is private, you'll need to share the link around.",
   "Reset": "Reset",
   "Local time ({timezone})": "Local time ({timezone})",
+  "Local times ({timezone})": "Local times ({timezone})",
   "Time in your timezone ({timezone})": "Time in your timezone ({timezone})",
   "Export": "Export",
   "Times in your timezone ({timezone})": "Times in your timezone ({timezone})",
@@ -1383,5 +1384,11 @@
   "{numberOfLanguages} selected": "{numberOfLanguages} selected",
   "Apply filters": "Apply filters",
   "Any distance": "Any distance",
-  "{number} kilometers": "{number} kilometers"
+  "{number} kilometers": "{number} kilometers",
+  "The pad will be created on {service}": "The pad will be created on {service}",
+  "The calc will be created on {service}": "The calc will be created on {service}",
+  "The videoconference will be created on {service}": "The videoconference will be created on {service}",
+  "Search target": "Search target",
+  "In this instance's network": "In this instance's network",
+  "On the Fediverse": "On the Fediverse"
 }
\ No newline at end of file
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index 74c219316..8c7eeb793 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -516,6 +516,7 @@
   "Loading comments…": "Chargement des commentaires…",
   "Local": "Local·e",
   "Local time ({timezone})": "Heure locale ({timezone})",
+  "Local times ({timezone})": "Heures locales ({timezone})",
   "Locality": "Commune",
   "Location": "Lieu",
   "Log in": "Se connecter",
@@ -1367,5 +1368,11 @@
   "{numberOfLanguages} selected": "{numberOfLanguages} sélectionnées",
   "Apply filters": "Appliquer les filtres",
   "Any distance": "N'importe quelle distance",
-  "{number} kilometers": "{number} kilomètres"
+  "{number} kilometers": "{number} kilomètres",
+  "The pad will be created on {service}": "Le pad sera créé sur {service}",
+  "The calc will be created on {service}": "Le calc sera créé sur {service}",
+  "The videoconference will be created on {service}": "La visio-conférence sera créée sur {service}",
+  "Search target": "Cible de la recherche",
+  "In this instance's network": "Dans le réseau de cette instance",
+  "On the Fediverse": "Dans le fediverse"
 }
diff --git a/js/src/main.ts b/js/src/main.ts
index bce9e6373..a3bfaf088 100644
--- a/js/src/main.ts
+++ b/js/src/main.ts
@@ -1,5 +1,4 @@
-import { provide, createApp, h } from "vue";
-// import "../node_modules/bulma/css/bulma.min.css";
+import { provide, createApp, h, computed, ref } from "vue";
 import VueScrollTo from "vue-scrollto";
 // import VueAnnouncer from "@vue-a11y/announcer";
 // import VueSkipTo from "@vue-a11y/skip-to";
@@ -7,45 +6,35 @@ import App from "./App.vue";
 import { router } from "./router";
 import { i18n, locale } from "./utils/i18n";
 import { apolloClient } from "./vue-apollo";
-import Breadcrumbs from "@/components/Utils/Breadcrumbs.vue";
+import Breadcrumbs from "@/components/Utils/NavBreadcrumbs.vue";
 import { DefaultApolloClient } from "@vue/apollo-composable";
 import "./registerServiceWorker";
 import "./assets/tailwind.css";
 import { setAppForAnalytics } from "./services/statistics";
-import Button from "./components/core/Button.vue";
-import Message from "./components/core/Message.vue";
-import CoreInput from "./components/core/Input.vue";
-import CoreField from "./components/core/Field.vue";
 import { dateFnsPlugin } from "./plugins/dateFns";
 import { dialogPlugin } from "./plugins/dialog";
 import { snackbarPlugin } from "./plugins/snackbar";
 import { notifierPlugin } from "./plugins/notifier";
-import Tag from "./components/core/CoreTag.vue";
 import FloatingVue from "floating-vue";
 import "floating-vue/dist/style.css";
 import Oruga from "@oruga-ui/oruga-next";
-// import "@oruga-ui/oruga-next/dist/oruga-full-vars.css";
 import "@oruga-ui/oruga-next/dist/oruga.css";
 import "./assets/oruga-tailwindcss.css";
 import { orugaConfig } from "./oruga-config";
 import MaterialIcon from "./components/core/MaterialIcon.vue";
 import { createHead } from "@vueuse/head";
+import { CONFIG } from "./graphql/config";
+import { IConfig } from "./types/config.model";
 
-// Vue.use(VueScrollTo);
 // Vue.use(VueAnnouncer);
 // Vue.use(VueSkipTo);
 
-// const app = createApp(App);
-
-const head = createHead();
-
 const app = createApp({
   setup() {
     provide(DefaultApolloClient, apolloClient);
   },
   render: () => h(App),
 });
-// app.provide(DefaultApolloClient, apolloClient);
 
 app.use(router);
 app.use(i18n);
@@ -55,16 +44,28 @@ app.use(snackbarPlugin);
 app.use(notifierPlugin);
 app.use(VueScrollTo);
 app.use(FloatingVue);
-app.use(head);
+
 app.component("breadcrumbs-nav", Breadcrumbs);
-app.component("b-button", Button);
-app.component("b-message", Message);
-app.component("b-input", CoreInput);
-app.component("b-field", CoreField);
-app.component("b-tag", Tag);
 app.component("material-icon", MaterialIcon);
 app.use(Oruga, orugaConfig);
 
+const instanceName = ref();
+
+apolloClient
+  .query<{ config: IConfig }>({
+    query: CONFIG,
+  })
+  .then(({ data: configData }) => {
+    instanceName.value = configData.config?.name;
+  });
+
+const head = createHead({
+  titleTemplate: computed(() =>
+    instanceName.value ? `%s | ${instanceName.value}` : "%s"
+  ).value,
+});
+app.use(head);
+
 app.mount("#app");
 
 setAppForAnalytics(app);
diff --git a/js/src/oruga-config.ts b/js/src/oruga-config.ts
index 82c4610d9..f3101b623 100644
--- a/js/src/oruga-config.ts
+++ b/js/src/oruga-config.ts
@@ -60,6 +60,7 @@ export const orugaConfig = {
   },
   switch: {
     labelClass: "switch-label",
+    checkCheckedClass: "switch-check-checked",
   },
   select: {
     selectClass: "select",
@@ -76,6 +77,8 @@ export const orugaConfig = {
   table: {
     tableClass: "table",
     tdClass: "table-td",
+    thClass: "table-th",
+    rootClass: "table-root",
   },
   pagination: {
     rootClass: "pagination",
@@ -98,4 +101,10 @@ export const orugaConfig = {
     itemHeaderTypeClass: "tabs-nav-item-",
     itemHeaderActiveClass: "tabs-nav-item-active-",
   },
+  tooltip: {
+    rootClass: "tooltip",
+    contentClass: "tooltip-content",
+    arrowClass: "tooltip-arrow",
+    variantClass: "tooltip-content-",
+  },
 };
diff --git a/js/src/plugins/dialog.ts b/js/src/plugins/dialog.ts
index b67433380..6bae5c408 100644
--- a/js/src/plugins/dialog.ts
+++ b/js/src/plugins/dialog.ts
@@ -1,4 +1,4 @@
-import DialogComponent from "@/components/core/Dialog.vue";
+import DialogComponent from "@/components/core/CustomDialog.vue";
 import { App } from "vue";
 
 export class Dialog {
diff --git a/js/src/plugins/notifier.ts b/js/src/plugins/notifier.ts
index 82b0b054a..0b5e9c3fe 100644
--- a/js/src/plugins/notifier.ts
+++ b/js/src/plugins/notifier.ts
@@ -8,22 +8,22 @@ export class Notifier {
   }
 
   success(message: string): void {
-    this.notification(message, "is-success");
+    this.notification(message, "success");
   }
 
   error(message: string): void {
-    this.notification(message, "is-danger");
+    this.notification(message, "danger");
   }
 
   info(message: string): void {
-    this.notification(message, "is-info");
+    this.notification(message, "info");
   }
 
   private notification(message: string, type: string) {
     this.app.config.globalProperties.$oruga.notification.open({
       message,
       duration: 5000,
-      position: "is-bottom-right",
+      position: "bottom-right",
       type,
       hasIcon: true,
     });
diff --git a/js/src/plugins/snackbar.ts b/js/src/plugins/snackbar.ts
index e1f199126..cb425b173 100644
--- a/js/src/plugins/snackbar.ts
+++ b/js/src/plugins/snackbar.ts
@@ -1,4 +1,4 @@
-import SnackbarComponent from "@/components/core/Snackbar.vue";
+import SnackbarComponent from "@/components/core/CustomSnackbar.vue";
 import { App } from "vue";
 
 export class Snackbar {
@@ -10,8 +10,6 @@ export class Snackbar {
 
   open({
     message,
-    queue,
-    indefinite,
     variant,
     position,
     actionText,
diff --git a/js/src/router/actor.ts b/js/src/router/actor.ts
index da6148089..59bb89a9b 100644
--- a/js/src/router/actor.ts
+++ b/js/src/router/actor.ts
@@ -14,7 +14,7 @@ export const actorRoutes: RouteRecordRaw[] = [
   {
     path: "/groups/create",
     name: ActorRouteName.CREATE_GROUP,
-    component: (): Promise<any> => import("@/views/Group/Create.vue"),
+    component: (): Promise<any> => import("@/views/Group/CreateView.vue"),
     meta: {
       requiredAuth: true,
       announcer: { message: (): string => t("Create group") as string },
@@ -23,7 +23,7 @@ export const actorRoutes: RouteRecordRaw[] = [
   {
     path: "/@:preferredUsername",
     name: ActorRouteName.GROUP,
-    component: (): Promise<any> => import("@/views/Group/Group.vue"),
+    component: (): Promise<any> => import("@/views/Group/GroupView.vue"),
     props: true,
     meta: { requiredAuth: false, announcer: { skip: true } },
   },
diff --git a/js/src/router/discussion.ts b/js/src/router/discussion.ts
index ec538e466..d31092888 100644
--- a/js/src/router/discussion.ts
+++ b/js/src/router/discussion.ts
@@ -1,51 +1,45 @@
-import { RouteConfig } from "vue-router";
-import { ImportedComponent } from "vue/types/options";
+import { RouteRecordRaw } from "vue-router";
 import { i18n } from "@/utils/i18n";
 
+const t = i18n.global.t;
+
 export enum DiscussionRouteName {
   DISCUSSION_LIST = "DISCUSSION_LIST",
   CREATE_DISCUSSION = "CREATE_DISCUSSION",
   DISCUSSION = "DISCUSSION",
 }
 
-export const discussionRoutes: RouteConfig[] = [
+export const discussionRoutes: RouteRecordRaw[] = [
   {
     path: "/@:preferredUsername/discussions",
     name: DiscussionRouteName.DISCUSSION_LIST,
-    component: (): Promise<ImportedComponent> =>
-      import(
-        /* webpackChunkName: "DiscussionsList" */ "@/views/Discussions/DiscussionsList.vue"
-      ),
+    component: (): Promise<any> =>
+      import("@/views/Discussions/DiscussionsListView.vue"),
     props: true,
     meta: {
       requiredAuth: true,
       announcer: {
-        message: (): string => i18n.t("Discussions list") as string,
+        message: (): string => t("Discussions list") as string,
       },
     },
   },
   {
     path: "/@:preferredUsername/discussions/new",
     name: DiscussionRouteName.CREATE_DISCUSSION,
-    component: (): Promise<ImportedComponent> =>
-      import(
-        /* webpackChunkName: "CreateDiscussion" */ "@/views/Discussions/Create.vue"
-      ),
+    component: (): Promise<any> => import("@/views/Discussions/CreateView.vue"),
     props: true,
     meta: {
       requiredAuth: true,
       announcer: {
-        message: (): string => i18n.t("Create discussion") as string,
+        message: (): string => t("Create discussion") as string,
       },
     },
   },
   {
     path: "/@:preferredUsername/c/:slug/:comment_id?",
     name: DiscussionRouteName.DISCUSSION,
-    component: (): Promise<ImportedComponent> =>
-      import(
-        /* webpackChunkName: "Discussion" */ "@/views/Discussions/Discussion.vue"
-      ),
+    component: (): Promise<any> =>
+      import("@/views/Discussions/DiscussionView.vue"),
     props: true,
     meta: { requiredAuth: true, announcer: { skip: true } },
   },
diff --git a/js/src/router/event.ts b/js/src/router/event.ts
index b4e443a4c..c413326a2 100644
--- a/js/src/router/event.ts
+++ b/js/src/router/event.ts
@@ -3,10 +3,10 @@ import { RouteLocationNormalized, RouteRecordRaw } from "vue-router";
 
 const t = i18n.global.t;
 
-const participations = () => import("@/views/Event/Participants.vue");
-const editEvent = () => import("@/views/Event/Edit.vue");
-const event = () => import("@/views/Event/Event.vue");
-const myEvents = () => import("@/views/Event/MyEvents.vue");
+const participations = () => import("@/views/Event/ParticipantsView.vue");
+const editEvent = () => import("@/views/Event/EditView.vue");
+const event = () => import("@/views/Event/EventView.vue");
+const myEvents = () => import("@/views/Event/MyEventsView.vue");
 
 export enum EventRouteName {
   EVENT_LIST = "EventList",
diff --git a/js/src/router/groups.ts b/js/src/router/groups.ts
index ed365093c..3a7824311 100644
--- a/js/src/router/groups.ts
+++ b/js/src/router/groups.ts
@@ -66,7 +66,7 @@ export const groupsRoutes: RouteRecordRaw[] = [
   },
   {
     path: "/@:preferredUsername/settings",
-    component: (): Promise<any> => import("@/views/Group/Settings.vue"),
+    component: (): Promise<any> => import("@/views/Group/SettingsView.vue"),
     props: true,
     meta: { requiredAuth: true },
     redirect: { name: GroupsRouteName.GROUP_PUBLIC_SETTINGS },
@@ -100,14 +100,14 @@ export const groupsRoutes: RouteRecordRaw[] = [
   },
   {
     path: "/@:preferredUsername/p/new",
-    component: (): Promise<any> => import("@/views/Posts/Edit.vue"),
+    component: (): Promise<any> => import("@/views/Posts/EditView.vue"),
     props: true,
     name: GroupsRouteName.POST_CREATE,
     meta: { requiredAuth: true, announcer: { skip: true } },
   },
   {
     path: "/p/:slug/edit",
-    component: (): Promise<any> => import("@/views/Posts/Edit.vue"),
+    component: (): Promise<any> => import("@/views/Posts/EditView.vue"),
     props: (route: RouteLocationNormalized): Record<string, unknown> => ({
       ...route.params,
       ...{ isUpdate: true },
@@ -117,14 +117,14 @@ export const groupsRoutes: RouteRecordRaw[] = [
   },
   {
     path: "/p/:slug",
-    component: (): Promise<any> => import("@/views/Posts/Post.vue"),
+    component: (): Promise<any> => import("@/views/Posts/PostView.vue"),
     props: true,
     name: GroupsRouteName.POST,
     meta: { requiredAuth: false, announcer: { skip: true } },
   },
   {
     path: "/@:preferredUsername/p",
-    component: (): Promise<any> => import("@/views/Posts/List.vue"),
+    component: (): Promise<any> => import("@/views/Posts/ListView.vue"),
     props: true,
     name: GroupsRouteName.POSTS,
     meta: { requiredAuth: false, announcer: { skip: true } },
@@ -155,7 +155,7 @@ export const groupsRoutes: RouteRecordRaw[] = [
   {
     path: "/@:preferredUsername/timeline",
     name: GroupsRouteName.TIMELINE,
-    component: (): Promise<any> => import("@/views/Group/Timeline.vue"),
+    component: (): Promise<any> => import("@/views/Group/TimelineView.vue"),
     props: true,
     meta: { requiredAuth: true, announcer: { skip: true } },
   },
diff --git a/js/src/router/index.ts b/js/src/router/index.ts
index 954cffdb0..d0e195131 100644
--- a/js/src/router/index.ts
+++ b/js/src/router/index.ts
@@ -76,7 +76,7 @@ export const routes = [
         path: "instance",
         name: RouteName.ABOUT_INSTANCE,
         component: (): Promise<any> =>
-          import("@/views/About/AboutInstance.vue"),
+          import("@/views/About/AboutInstanceView.vue"),
         meta: {
           announcer: {
             message: (): string => t("About instance") as string,
@@ -86,7 +86,7 @@ export const routes = [
       {
         path: "/terms",
         name: RouteName.TERMS,
-        component: (): Promise<any> => import("@/views/About/Terms.vue"),
+        component: (): Promise<any> => import("@/views/About/TermsView.vue"),
         meta: {
           requiredAuth: false,
           announcer: { message: (): string => t("Terms") as string },
@@ -95,7 +95,7 @@ export const routes = [
       {
         path: "/privacy",
         name: RouteName.PRIVACY,
-        component: (): Promise<any> => import("@/views/About/Privacy.vue"),
+        component: (): Promise<any> => import("@/views/About/PrivacyView.vue"),
         meta: {
           requiredAuth: false,
           announcer: { message: (): string => t("Privacy") as string },
@@ -104,7 +104,7 @@ export const routes = [
       {
         path: "/rules",
         name: RouteName.RULES,
-        component: (): Promise<any> => import("@/views/About/Rules.vue"),
+        component: (): Promise<any> => import("@/views/About/RulesView.vue"),
         meta: {
           requiredAuth: false,
           announcer: { message: (): string => t("Rules") as string },
@@ -113,7 +113,7 @@ export const routes = [
       {
         path: "/glossary",
         name: RouteName.GLOSSARY,
-        component: (): Promise<any> => import("@/views/About/Glossary.vue"),
+        component: (): Promise<any> => import("@/views/About/GlossaryView.vue"),
         meta: {
           requiredAuth: false,
           announcer: { message: (): string => t("Glossary") as string },
@@ -124,8 +124,7 @@ export const routes = [
   {
     path: "/interact",
     name: RouteName.INTERACT,
-    component: (): Promise<any> =>
-      import(/* webpackChunkName: "interact" */ "@/views/Interact.vue"),
+    component: (): Promise<any> => import("@/views/InteractView.vue"),
     meta: {
       requiredAuth: false,
       announcer: { message: (): string => t("Interact") as string },
diff --git a/js/src/router/settings.ts b/js/src/router/settings.ts
index fcf5fed44..8e49755e2 100644
--- a/js/src/router/settings.ts
+++ b/js/src/router/settings.ts
@@ -105,7 +105,7 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "admin/settings",
         name: SettingsRouteName.ADMIN_SETTINGS,
-        component: (): Promise<any> => import("@/views/Admin/Settings.vue"),
+        component: (): Promise<any> => import("@/views/Admin/SettingsView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -117,7 +117,7 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "admin/users",
         name: SettingsRouteName.USERS,
-        component: (): Promise<any> => import("@/views/Admin/Users.vue"),
+        component: (): Promise<any> => import("@/views/Admin/UsersView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -138,7 +138,7 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "admin/profiles",
         name: SettingsRouteName.PROFILES,
-        component: (): Promise<any> => import("@/views/Admin/Profiles.vue"),
+        component: (): Promise<any> => import("@/views/Admin/ProfilesView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -176,7 +176,8 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "admin/instances",
         name: SettingsRouteName.INSTANCES,
-        component: (): Promise<any> => import("@/views/Admin/Instances.vue"),
+        component: (): Promise<any> =>
+          import("@/views/Admin/InstancesView.vue"),
         meta: {
           requiredAuth: true,
           announcer: {
@@ -188,7 +189,7 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "admin/instances/:domain",
         name: SettingsRouteName.INSTANCE,
-        component: (): Promise<any> => import("@/views/Admin/Instance.vue"),
+        component: (): Promise<any> => import("@/views/Admin/InstanceView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -207,7 +208,7 @@ export const settingsRoutes: RouteRecordRaw[] = [
         path: "/moderation/reports",
         name: SettingsRouteName.REPORTS,
         component: (): Promise<any> =>
-          import("@/views/Moderation/ReportList.vue"),
+          import("@/views/Moderation/ReportListView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -219,7 +220,8 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "/moderation/report/:reportId",
         name: SettingsRouteName.REPORT,
-        component: (): Promise<any> => import("@/views/Moderation/Report.vue"),
+        component: (): Promise<any> =>
+          import("@/views/Moderation/ReportView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
@@ -229,7 +231,8 @@ export const settingsRoutes: RouteRecordRaw[] = [
       {
         path: "/moderation/logs",
         name: SettingsRouteName.REPORT_LOGS,
-        component: (): Promise<any> => import("@/views/Moderation/Logs.vue"),
+        component: (): Promise<any> =>
+          import("@/views/Moderation/LogsView.vue"),
         props: true,
         meta: {
           requiredAuth: true,
diff --git a/js/src/router/user.ts b/js/src/router/user.ts
index ae8fd4835..978cb9c83 100644
--- a/js/src/router/user.ts
+++ b/js/src/router/user.ts
@@ -30,7 +30,7 @@ export const userRoutes: RouteRecordRaw[] = [
   {
     path: "/register/profile",
     name: UserRouteName.REGISTER_PROFILE,
-    component: (): Promise<any> => import("@/views/Account/Register.vue"),
+    component: (): Promise<any> => import("@/views/Account/RegisterView.vue"),
     // We can only pass string values through params, therefore
     props: (route: RouteLocationNormalized): Record<string, unknown> => ({
       email: route.params.email,
diff --git a/js/src/service-worker.ts b/js/src/service-worker.ts
index af230659d..c7aee7a22 100644
--- a/js/src/service-worker.ts
+++ b/js/src/service-worker.ts
@@ -104,7 +104,7 @@ async function isClientFocused(): Promise<boolean> {
 self.addEventListener("push", async (event: PushEvent) => {
   if (!event.data) return;
   const payload = event.data.json();
-  console.log("received push", payload);
+  console.debug("received push", payload);
   const options = {
     body: payload.body,
     icon: "/img/icons/android-chrome-512x512.png",
@@ -157,7 +157,7 @@ self.addEventListener("message", (event: ExtendableMessageEvent) => {
   const replyPort = event.ports[0];
   const message = event.data;
   if (replyPort && message && message.type === "skip-waiting") {
-    console.log("doing skip waiting");
+    console.debug("doing skip waiting");
     event.waitUntil(
       self.skipWaiting().then(
         () => replyPort.postMessage({ error: null }),
diff --git a/js/src/services/push-subscription.ts b/js/src/services/push-subscription.ts
index 8502f69f5..0abb0d3c4 100644
--- a/js/src/services/push-subscription.ts
+++ b/js/src/services/push-subscription.ts
@@ -1,6 +1,4 @@
 import { apolloClient } from "@/vue-apollo";
-import { NormalizedCacheObject } from "@apollo/client/cache/inmemory/types";
-import { ApolloClient } from "@apollo/client/core/ApolloClient";
 import { provideApolloClient, useQuery } from "@vue/apollo-composable";
 import { WEB_PUSH } from "../graphql/config";
 import { IConfig } from "../types/config.model";
@@ -49,15 +47,15 @@ export async function subscribeUserToPush(): Promise<PushSubscription | null> {
 }
 
 export async function unsubscribeUserToPush(): Promise<string | undefined> {
-  console.log("performing unsubscribeUserToPush");
+  console.debug("performing unsubscribeUserToPush");
   const registration = await navigator.serviceWorker.ready;
-  console.log("found registration", registration);
+  console.debug("found registration", registration);
   const subscription = await registration.pushManager?.getSubscription();
-  console.log("found subscription", subscription);
+  console.debug("found subscription", subscription);
   if (subscription && (await subscription?.unsubscribe()) === true) {
-    console.log("done unsubscription");
+    console.debug("done unsubscription");
     return subscription?.endpoint;
   }
-  console.log("went wrong");
+  console.debug("went wrong");
   return undefined;
 }
diff --git a/js/src/services/statistics/index.ts b/js/src/services/statistics/index.ts
index 14637a0e7..b4c051ccd 100644
--- a/js/src/services/statistics/index.ts
+++ b/js/src/services/statistics/index.ts
@@ -14,13 +14,13 @@ export const statistics = async (
   const matomoConfig = checkProviderConfig(configAnalytics, "matomo");
   if (matomoConfig?.enabled === true) {
     const { matomo } = (await import("./matomo")) as any;
-    matomo(environement, convertConfig(matomoConfig.configuration));
+    matomo({ ...environement, app }, convertConfig(matomoConfig.configuration));
   }
 
   const sentryConfig = checkProviderConfig(configAnalytics, "sentry");
   if (sentryConfig?.enabled === true) {
     const { sentry } = (await import("./sentry")) as any;
-    sentry(environement, convertConfig(sentryConfig.configuration));
+    sentry({ ...environement, app }, convertConfig(sentryConfig.configuration));
   }
 };
 
diff --git a/js/src/services/statistics/sentry.ts b/js/src/services/statistics/sentry.ts
index 341221d4f..5044446a0 100644
--- a/js/src/services/statistics/sentry.ts
+++ b/js/src/services/statistics/sentry.ts
@@ -1,8 +1,6 @@
 import * as Sentry from "@sentry/vue";
 import { Integrations } from "@sentry/tracing";
 
-const app: any = null;
-
 export const sentry = (environment: any, sentryConfiguration: any) => {
   console.debug("Loading Sentry statistics");
   console.debug(
diff --git a/js/src/types/actor/group.model.ts b/js/src/types/actor/group.model.ts
index c41639563..26e20449a 100644
--- a/js/src/types/actor/group.model.ts
+++ b/js/src/types/actor/group.model.ts
@@ -26,6 +26,8 @@ export interface IGroup extends IActor {
   manuallyApprovesFollowers: boolean;
   activity: Paginate<IActivity>;
   followers: Paginate<IFollower>;
+  membersCount?: number;
+  followersCount?: number;
 }
 
 export class Group extends Actor implements IGroup {
diff --git a/js/src/types/config.model.ts b/js/src/types/config.model.ts
index 1498a2ce2..e2ed75994 100644
--- a/js/src/types/config.model.ts
+++ b/js/src/types/config.model.ts
@@ -124,4 +124,10 @@ export interface IConfig {
     eventParticipants: string[];
   };
   analytics: IAnalyticsConfig[];
+  search: {
+    global: {
+      isEnabled: boolean;
+      isDefault: boolean;
+    };
+  };
 }
diff --git a/js/src/types/enums.ts b/js/src/types/enums.ts
index e4e51534d..871ee46db 100644
--- a/js/src/types/enums.ts
+++ b/js/src/types/enums.ts
@@ -286,3 +286,8 @@ export enum InstanceFollowStatus {
   PENDING = "PENDING",
   NONE = "NONE",
 }
+
+export enum SearchTargets {
+  INTERNAL = "INTERNAL",
+  GLOBAL = "GLOBAL",
+}
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index 3e3f09e2c..241d3f71c 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -17,6 +17,8 @@ export interface IEventCardOptions {
   loggedPerson?: IPerson | boolean;
   hideDetails?: boolean;
   organizerActor?: IActor | null;
+  isRemoteEvent?: boolean;
+  isLoggedIn?: boolean;
 }
 
 export interface IEventParticipantStats {
diff --git a/js/src/types/post.model.ts b/js/src/types/post.model.ts
index ca15219f9..094b3075e 100644
--- a/js/src/types/post.model.ts
+++ b/js/src/types/post.model.ts
@@ -10,7 +10,7 @@ export interface IPost {
   local: boolean;
   title: string;
   body: string;
-  tags?: ITag[];
+  tags: ITag[];
   picture?: IMedia | null;
   draft: boolean;
   visibility: PostVisibility;
diff --git a/js/src/utils/auth.ts b/js/src/utils/auth.ts
index 3df59446d..e4c43dfa7 100644
--- a/js/src/utils/auth.ts
+++ b/js/src/utils/auth.ts
@@ -50,7 +50,7 @@ export function deleteUserData(): void {
 }
 
 export async function logout(performServerLogout = true): Promise<void> {
-  const { mutate: logout } = provideApolloClient(apolloClient)(() =>
+  const { mutate: logoutMutation } = provideApolloClient(apolloClient)(() =>
     useMutation(LOGOUT)
   );
   const { mutate: cleanUserClient } = provideApolloClient(apolloClient)(() =>
@@ -61,7 +61,7 @@ export async function logout(performServerLogout = true): Promise<void> {
   );
 
   if (performServerLogout) {
-    logout({
+    logoutMutation({
       refreshToken: localStorage.getItem(AUTH_REFRESH_TOKEN),
     });
   }
diff --git a/js/src/utils/datetime.ts b/js/src/utils/datetime.ts
index 4eef7cc7f..8988b7c87 100644
--- a/js/src/utils/datetime.ts
+++ b/js/src/utils/datetime.ts
@@ -1,3 +1,6 @@
+import type { Locale } from "date-fns";
+import { format } from "date-fns";
+
 function localeMonthNames(): string[] {
   const monthNames: string[] = [];
   for (let i = 0; i < 12; i += 1) {
@@ -31,4 +34,22 @@ function formatBytes(bytes: number, decimals = 2, zero = "0 Bytes"): string {
   return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
 }
 
-export { localeMonthNames, localeShortWeekDayNames, formatBytes };
+function roundToNearestMinute(date = new Date()) {
+  const minutes = 1;
+  const ms = 1000 * 60 * minutes;
+
+  // 👇️ replace Math.round with Math.ceil to always round UP
+  return new Date(Math.round(date.getTime() / ms) * ms);
+}
+
+function formatDateTimeForEvent(dateTime: Date, locale: Locale): string {
+  return format(dateTime, "PPp", { locale });
+}
+
+export {
+  localeMonthNames,
+  localeShortWeekDayNames,
+  formatBytes,
+  roundToNearestMinute,
+  formatDateTimeForEvent,
+};
diff --git a/js/src/views/About.vue b/js/src/views/About.vue
index b4be00cce..8476b2396 100644
--- a/js/src/views/About.vue
+++ b/js/src/views/About.vue
@@ -106,22 +106,29 @@
 </template>
 
 <script lang="ts" setup>
-import { CONFIG } from "@/graphql/config";
+import { ABOUT } from "@/graphql/config";
 import { IConfig } from "@/types/config.model";
 import RouteName from "../router/name";
 import { useQuery } from "@vue/apollo-composable";
 import { computed } from "vue";
 import { useCurrentUserClient } from "@/composition/apollo/user";
 import { useI18n } from "vue-i18n";
+import { useHead } from "@vueuse/head";
 
 const { currentUser } = useCurrentUserClient();
 
-const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
+const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
 
 const config = computed(() => configResult.value?.config);
 
 const { t } = useI18n({ useScope: "global" });
 
+useHead({
+  title: computed(() =>
+    t("About {instance}", { instance: config.value?.name })
+  ),
+});
+
 // metaInfo() {
 //   return {
 //     title: this.t("About {instance}", {
diff --git a/js/src/views/About/AboutInstance.vue b/js/src/views/About/AboutInstanceView.vue
similarity index 100%
rename from js/src/views/About/AboutInstance.vue
rename to js/src/views/About/AboutInstanceView.vue
diff --git a/js/src/views/About/Glossary.vue b/js/src/views/About/GlossaryView.vue
similarity index 100%
rename from js/src/views/About/Glossary.vue
rename to js/src/views/About/GlossaryView.vue
diff --git a/js/src/views/About/Privacy.vue b/js/src/views/About/PrivacyView.vue
similarity index 100%
rename from js/src/views/About/Privacy.vue
rename to js/src/views/About/PrivacyView.vue
diff --git a/js/src/views/About/Rules.vue b/js/src/views/About/RulesView.vue
similarity index 100%
rename from js/src/views/About/Rules.vue
rename to js/src/views/About/RulesView.vue
diff --git a/js/src/views/About/Terms.vue b/js/src/views/About/TermsView.vue
similarity index 100%
rename from js/src/views/About/Terms.vue
rename to js/src/views/About/TermsView.vue
diff --git a/js/src/views/Account/Register.vue b/js/src/views/Account/RegisterView.vue
similarity index 100%
rename from js/src/views/Account/Register.vue
rename to js/src/views/Account/RegisterView.vue
diff --git a/js/src/views/Account/children/EditIdentity.vue b/js/src/views/Account/children/EditIdentity.vue
index a2e20106f..f2d71ea4d 100644
--- a/js/src/views/Account/children/EditIdentity.vue
+++ b/js/src/views/Account/children/EditIdentity.vue
@@ -91,7 +91,7 @@
         <o-button
           v-if="isUpdate"
           @click="openDeleteIdentityConfirmation()"
-          type="is-text"
+          variant="text"
         >
           {{ $t("Delete this identity") }}
         </o-button>
@@ -593,7 +593,7 @@ const dialog = inject<Dialog>("dialog");
 
 const openRegenerateFeedTokensConfirmation = (): void => {
   dialog?.confirm({
-    type: "is-warning",
+    variant: "warning",
     title: t("Regenerate new links") as string,
     message: t(
       "You'll need to change the URLs where there were previously entered."
@@ -606,7 +606,7 @@ const openRegenerateFeedTokensConfirmation = (): void => {
 
 const openDeleteIdentityConfirmation = (): void => {
   dialog?.prompt({
-    type: "danger",
+    variant: "danger",
     title: t("Delete your identity") as string,
     message: `${t(
       "This will delete / anonymize all content (events, comments, messages, participations…) created from this identity."
diff --git a/js/src/views/Admin/AdminGroupProfile.vue b/js/src/views/Admin/AdminGroupProfile.vue
index 76dbe1557..ae907de81 100644
--- a/js/src/views/Admin/AdminGroupProfile.vue
+++ b/js/src/views/Admin/AdminGroupProfile.vue
@@ -112,33 +112,27 @@
           :label="t('Member')"
           v-slot="props"
         >
-          <article class="media">
-            <figure
-              class="media-left image is-48x48"
-              v-if="props.row.actor.avatar"
-            >
-              <img
-                class="is-rounded"
-                :src="props.row.actor.avatar.url"
-                alt=""
-              />
-            </figure>
-            <o-icon
-              class="media-left"
-              v-else
-              size="large"
-              icon="account-circle"
-            />
-            <div class="media-content">
+          <article class="flex gap-1">
+            <div class="flex-none">
+              <figure v-if="props.row.actor.avatar">
+                <img
+                  class="rounded"
+                  :src="props.row.actor.avatar.url"
+                  alt=""
+                  width="48"
+                  height="48"
+                />
+              </figure>
+              <AccountCircle :size="48" v-else />
+            </div>
+            <div>
               <div class="prose dark:prose-invert">
                 <span v-if="props.row.actor.name">{{
                   props.row.actor.name
                 }}</span
                 ><span v-else>@{{ usernameWithDomain(props.row.actor) }}</span
                 ><br />
-                <span
-                  v-if="props.row.actor.name"
-                  class="is-size-7 has-text-grey"
+                <span v-if="props.row.actor.name"
                   >@{{ usernameWithDomain(props.row.actor) }}</span
                 >
               </div>
@@ -146,39 +140,39 @@
           </article>
         </o-table-column>
         <o-table-column field="role" :label="t('Role')" v-slot="props">
-          <b-tag
+          <tag
             variant="primary"
             v-if="props.row.role === MemberRole.ADMINISTRATOR"
           >
             {{ t("Administrator") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="primary"
             v-else-if="props.row.role === MemberRole.MODERATOR"
           >
             {{ t("Moderator") }}
-          </b-tag>
-          <b-tag v-else-if="props.row.role === MemberRole.MEMBER">
+          </tag>
+          <tag v-else-if="props.row.role === MemberRole.MEMBER">
             {{ t("Member") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="warning"
             v-else-if="props.row.role === MemberRole.NOT_APPROVED"
           >
             {{ t("Not approved") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="danger"
             v-else-if="props.row.role === MemberRole.REJECTED"
           >
             {{ t("Rejected") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="danger"
             v-else-if="props.row.role === MemberRole.INVITED"
           >
             {{ t("Invited") }}
-          </b-tag>
+          </tag>
         </o-table-column>
         <o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
           <span class="has-text-centered">
@@ -225,9 +219,7 @@
             :to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
           >
             {{ props.row.title }}
-            <b-tag variant="info" v-if="props.row.draft">{{
-              t("Draft")
-            }}</b-tag>
+            <tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
           </router-link>
         </o-table-column>
         <o-table-column field="beginsOn" :label="t('Begins on')" v-slot="props">
@@ -271,9 +263,7 @@
             :to="{ name: RouteName.POST, params: { slug: props.row.slug } }"
           >
             {{ props.row.title }}
-            <b-tag variant="info" v-if="props.row.draft">{{
-              t("Draft")
-            }}</b-tag>
+            <tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
           </router-link>
         </o-table-column>
         <o-table-column
@@ -295,7 +285,7 @@
     {{ t("This group was not found") }}
     <template #desc>
       <o-button
-        type="is-text"
+        variant="text"
         tag="router-link"
         :to="{ name: RouteName.ADMIN_GROUPS }"
         >{{ t("Back to group list") }}</o-button
@@ -330,6 +320,8 @@ import {
 } from "@/filters/datetime";
 import { Dialog } from "@/plugins/dialog";
 import { Notifier } from "@/plugins/notifier";
+import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
+import Tag from "@/components/Tag.vue";
 
 const EVENTS_PER_PAGE = 10;
 const POSTS_PER_PAGE = 10;
@@ -396,23 +388,21 @@ const dialog = inject<Dialog>("dialog");
 const notifier = inject<Notifier>("notifier");
 
 const confirmSuspendProfile = (): void => {
-  const message = (
-    group.value.domain
-      ? t(
-          "Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
-          { instance: group.value.domain }
-        )
-      : t(
-          "Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
-        )
-  ) as string;
+  const message = group.value.domain
+    ? t(
+        "Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
+        { instance: group.value.domain }
+      )
+    : t(
+        "Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
+      );
 
   dialog?.confirm({
-    title: t("Suspend group") as string,
+    title: t("Suspend group"),
     message,
-    confirmText: t("Suspend group") as string,
-    cancelText: t("Cancel") as string,
-    type: "danger",
+    confirmText: t("Suspend group"),
+    cancelText: t("Cancel"),
+    variant: "danger",
     hasIcon: true,
     onConfirm: () =>
       suspendProfile({
diff --git a/js/src/views/Admin/AdminProfile.vue b/js/src/views/Admin/AdminProfile.vue
index 402580922..bf11af0bb 100644
--- a/js/src/views/Admin/AdminProfile.vue
+++ b/js/src/views/Admin/AdminProfile.vue
@@ -34,14 +34,14 @@
                   <tr
                     v-for="{ key, value, link } in metadata"
                     :key="key"
-                    class="odd:bg-white even:bg-gray-50 border-b"
+                    class="odd:bg-white dark:odd:bg-zinc-800 even:bg-gray-50 dark:even:bg-zinc-700 border-b"
                   >
                     <td class="py-4 px-2 whitespace-nowrap">
                       {{ key }}
                     </td>
                     <td
                       v-if="link"
-                      class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
+                      class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
                     >
                       <router-link :to="link">
                         {{ value }}
@@ -49,7 +49,7 @@
                     </td>
                     <td
                       v-else
-                      class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
+                      class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
                     >
                       {{ value }}
                     </td>
@@ -102,7 +102,7 @@
     <section class="mt-4 mb-3">
       <h2 class="">{{ $t("Organized events") }}</h2>
       <o-table
-        :data="person.organizedEvents.elements"
+        :data="person.organizedEvents?.elements"
         :loading="loading"
         paginated
         backend-pagination
@@ -111,7 +111,7 @@
         :aria-previous-label="$t('Previous page')"
         :aria-page-label="$t('Page')"
         :aria-current-label="$t('Current page')"
-        :total="person.organizedEvents.total"
+        :total="person.organizedEvents?.total"
         :per-page="EVENTS_PER_PAGE"
         @page-change="onOrganizedEventsPageChange"
       >
@@ -140,7 +140,7 @@
       <h2 class="">{{ $t("Participations") }}</h2>
       <o-table
         :data="
-          person.participations.elements.map(
+          person.participations?.elements.map(
             (participation) => participation.event
           )
         "
@@ -152,7 +152,7 @@
         :aria-previous-label="$t('Previous page')"
         :aria-page-label="$t('Page')"
         :aria-current-label="$t('Current page')"
-        :total="person.participations.total"
+        :total="person.participations?.total"
         :per-page="EVENTS_PER_PAGE"
         @page-change="onParticipationsPageChange"
       >
@@ -180,7 +180,7 @@
     <section class="mt-4 mb-3">
       <h2 class="">{{ $t("Memberships") }}</h2>
       <o-table
-        :data="person.memberships.elements"
+        :data="person.memberships?.elements"
         :loading="loading"
         paginated
         backend-pagination
@@ -189,7 +189,7 @@
         :aria-previous-label="$t('Previous page')"
         :aria-page-label="$t('Page')"
         :aria-current-label="$t('Current page')"
-        :total="person.memberships.total"
+        :total="person.memberships?.total"
         :per-page="EVENTS_PER_PAGE"
         @page-change="onMembershipsPageChange"
       >
@@ -215,47 +215,45 @@
                   props.row.parent.name
                 }}</span
                 ><br />
-                <span class="is-size-7 has-text-grey"
-                  >@{{ usernameWithDomain(props.row.parent) }}</span
-                >
+                <span>@{{ usernameWithDomain(props.row.parent) }}</span>
               </div>
             </div>
           </article>
         </o-table-column>
         <o-table-column field="role" :label="$t('Role')" v-slot="props">
-          <b-tag
+          <tag
             variant="primary"
             v-if="props.row.role === MemberRole.ADMINISTRATOR"
           >
             {{ $t("Administrator") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="primary"
             v-else-if="props.row.role === MemberRole.MODERATOR"
           >
             {{ $t("Moderator") }}
-          </b-tag>
-          <b-tag v-else-if="props.row.role === MemberRole.MEMBER">
+          </tag>
+          <tag v-else-if="props.row.role === MemberRole.MEMBER">
             {{ $t("Member") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="warning"
             v-else-if="props.row.role === MemberRole.NOT_APPROVED"
           >
             {{ $t("Not approved") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="danger"
             v-else-if="props.row.role === MemberRole.REJECTED"
           >
             {{ $t("Rejected") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="danger"
             v-else-if="props.row.role === MemberRole.INVITED"
           >
             {{ $t("Invited") }}
-          </b-tag>
+          </tag>
         </o-table-column>
         <o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
           <span class="has-text-centered">
@@ -276,7 +274,7 @@
     {{ $t("This profile was not found") }}
     <template #desc>
       <o-button
-        type="is-text"
+        variant="text"
         tag="router-link"
         :to="{ name: RouteName.PROFILES }"
         >{{ $t("Back to profile list") }}</o-button
@@ -310,6 +308,7 @@ import {
   formatDateTimeString,
 } from "@/filters/datetime";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
+import Tag from "@/components/Tag.vue";
 
 const EVENTS_PER_PAGE = 10;
 const PARTICIPATIONS_PER_PAGE = 10;
@@ -423,7 +422,7 @@ const { mutate: suspendProfile } = useMutation<
     });
 
     if (!profileData) return;
-    const { person } = profileData;
+    const { person: cachedPerson } = profileData;
     store.writeQuery({
       query: GET_PERSON,
       variables: {
@@ -431,7 +430,7 @@ const { mutate: suspendProfile } = useMutation<
       },
       data: {
         person: {
-          ...cloneDeep(person),
+          ...cloneDeep(cachedPerson),
           participations: { total: 0, elements: [] },
           suspended: true,
           avatar: null,
diff --git a/js/src/views/Admin/AdminUserProfile.vue b/js/src/views/Admin/AdminUserProfile.vue
index e4e9bf418..91fffcb30 100644
--- a/js/src/views/Admin/AdminUserProfile.vue
+++ b/js/src/views/Admin/AdminUserProfile.vue
@@ -24,7 +24,7 @@
               <table v-if="metadata.length > 0" class="min-w-full">
                 <tbody>
                   <tr
-                    class="odd:bg-white even:bg-gray-50 border-b"
+                    class="border-b"
                     v-for="{ key, value, type } in metadata"
                     :key="key"
                   >
@@ -67,7 +67,7 @@
                         size="small"
                         v-if="!user.disabled"
                         @click="isEmailChangeModalActive = true"
-                        type="is-text"
+                        variant="text"
                         icon-left="pencil"
                         >{{ t("Change email") }}</o-button
                       >
@@ -78,7 +78,7 @@
                           query: { emailFilter: `@${userEmailDomain}` },
                         }"
                         size="small"
-                        type="is-text"
+                        variant="text"
                         icon-left="magnify"
                         >{{
                           t("Other users with the same email domain")
@@ -93,7 +93,7 @@
                         size="small"
                         v-if="!user.confirmedAt || user.disabled"
                         @click="isConfirmationModalActive = true"
-                        type="is-text"
+                        variant="text"
                         icon-left="check"
                         >{{ t("Confirm user") }}</o-button
                       >
@@ -106,7 +106,7 @@
                         size="small"
                         v-if="!user.disabled"
                         @click="isRoleChangeModalActive = true"
-                        type="is-text"
+                        variant="text"
                         icon-left="chevron-double-up"
                         >{{ t("Change role") }}</o-button
                       >
@@ -122,7 +122,7 @@
                           query: { ipFilter: user.currentSignInIp },
                         }"
                         size="small"
-                        type="is-text"
+                        variant="text"
                         icon-left="web"
                         >{{
                           t("Other users with the same IP address")
@@ -192,7 +192,7 @@
           </header>
           <section class="">
             <o-field :label="t('Previous email')">
-              <o-input type="email" :value="user.email" disabled> </o-input>
+              <o-input type="email" v-model="user.email" disabled />
             </o-field>
             <o-field :label="t('New email')">
               <o-input
@@ -208,7 +208,7 @@
             }}</o-checkbox>
           </section>
           <footer class="mt-2 flex gap-2">
-            <o-button @click="isEmailChangeModalActive = false">{{
+            <o-button outlined @click="isEmailChangeModalActive = false">{{
               t("Close")
             }}</o-button>
             <o-button native-type="submit" variant="primary">{{
@@ -309,7 +309,7 @@
     {{ t("This user was not found") }}
     <template #desc>
       <o-button
-        type="is-text"
+        variant="text"
         tag="router-link"
         :to="{ name: RouteName.USERS }"
         >{{ t("Back to user list") }}</o-button
@@ -459,7 +459,7 @@ const suspendAccount = async (): Promise<void> => {
     ),
     confirmText: t("Suspend the account"),
     cancelText: t("Cancel"),
-    type: "is-danger",
+    variant: "danger",
     onConfirm: async () => {
       suspendUser({
         userId: props.id,
diff --git a/js/src/views/Admin/GroupProfiles.vue b/js/src/views/Admin/GroupProfiles.vue
index edbf0db20..40cbdbec5 100644
--- a/js/src/views/Admin/GroupProfiles.vue
+++ b/js/src/views/Admin/GroupProfiles.vue
@@ -2,10 +2,10 @@
   <div>
     <breadcrumbs-nav
       :links="[
-        { name: RouteName.MODERATION, text: $t('Moderation') },
+        { name: RouteName.MODERATION, text: t('Moderation') },
         {
           name: RouteName.ADMIN_GROUPS,
-          text: $t('Groups'),
+          text: t('Groups'),
         },
       ]"
     />
@@ -13,13 +13,13 @@
       <router-link
         class="button is-primary"
         :to="{ name: RouteName.CREATE_GROUP }"
-        >{{ $t("Create group") }}</router-link
+        >{{ t("Create group") }}</router-link
       >
     </div>
     <div v-if="groups">
       <div class="flex gap-2">
-        <o-switch v-model="local">{{ $t("Local") }}</o-switch>
-        <o-switch v-model="suspended">{{ $t("Suspended") }}</o-switch>
+        <o-switch v-model="local">{{ t("Local") }}</o-switch>
+        <o-switch v-model="suspended">{{ t("Suspended") }}</o-switch>
       </div>
       <o-table
         :data="groups.elements"
@@ -29,10 +29,10 @@
         backend-filtering
         :debounce-search="200"
         v-model:current-page="page"
-        :aria-next-label="$t('Next page')"
-        :aria-previous-label="$t('Previous page')"
-        :aria-page-label="$t('Page')"
-        :aria-current-label="$t('Current page')"
+        :aria-next-label="t('Next page')"
+        :aria-previous-label="t('Previous page')"
+        :aria-page-label="t('Page')"
+        :aria-current-label="t('Current page')"
         :total="groups.total"
         :per-page="PROFILES_PER_PAGE"
         @page-change="onPageChange"
@@ -40,14 +40,14 @@
       >
         <o-table-column
           field="preferredUsername"
-          :label="$t('Username')"
+          :label="t('Username')"
           searchable
         >
           <template #searchable="props">
             <o-input
-              :aria-label="$t('Filter')"
+              :aria-label="t('Filter')"
               v-model="props.filters.preferredUsername"
-              :placeholder="$t('Filter')"
+              :placeholder="t('Filter')"
               icon="magnify"
             />
           </template>
@@ -82,12 +82,12 @@
           </template>
         </o-table-column>
 
-        <o-table-column field="domain" :label="$t('Domain')" searchable>
+        <o-table-column field="domain" :label="t('Domain')" searchable>
           <template #searchable="props">
             <o-input
-              :aria-label="$t('Filter')"
+              :aria-label="t('Filter')"
               v-model="props.filters.domain"
-              :placeholder="$t('Filter')"
+              :placeholder="t('Filter')"
               icon="magnify"
             />
           </template>
@@ -97,7 +97,7 @@
         </o-table-column>
         <template #empty>
           <empty-content icon="account-group" :inline="true">
-            {{ $t("No group matches the filters") }}
+            {{ t("No group matches the filters") }}
           </empty-content>
         </template>
       </o-table>
diff --git a/js/src/views/Admin/Instance.vue b/js/src/views/Admin/InstanceView.vue
similarity index 90%
rename from js/src/views/Admin/Instance.vue
rename to js/src/views/Admin/InstanceView.vue
index 806b54535..aea9eea37 100644
--- a/js/src/views/Admin/Instance.vue
+++ b/js/src/views/Admin/InstanceView.vue
@@ -11,7 +11,7 @@
     <div
       class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2"
     >
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <router-link
           :to="{
             name: RouteName.PROFILES,
@@ -24,7 +24,7 @@
           <span class="text-sm block">{{ $t("Profiles") }}</span>
         </router-link>
       </div>
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <router-link
           :to="{
             name: RouteName.ADMIN_GROUPS,
@@ -37,19 +37,19 @@
           <span class="text-sm block">{{ $t("Groups") }}</span>
         </router-link>
       </div>
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <span class="mb-4 text-xl font-semibold block">{{
           instance.followingsCount
         }}</span>
         <span class="text-sm block">{{ $t("Followings") }}</span>
       </div>
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <span class="mb-4 text-xl font-semibold block">{{
           instance.followersCount
         }}</span>
         <span class="text-sm block">{{ $t("Followers") }}</span>
       </div>
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <router-link
           :to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
         >
@@ -59,7 +59,7 @@
           <span class="text-sm block">{{ $t("Reports") }}</span>
         </router-link>
       </div>
-      <div class="bg-gray-50 rounded-xl p-8">
+      <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
         <span class="mb-4 font-semibold block">{{
           formatBytes(instance.mediaSize)
         }}</span>
@@ -68,7 +68,7 @@
     </div>
     <div class="mt-3 grid xl:grid-cols-2 gap-4">
       <div
-        class="border bg-white p-6 shadow-md rounded-md"
+        class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md"
         v-if="instance.hasRelay"
       >
         <button
@@ -104,7 +104,9 @@
       <div v-else class="md:h-48 py-16 text-center opacity-50">
         {{ $t("Only Mobilizon instances can be followed") }}
       </div>
-      <div class="border bg-white p-6 shadow-md rounded-md flex flex-col gap-2">
+      <div
+        class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md flex flex-col gap-2"
+      >
         <button
           @click="
             acceptInstance({
@@ -140,6 +142,7 @@ import {
   ADD_INSTANCE,
   INSTANCE,
   REJECT_RELAY,
+  REMOVE_RELAY,
 } from "@/graphql/admin";
 import { formatBytes } from "@/utils/datetime";
 import RouteName from "@/router/name";
@@ -215,7 +218,7 @@ onRejectInstanceError((error) => {
 });
 
 const { mutate: followInstanceMutation, onError: onFollowInstanceError } =
-  useMutation(ADD_INSTANCE);
+  useMutation<{ addInstance: IInstance }>(ADD_INSTANCE);
 
 onFollowInstanceError((error) => {
   if (error.graphQLErrors && error.graphQLErrors.length > 0) {
@@ -232,10 +235,10 @@ const followInstance = async (e: Event): Promise<void> => {
  * Stop following instance
  */
 const { mutate: removeInstanceFollow, onError: onRemoveInstanceFollowError } =
-  useMutation(REJECT_RELAY, () => ({
+  useMutation(REMOVE_RELAY, () => ({
     update(cache: ApolloCache<any>) {
       cache.writeFragment({
-        id: cache.identify(instance as unknown as Reference),
+        id: cache.identify(instance.value as unknown as Reference),
         fragment: gql`
           fragment InstanceFollowedStatus on Instance {
             followedStatus
diff --git a/js/src/views/Admin/Instances.vue b/js/src/views/Admin/InstancesView.vue
similarity index 80%
rename from js/src/views/Admin/Instances.vue
rename to js/src/views/Admin/InstancesView.vue
index 4a09ef21b..529fc0f7d 100644
--- a/js/src/views/Admin/Instances.vue
+++ b/js/src/views/Admin/InstancesView.vue
@@ -2,24 +2,29 @@
   <div>
     <breadcrumbs-nav
       :links="[
-        { name: RouteName.ADMIN, text: $t('Admin') },
-        { text: $t('Instances') },
+        { name: RouteName.ADMIN, text: t('Admin') },
+        { text: t('Instances') },
       ]"
     />
     <section>
-      <h1 class="title">{{ $t("Instances") }}</h1>
+      <h1 class="title">{{ t("Instances") }}</h1>
       <form @submit="followInstance" class="my-4">
-        <o-field :label="$t('Follow a new instance')" horizontal>
+        <o-field
+          :label="t('Follow a new instance')"
+          horizontal
+          label-for="newRelayAddress"
+        >
           <o-field grouped group-multiline expanded size="large">
             <p class="control">
               <o-input
+                id="newRelayAddress"
                 v-model="newRelayAddress"
-                :placeholder="$t('Ex: mobilizon.fr')"
+                :placeholder="t('Ex: mobilizon.fr')"
               />
             </p>
             <p class="control">
               <o-button variant="primary" native-type="submit">{{
-                $t("Add an instance")
+                t("Add an instance")
               }}</o-button>
               <o-loading
                 :is-full-page="true"
@@ -31,31 +36,31 @@
         </o-field>
       </form>
       <div class="flex flex-wrap gap-2">
-        <o-field :label="$t('Follow status')">
+        <o-field :label="t('Follow status')">
           <o-radio
             v-model="followStatus"
             :native-value="InstanceFilterFollowStatus.ALL"
-            >{{ $t("All") }}</o-radio
+            >{{ t("All") }}</o-radio
           >
           <o-radio
             v-model="followStatus"
             :native-value="InstanceFilterFollowStatus.FOLLOWING"
-            >{{ $t("Following") }}</o-radio
+            >{{ t("Following") }}</o-radio
           >
           <o-radio
             v-model="followStatus"
             :native-value="InstanceFilterFollowStatus.FOLLOWED"
-            >{{ $t("Followed") }}</o-radio
+            >{{ t("Followed") }}</o-radio
           >
         </o-field>
         <o-field
-          :label="$t('Domain')"
+          :label="t('Domain')"
           label-for="domain-filter"
           class="flex-auto"
         >
           <o-input
             id="domain-filter"
-            :placeholder="$t('mobilizon-instance.tld')"
+            :placeholder="t('mobilizon-instance.tld')"
             :value="filterDomain"
             @input="debouncedUpdateDomainFilter"
           />
@@ -67,7 +72,7 @@
             name: RouteName.INSTANCE,
             params: { domain: instance.domain },
           }"
-          class="flex items-center mb-2 rounded bg-secondary p-4 flex-wrap justify-center gap-x-2 gap-y-3"
+          class="flex items-center mb-2 rounded bg-mbz-yellow-alt-300 dark:bg-mbz-purple-400 p-4 flex-wrap justify-center gap-x-2 gap-y-3"
           v-for="instance in instances.elements"
           :key="instance.domain"
         >
@@ -75,19 +80,19 @@
             <img
               class="w-12"
               v-if="instance.hasRelay"
-              src="../../assets/logo.svg"
+              src="/img/logo.svg"
               alt=""
             />
             <CloudQuestion v-else :size="36" />
 
             <div class="">
-              <h4 class="text-lg truncate">{{ instance.domain }}</h4>
+              <h3 class="text-lg truncate">{{ instance.domain }}</h3>
               <span
                 class="text-sm"
                 v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
               >
                 <o-icon icon="inbox-arrow-down" />
-                {{ $t("Followed") }}</span
+                {{ t("Followed") }}</span
               >
               <span
                 class="text-sm"
@@ -96,32 +101,32 @@
                 "
               >
                 <o-icon icon="inbox-arrow-down" />
-                {{ $t("Followed, pending response") }}</span
+                {{ t("Followed, pending response") }}</span
               >
               <span
                 class="text-sm"
                 v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
               >
                 <o-icon icon="inbox-arrow-up" />
-                {{ $t("Follows us") }}</span
+                {{ t("Follows us") }}</span
               >
               <span
                 class="text-sm"
                 v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
               >
                 <o-icon icon="inbox-arrow-up" />
-                {{ $t("Follows us, pending approval") }}</span
+                {{ t("Follows us, pending approval") }}</span
               >
             </div>
           </div>
           <div class="flex-none flex gap-3 ltr:ml-3 rtl:mr-3">
             <p class="flex flex-col text-center">
               <span class="text-xl">{{ instance.eventCount }}</span
-              ><span class="text-sm">{{ $t("Events") }}</span>
+              ><span class="text-sm">{{ t("Events") }}</span>
             </p>
             <p class="flex flex-col text-center">
               <span class="text-xl">{{ instance.personCount }}</span
-              ><span class="text-sm">{{ $t("Profiles") }}</span>
+              ><span class="text-sm">{{ t("Profiles") }}</span>
             </p>
           </div>
         </router-link>
@@ -130,26 +135,26 @@
           :total="instances.total"
           v-model="instancePage"
           :per-page="INSTANCES_PAGE_LIMIT"
-          :aria-next-label="$t('Next page')"
-          :aria-previous-label="$t('Previous page')"
-          :aria-page-label="$t('Page')"
-          :aria-current-label="$t('Current page')"
+          :aria-next-label="t('Next page')"
+          :aria-previous-label="t('Previous page')"
+          :aria-page-label="t('Page')"
+          :aria-current-label="t('Current page')"
         >
         </o-pagination>
       </div>
       <div v-else-if="instances && instances.elements.length == 0">
         <empty-content icon="lan-disconnect" :inline="true">
-          {{ $t("No instance found.") }}
+          {{ t("No instance found.") }}
           <template #desc>
             <span v-if="hasFilter">
               {{
-                $t(
+                t(
                   "No instances match this filter. Try resetting filter fields?"
                 )
               }}
             </span>
             <span v-else>
-              {{ $t("You haven't interacted with other instances yet.") }}
+              {{ t("You haven't interacted with other instances yet.") }}
             </span>
           </template>
         </empty-content>
@@ -176,10 +181,11 @@ import {
   useRouteQuery,
 } from "vue-use-route-query";
 import { useMutation, useQuery } from "@vue/apollo-composable";
-import { computed, ref } from "vue";
+import { computed, inject, ref } from "vue";
 import { useRouter } from "vue-router";
 import { useHead } from "@vueuse/head";
 import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue";
+import { Notifier } from "@/plugins/notifier";
 
 const INSTANCES_PAGE_LIMIT = 10;
 
@@ -243,6 +249,8 @@ onDone(({ data }) => {
   });
 });
 
+const notifier = inject<Notifier>("notifier");
+
 onError((error) => {
   if (error.message) {
     if (error.graphQLErrors && error.graphQLErrors.length > 0) {
diff --git a/js/src/views/Admin/Profiles.vue b/js/src/views/Admin/ProfilesView.vue
similarity index 82%
rename from js/src/views/Admin/Profiles.vue
rename to js/src/views/Admin/ProfilesView.vue
index 0c122b28b..4bd47cb5d 100644
--- a/js/src/views/Admin/Profiles.vue
+++ b/js/src/views/Admin/ProfilesView.vue
@@ -2,16 +2,18 @@
   <div>
     <breadcrumbs-nav
       :links="[
-        { name: RouteName.MODERATION, text: $t('Moderation') },
+        { name: RouteName.MODERATION, text: t('Moderation') },
         {
           name: RouteName.PROFILES,
-          text: $t('Profiles'),
+          text: t('Profiles'),
         },
       ]"
     />
     <div v-if="persons">
-      <o-switch v-model="local">{{ $t("Local") }}</o-switch>
-      <o-switch v-model="suspended">{{ $t("Suspended") }}</o-switch>
+      <div class="flex gap-2">
+        <o-switch v-model="local">{{ t("Local") }}</o-switch>
+        <o-switch v-model="suspended">{{ t("Suspended") }}</o-switch>
+      </div>
       <o-table
         :data="persons.elements"
         :loading="loading"
@@ -20,25 +22,24 @@
         backend-filtering
         :debounce-search="200"
         v-model:current-page="page"
-        :aria-next-label="$t('Next page')"
-        :aria-previous-label="$t('Previous page')"
-        :aria-page-label="$t('Page')"
-        :aria-current-label="$t('Current page')"
+        :aria-next-label="t('Next page')"
+        :aria-previous-label="t('Previous page')"
+        :aria-page-label="t('Page')"
+        :aria-current-label="t('Current page')"
         :total="persons.total"
         :per-page="PROFILES_PER_PAGE"
-        @page-change="onPageChange"
         @filters-change="onFiltersChange"
       >
         <o-table-column
           field="preferredUsername"
-          :label="$t('Username')"
+          :label="t('Username')"
           searchable
         >
           <template #searchable="props">
             <o-input
               v-model="props.filters.preferredUsername"
-              :aria-label="$t('Filter')"
-              :placeholder="$t('Filter')"
+              :aria-label="t('Filter')"
+              :placeholder="t('Filter')"
               icon="magnify"
             />
           </template>
@@ -57,6 +58,7 @@
                     :alt="props.row.avatar.alt || ''"
                     width="48"
                     height="48"
+                    class="rounded-full"
                   />
                 </figure>
                 <Account v-else :size="48" />
@@ -72,12 +74,12 @@
           </template>
         </o-table-column>
 
-        <o-table-column field="domain" :label="$t('Domain')" searchable>
+        <o-table-column field="domain" :label="t('Domain')" searchable>
           <template #searchable="props">
             <o-input
               v-model="props.filters.domain"
-              :aria-label="$t('Filter')"
-              :placeholder="$t('Filter')"
+              :aria-label="t('Filter')"
+              :placeholder="t('Filter')"
               icon="magnify"
             />
           </template>
@@ -87,7 +89,7 @@
         </o-table-column>
         <template #empty>
           <empty-content icon="account" :inline="true">
-            {{ $t("No profile matches the filters") }}
+            {{ t("No profile matches the filters") }}
           </empty-content>
         </template>
       </o-table>
@@ -142,10 +144,6 @@ useHead({
   title: computed(() => t("Profiles")),
 });
 
-const onPageChange = async (): Promise<void> => {
-  await fetchMore();
-};
-
 const onFiltersChange = ({
   preferredUsername: newPreferredUsername,
   domain: newDomain,
@@ -155,7 +153,7 @@ const onFiltersChange = ({
 }): void => {
   preferredUsername.value = newPreferredUsername;
   domain.value = newDomain;
-  fetchMore();
+  fetchMore({});
 };
 </script>
 <style lang="scss" scoped>
diff --git a/js/src/views/Admin/Settings.vue b/js/src/views/Admin/SettingsView.vue
similarity index 100%
rename from js/src/views/Admin/Settings.vue
rename to js/src/views/Admin/SettingsView.vue
diff --git a/js/src/views/Admin/Users.vue b/js/src/views/Admin/UsersView.vue
similarity index 100%
rename from js/src/views/Admin/Users.vue
rename to js/src/views/Admin/UsersView.vue
diff --git a/js/src/views/CategoriesView.vue b/js/src/views/CategoriesView.vue
index 646114c27..97de3f25d 100644
--- a/js/src/views/CategoriesView.vue
+++ b/js/src/views/CategoriesView.vue
@@ -104,20 +104,15 @@ import {
   CategoryPictureLicencing,
   CategoryPictureLicencingElement,
 } from "@/components/Categories/constants";
-import { CONFIG } from "@/graphql/config";
-import { IConfig } from "@/types/config.model";
 import { useI18n } from "vue-i18n";
+import { useEventCategories } from "@/composition/apollo/config";
 
 const { t } = useI18n({ useScope: "global" });
 
-const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
-
-const config = computed(() => configResult.value?.config);
-
-const eventCategories = computed(() => config.value?.eventCategories ?? []);
+const { eventCategories } = useEventCategories();
 
 const eventCategoryLabel = (categoryId: string): string | undefined => {
-  return eventCategories.value.find(({ id }) => categoryId == id)?.label;
+  return eventCategories.value?.find(({ id }) => categoryId == id)?.label;
 };
 
 const { result: categoryStatsResult } = useQuery<{
@@ -132,7 +127,7 @@ const promotedCategories = computed((): CategoryStatsModel[] => {
     .map(({ key, number }) => ({
       key,
       number,
-      label: eventCategoryLabel(key),
+      label: eventCategoryLabel(key) as string,
     }))
     .filter(
       ({ key, number, label }) =>
diff --git a/js/src/views/Discussions/Create.vue b/js/src/views/Discussions/CreateView.vue
similarity index 97%
rename from js/src/views/Discussions/Create.vue
rename to js/src/views/Discussions/CreateView.vue
index 0c2d3aaf0..48ac1a70d 100644
--- a/js/src/views/Discussions/Create.vue
+++ b/js/src/views/Discussions/CreateView.vue
@@ -72,7 +72,9 @@ import { useHead } from "@vueuse/head";
 import { Notifier } from "@/plugins/notifier";
 import { AbsintheGraphQLError } from "@/types/errors.model";
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const props = defineProps<{ preferredUsername: string }>();
 
diff --git a/js/src/views/Discussions/Discussion.vue b/js/src/views/Discussions/DiscussionView.vue
similarity index 98%
rename from js/src/views/Discussions/Discussion.vue
rename to js/src/views/Discussions/DiscussionView.vue
index 3ddbd572f..df866d18b 100644
--- a/js/src/views/Discussions/Discussion.vue
+++ b/js/src/views/Discussions/DiscussionView.vue
@@ -95,6 +95,7 @@
         </form>
       </div>
       <discussion-comment
+        class="border rounded-md p-2 mt-4"
         v-for="comment in discussion.comments.elements"
         :key="comment.id"
         :model-value="comment"
@@ -237,7 +238,9 @@ const discussion = computed(() => discussionResult.value?.discussion);
 
 const { group } = useGroup(usernameWithDomain(discussion.value?.actor));
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 useHead({
   title: computed(() => discussion.value?.title ?? ""),
@@ -362,7 +365,7 @@ const dialog = inject<Dialog>("dialog");
 
 const openDeleteDiscussionConfirmation = (): void => {
   dialog?.confirm({
-    type: "is-danger",
+    variant: "danger",
     title: t("Delete this discussion"),
     message: t("Are you sure you want to delete this entire discussion?"),
     confirmText: t("Delete discussion"),
diff --git a/js/src/views/Discussions/DiscussionsList.vue b/js/src/views/Discussions/DiscussionsListView.vue
similarity index 98%
rename from js/src/views/Discussions/DiscussionsList.vue
rename to js/src/views/Discussions/DiscussionsListView.vue
index e25762ef8..ff85999da 100644
--- a/js/src/views/Discussions/DiscussionsList.vue
+++ b/js/src/views/Discussions/DiscussionsListView.vue
@@ -41,6 +41,7 @@
           :key="discussion.id"
         />
         <o-pagination
+          v-show="group.discussions.total > DISCUSSIONS_PER_PAGE"
           class="discussion-pagination"
           :total="group.discussions.total"
           v-model="page"
diff --git a/js/src/views/Event/Edit.vue b/js/src/views/Event/EditView.vue
similarity index 86%
rename from js/src/views/Event/Edit.vue
rename to js/src/views/Event/EditView.vue
index 586e2ae23..dccac9f30 100644
--- a/js/src/views/Event/Edit.vue
+++ b/js/src/views/Event/EditView.vue
@@ -1,23 +1,23 @@
 <template>
   <div class="container mx-auto" v-if="hasCurrentActorPermissionsToEdit">
     <h1 class="" v-if="isUpdate === true">
-      {{ $t("Update event {name}", { name: event.title }) }}
+      {{ t("Update event {name}", { name: event.title }) }}
     </h1>
     <h1 class="" v-else>
-      {{ $t("Create a new event") }}
+      {{ t("Create a new event") }}
     </h1>
 
     <form ref="form">
-      <h2>{{ $t("General information") }}</h2>
+      <h2>{{ t("General information") }}</h2>
       <picture-upload
         v-if="pictureFile"
-        v-model:pictureFile="pictureFile"
-        :textFallback="$t('Headline picture')"
+        v-model:modelValue="pictureFile"
+        :textFallback="t('Headline picture')"
         :defaultImage="event.picture"
       />
 
       <o-field
-        :label="$t('Title')"
+        :label="t('Title')"
         label-for="title"
         :type="checkTitleLength[0]"
         :message="checkTitleLength[1]"
@@ -35,12 +35,12 @@
       <div class="flex flex-wrap gap-4">
         <o-field
           v-if="eventCategories"
-          :label="$t('Category')"
+          :label="t('Category')"
           label-for="category"
           class="w-full md:max-w-fit"
         >
           <o-select
-            :placeholder="$t('Select a category')"
+            :placeholder="t('Select a category')"
             v-model="event.category"
             expanded
           >
@@ -62,13 +62,13 @@
 
       <o-field
         horizontal
-        :label="$t('Starts on…')"
+        :label="t('Starts on…')"
         class="begins-on-field"
         label-for="begins-on-field"
       >
         <o-datetimepicker
           class="datepicker starts-on"
-          :placeholder="$t('Type or select a date…')"
+          :placeholder="t('Type or select a date…')"
           icon="calendar-today"
           :locale="$i18n.locale"
           v-model="beginsOn"
@@ -78,17 +78,17 @@
           :first-day-of-week="firstDayOfWeek"
           :datepicker="{
             id: 'begins-on-field',
-            'aria-next-label': $t('Next month'),
-            'aria-previous-label': $t('Previous month'),
+            'aria-next-label': t('Next month'),
+            'aria-previous-label': t('Previous month'),
           }"
         >
         </o-datetimepicker>
       </o-field>
 
-      <o-field horizontal :label="$t('Ends on…')" label-for="ends-on-field">
+      <o-field horizontal :label="t('Ends on…')" label-for="ends-on-field">
         <o-datetimepicker
           class="datepicker ends-on"
-          :placeholder="$t('Type or select a date…')"
+          :placeholder="t('Type or select a date…')"
           icon="calendar-today"
           :locale="$i18n.locale"
           v-model="endsOn"
@@ -99,41 +99,40 @@
           :first-day-of-week="firstDayOfWeek"
           :datepicker="{
             id: 'ends-on-field',
-            'aria-next-label': $t('Next month'),
-            'aria-previous-label': $t('Previous month'),
+            'aria-next-label': t('Next month'),
+            'aria-previous-label': t('Previous month'),
           }"
         >
         </o-datetimepicker>
       </o-field>
 
-      <o-button type="is-text" @click="dateSettingsIsOpen = true">
-        {{ $t("Date parameters") }}
+      <o-button class="block" variant="text" @click="dateSettingsIsOpen = true">
+        {{ t("Date parameters") }}
       </o-button>
 
-      <div class="address">
+      <div class="my-6">
         <full-address-auto-complete
           v-model="eventPhysicalAddress"
           :user-timezone="userActualTimezone"
-          :disabled="isOnline"
+          :disabled="event.options.isOnline"
+          :hideSelected="true"
         />
-        <o-switch class="is-online" v-model="isOnline">{{
-          $t("The event is fully online")
+        <o-switch class="my-4" v-model="isOnline">{{
+          t("The event is fully online")
         }}</o-switch>
       </div>
 
       <div class="o-field field">
-        <label class="o-field__label field-label">{{
-          $t("Description")
-        }}</label>
+        <label class="o-field__label field-label">{{ t("Description") }}</label>
         <editor-component
           v-if="currentActor"
           :current-actor="(currentActor as IPerson)"
           v-model="event.description"
-          :aria-label="$t('Event description body')"
+          :aria-label="t('Event description body')"
         />
       </div>
 
-      <o-field :label="$t('Website / URL')" label-for="website-url">
+      <o-field :label="t('Website / URL')" label-for="website-url">
         <o-input
           icon="link"
           type="url"
@@ -144,7 +143,7 @@
       </o-field>
 
       <section class="my-4">
-        <h2>{{ $t("Organizers") }}</h2>
+        <h2>{{ t("Organizers") }}</h2>
 
         <div v-if="features?.groups && organizerActor?.id">
           <o-field>
@@ -155,21 +154,21 @@
           </o-field>
           <p v-if="!attributedToAGroup && organizerActorEqualToCurrentActor">
             {{
-              $t("The event will show as attributed to your personal profile.")
+              t("The event will show as attributed to your personal profile.")
             }}
           </p>
           <p v-else-if="!attributedToAGroup">
-            {{ $t("The event will show as attributed to this profile.") }}
+            {{ t("The event will show as attributed to this profile.") }}
           </p>
           <p v-else>
             <span>{{
-              $t("The event will show as attributed to this group.")
+              t("The event will show as attributed to this group.")
             }}</span>
             <span
               v-if="event.contacts && event.contacts.length"
               v-html="
                 ' ' +
-                $t(
+                t(
                   '<b>{contact}</b> will be displayed as contact.',
 
                   {
@@ -184,16 +183,16 @@
               "
             />
             <span v-else>
-              {{ $t("You may show some members as contacts.") }}
+              {{ t("You may show some members as contacts.") }}
             </span>
           </p>
         </div>
       </section>
       <section class="my-4">
-        <h2>{{ $t("Event metadata") }}</h2>
+        <h2>{{ t("Event metadata") }}</h2>
         <p>
           {{
-            $t(
+            t(
               "Integrate this event with 3rd-party tools and show metadata for the event."
             )
           }}
@@ -202,12 +201,12 @@
       </section>
       <section class="my-4">
         <h2>
-          {{ $t("Who can view this event and participate") }}
+          {{ t("Who can view this event and participate") }}
         </h2>
         <fieldset>
           <legend>
             {{
-              $t(
+              t(
                 "When the event is private, you'll need to share the link around."
               )
             }}
@@ -217,7 +216,7 @@
               v-model="event.visibility"
               name="eventVisibility"
               :native-value="EventVisibility.PUBLIC"
-              >{{ $t("Visible everywhere on the web (public)") }}</o-radio
+              >{{ t("Visible everywhere on the web (public)") }}</o-radio
             >
           </div>
           <div class="field">
@@ -225,7 +224,7 @@
               v-model="event.visibility"
               name="eventVisibility"
               :native-value="EventVisibility.UNLISTED"
-              >{{ $t("Only accessible through link (private)") }}</o-radio
+              >{{ t("Only accessible through link (private)") }}</o-radio
             >
           </div>
         </fieldset>
@@ -233,7 +232,7 @@
           <o-radio v-model="event.visibility"
                    name="eventVisibility"
                    :native-value="EventVisibility.PRIVATE">
-             {{ $t('Page limited to my group (asks for auth)') }}
+             {{ t('Page limited to my group (asks for auth)') }}
           </o-radio>
         </div>-->
 
@@ -242,9 +241,7 @@
           :label="t('Anonymous participations')"
         >
           <o-switch v-model="eventOptions.anonymousParticipation">
-            {{
-              $t("I want to allow people to participate without an account.")
-            }}
+            {{ t("I want to allow people to participate without an account.") }}
             <small
               v-if="
                 anonymousParticipationConfig?.validation.email
@@ -253,7 +250,7 @@
             >
               <br />
               {{
-                $t(
+                t(
                   "Anonymous participants will be asked to confirm their participation through e-mail."
                 )
               }}
@@ -263,23 +260,23 @@
 
         <o-field :label="t('Participation approval')">
           <o-switch v-model="needsApproval">{{
-            $t("I want to approve every participation request")
+            t("I want to approve every participation request")
           }}</o-switch>
         </o-field>
 
         <o-field :label="t('Number of places')">
           <o-switch v-model="limitedPlaces">{{
-            $t("Limited number of places")
+            t("Limited number of places")
           }}</o-switch>
         </o-field>
 
         <div class="" v-if="limitedPlaces">
-          <o-field :label="$t('Number of places')" label-for="number-of-places">
+          <o-field :label="t('Number of places')" label-for="number-of-places">
             <o-input
               type="number"
               controls-position="compact"
-              :aria-minus-label="$t('Decrease')"
-              :aria-plus-label="$t('Increase')"
+              :aria-minus-label="t('Decrease')"
+              :aria-plus-label="t('Increase')"
               min="1"
               v-model="eventOptions.maximumAttendeeCapacity"
               id="number-of-places"
@@ -288,28 +285,28 @@
           <!--
           <o-field>
             <o-switch v-model="eventOptions.showRemainingAttendeeCapacity">
-              {{ $t('Show remaining number of places') }}
+              {{ t('Show remaining number of places') }}
             </o-switch>
           </o-field>
 
           <o-field>
             <o-switch v-model="eventOptions.showParticipationPrice">
-              {{ $t('Display participation price') }}
+              {{ t('Display participation price') }}
             </o-switch>
           </o-field>-->
         </div>
       </section>
       <section class="my-4">
-        <h2>{{ $t("Public comment moderation") }}</h2>
+        <h2>{{ t("Public comment moderation") }}</h2>
 
         <fieldset>
-          <legend>{{ $t("Who can post a comment?") }}</legend>
+          <legend>{{ t("Who can post a comment?") }}</legend>
           <o-field>
             <o-radio
               v-model="eventOptions.commentModeration"
               name="commentModeration"
               :native-value="CommentModeration.ALLOW_ALL"
-              >{{ $t("Allow all comments from users with accounts") }}</o-radio
+              >{{ t("Allow all comments from users with accounts") }}</o-radio
             >
           </o-field>
 
@@ -317,7 +314,7 @@
           <!--            <o-radio v-model="eventOptions.commentModeration"-->
           <!--                     name="commentModeration"-->
           <!--                     :native-value="CommentModeration.MODERATED">-->
-          <!--              {{ $t('Moderated comments (shown after approval)') }}-->
+          <!--              {{ t('Moderated comments (shown after approval)') }}-->
           <!--            </o-radio>-->
           <!--          </div>-->
 
@@ -326,18 +323,18 @@
               v-model="eventOptions.commentModeration"
               name="commentModeration"
               :native-value="CommentModeration.CLOSED"
-              >{{ $t("Close comments for all (except for admins)") }}</o-radio
+              >{{ t("Close comments for all (except for admins)") }}</o-radio
             >
           </o-field>
         </fieldset>
       </section>
       <section class="my-4">
-        <h2>{{ $t("Status") }}</h2>
+        <h2>{{ t("Status") }}</h2>
 
         <fieldset>
           <legend>
             {{
-              $t(
+              t(
                 "Does the event needs to be confirmed later or is it cancelled?"
               )
             }}
@@ -350,7 +347,7 @@
               :native-value="EventStatus.TENTATIVE"
             >
               <o-icon icon="calendar-question" />
-              {{ $t("Tentative: Will be confirmed later") }}
+              {{ t("Tentative: Will be confirmed later") }}
             </o-radio>
             <o-radio
               v-model="event.status"
@@ -359,7 +356,7 @@
               :native-value="EventStatus.CONFIRMED"
             >
               <o-icon icon="calendar-check" />
-              {{ $t("Confirmed: Will happen") }}
+              {{ t("Confirmed: Will happen") }}
             </o-radio>
             <o-radio
               v-model="event.status"
@@ -368,7 +365,7 @@
               :native-value="EventStatus.CANCELLED"
             >
               <o-icon icon="calendar-remove" />
-              {{ $t("Cancelled: Won't happen") }}
+              {{ t("Cancelled: Won't happen") }}
             </o-radio>
           </o-field>
         </fieldset>
@@ -377,30 +374,30 @@
   </div>
   <div class="container mx-auto" v-else>
     <o-notification variant="danger">
-      {{ $t("Only group moderators can create, edit and delete events.") }}
+      {{ t("Only group moderators can create, edit and delete events.") }}
     </o-notification>
   </div>
   <o-modal
     v-model:active="dateSettingsIsOpen"
     has-modal-card
     trap-focus
-    :close-button-aria-label="$t('Close')"
+    :close-button-aria-label="t('Close')"
   >
     <form class="p-3">
       <header class="">
-        <h2 class="">{{ $t("Date and time settings") }}</h2>
+        <h2 class="">{{ t("Date and time settings") }}</h2>
       </header>
       <section class="">
         <p>
           {{
-            $t(
+            t(
               "Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting."
             )
           }}
         </p>
-        <o-field :label="$t('Timezone')" label-for="timezone" expanded>
+        <o-field :label="t('Timezone')" label-for="timezone" expanded>
           <o-select
-            :placeholder="$t('Select a timezone')"
+            :placeholder="t('Select a timezone')"
             :loading="timezoneLoading"
             v-model="timezone"
             id="timezone"
@@ -424,23 +421,23 @@
             @click="timezone = null"
             class="reset-area"
             icon-left="close"
-            :title="$t('Clear timezone field')"
+            :title="t('Clear timezone field')"
           />
         </o-field>
-        <o-field :label="$t('Event page settings')">
+        <o-field :label="t('Event page settings')">
           <o-switch v-model="eventOptions.showStartTime">{{
-            $t("Show the time when the event begins")
+            t("Show the time when the event begins")
           }}</o-switch>
         </o-field>
         <o-field>
           <o-switch v-model="eventOptions.showEndTime">{{
-            $t("Show the time when the event ends")
+            t("Show the time when the event ends")
           }}</o-switch>
         </o-field>
       </section>
       <footer class="mt-2">
         <o-button @click="dateSettingsIsOpen = false">
-          {{ $t("OK") }}
+          {{ t("OK") }}
         </o-button>
       </footer>
     </form>
@@ -449,23 +446,22 @@
   <nav
     role="navigation"
     aria-label="main navigation"
-    class="bg-secondary/70"
+    class="bg-mbz-yellow-alt-200 py-3"
     :class="{ 'is-fixed-bottom': showFixedNavbar }"
     v-if="hasCurrentActorPermissionsToEdit"
   >
     <div class="container mx-auto">
-      <div class="flex justify-between">
-        <div class="">
-          <span class="dark:text-gray-900" v-if="isEventModified">{{
-            $t("Unsaved changes")
-          }}</span>
-        </div>
+      <div class="flex justify-between items-center">
+        <span class="dark:text-gray-900" v-if="isEventModified">
+          {{ t("Unsaved changes") }}
+        </span>
         <div class="flex flex-wrap gap-3 items-center">
-          <span class="">
-            <o-button type="is-text" @click="confirmGoBack">{{
-              $t("Cancel")
-            }}</o-button>
-          </span>
+          <o-button
+            variant="text"
+            @click="confirmGoBack"
+            class="dark:!text-black"
+            >{{ t("Cancel") }}</o-button
+          >
           <!-- If an event has been published we can't make it draft anymore -->
           <span class="" v-if="event.draft === true">
             <o-button
@@ -473,7 +469,7 @@
               outlined
               @click="createOrUpdateDraft"
               :disabled="saving"
-              >{{ $t("Save draft") }}</o-button
+              >{{ t("Save draft") }}</o-button
             >
           </span>
           <span class="">
@@ -483,9 +479,9 @@
               @click="createOrUpdatePublish"
               @keyup.enter="createOrUpdatePublish"
             >
-              <span v-if="isUpdate === false">{{ $t("Create my event") }}</span>
-              <span v-else-if="event.draft === true">{{ $t("Publish") }}</span>
-              <span v-else>{{ $t("Update my event") }}</span>
+              <span v-if="isUpdate === false">{{ t("Create my event") }}</span>
+              <span v-else-if="event.draft === true">{{ t("Publish") }}</span>
+              <span v-else>{{ t("Update my event") }}</span>
             </o-button>
           </span>
         </div>
@@ -596,7 +592,7 @@
 <script lang="ts" setup>
 import { getTimezoneOffset } from "date-fns-tz";
 import PictureUpload from "@/components/PictureUpload.vue";
-import EditorComponent from "@/components/Editor.vue";
+import EditorComponent from "@/components/TextEditor.vue";
 import TagInput from "@/components/Event/TagInput.vue";
 import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
 import EventMetadataList from "@/components/Event/EventMetadataList.vue";
@@ -617,27 +613,33 @@ import {
   MemberRole,
   ParticipantRole,
 } from "@/types/enums";
-import OrganizerPickerWrapper from "../../components/Event/OrganizerPickerWrapper.vue";
+import OrganizerPickerWrapper from "@/components/Event/OrganizerPickerWrapper.vue";
 import {
   CREATE_EVENT,
   EDIT_EVENT,
   EVENT_PERSON_PARTICIPATION,
-} from "../../graphql/event";
+} from "@/graphql/event";
 import {
   EventModel,
   IEditableEvent,
   IEvent,
   removeTypeName,
   toEditJSON,
-} from "../../types/event.model";
-import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
-import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
+} from "@/types/event.model";
+import { LOGGED_USER_DRAFTS } from "@/graphql/actor";
+import {
+  IActor,
+  IGroup,
+  IPerson,
+  usernameWithDomain,
+  displayNameAndUsername,
+} from "@/types/actor";
 import {
   buildFileFromIMedia,
   buildFileVariable,
   readFileAsync,
-} from "../../utils/image";
-import RouteName from "../../router/name";
+} from "@/utils/image";
+import RouteName from "@/router/name";
 import "intersection-observer";
 import {
   ApolloCache,
@@ -759,7 +761,7 @@ const unmodifiedEvent = ref<IEditableEvent>(new EventModel());
 
 const pictureFile = ref<File | null>(null);
 
-const canPromote = ref(true);
+// const canPromote = ref(true);
 const limitedPlaces = ref(false);
 const showFixedNavbar = ref(true);
 
@@ -905,7 +907,7 @@ onCreateEventMutationDone(async ({ data }) => {
     message: (event.value.draft
       ? t("The event has been created as a draft")
       : t("The event has been published")) as string,
-    variant: "is-success",
+    variant: "success",
     position: "bottom-right",
     duration: 5000,
   });
@@ -964,6 +966,7 @@ onEditEventMutationError((err) => {
 const updateEvent = async (): Promise<void> => {
   saving.value = true;
   const variables = await buildVariables();
+  console.debug("update event", variables);
   editEventMutation(variables);
 };
 
@@ -1016,7 +1019,6 @@ const handleError = (err: any) => {
  */
 const postCreateOrUpdate = (store: any, updatedEvent: IEvent) => {
   const resultEvent: IEvent = { ...updatedEvent };
-  console.debug("resultEvent", resultEvent);
   if (!updatedEvent.draft) {
     store.writeQuery({
       query: EVENT_PERSON_PARTICIPATION,
@@ -1056,7 +1058,6 @@ const postCreateOrUpdate = (store: any, updatedEvent: IEvent) => {
 /**
  * Refresh drafts or participation cache depending if the event is still draft or not
  */
-// eslint-disable-next-line class-methods-use-this
 const postRefetchQueries = (
   updatedEvent: IEvent
 ): InternalRefetchQueriesInclude => {
@@ -1093,11 +1094,11 @@ const buildVariables = async () => {
     options: eventOptions.value,
   };
 
-  // const organizerActor = event.value?.organizerActor?.id
-  //   ? event.value.organizerActor
-  //   : organizerActor.value;
+  const localOrganizerActor = event.value?.organizerActor?.id
+    ? event.value.organizerActor
+    : organizerActor.value;
   if (organizerActor.value) {
-    res = { ...res, organizerActorId: organizerActor.value?.id };
+    res = { ...res, organizerActorId: localOrganizerActor?.id };
   }
   const attributedToId = event.value?.attributedTo?.id
     ? event.value?.attributedTo.id
@@ -1155,7 +1156,7 @@ const needsApproval = computed({
 
 const checkTitleLength = computed((): Array<string | undefined> => {
   return event.value.title.length > 80
-    ? ["is-info", t("The event title will be ellipsed.") as string]
+    ? ["info", t("The event title will be ellipsed.")]
     : [undefined, undefined];
 });
 
@@ -1189,7 +1190,7 @@ const confirmGoElsewhere = (): Promise<boolean> => {
       message,
       confirmText: t("Abandon editing") as string,
       cancelText: t("Continue editing") as string,
-      type: "is-warning",
+      variant: "warning",
       hasIcon: true,
       onConfirm: () => resolve(true),
       onCancel: () => resolve(false),
@@ -1356,6 +1357,13 @@ const isOnline = computed({
     };
   },
 });
+
+watch(isOnline, (newIsOnline) => {
+  if (newIsOnline === true) {
+    eventPhysicalAddress.value = null;
+  }
+});
+
 const dateFnsLocale = inject<Locale>("dateFnsLocale");
 
 const firstDayOfWeek = computed((): number => {
@@ -1366,6 +1374,15 @@ const { event: fetchedEvent, onResult: onFetchEventResult } = useFetchEvent(
   eventId.value
 );
 
+watch(
+  fetchedEvent,
+  () => {
+    if (!fetchedEvent.value) return;
+    event.value = { ...fetchedEvent.value };
+  },
+  { immediate: true }
+);
+
 onFetchEventResult((result) => {
   if (!result.loading && result.data?.event) {
     event.value = { ...result.data?.event };
diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/EventView.vue
similarity index 94%
rename from js/src/views/Event/Event.vue
rename to js/src/views/Event/EventView.vue
index bf7dcbb16..175a3be5b 100755
--- a/js/src/views/Event/Event.vue
+++ b/js/src/views/Event/EventView.vue
@@ -65,36 +65,35 @@
                   </popover-actor-card>
                 </span>
               </div>
-              <p class="flex gap-1 items-center" dir="auto">
-                <tag v-if="eventCategory" class="category" capitalize>{{
-                  eventCategory
-                }}</tag>
-                <router-link
-                  v-for="tag in event?.tags ?? []"
-                  :key="tag.title"
-                  :to="{ name: RouteName.TAG, params: { tag: tag.title } }"
-                >
-                  <tag>{{ tag.title }}</tag>
-                </router-link>
-              </p>
-              <tag variant="warning" size="is-medium" v-if="event?.draft"
-                >{{ t("Draft") }}
-              </tag>
-              <span
-                class="event-status"
-                v-if="event?.status !== EventStatus.CONFIRMED"
-              >
-                <tag
-                  variant="warning"
-                  v-if="event?.status === EventStatus.TENTATIVE"
-                  >{{ t("Event to be confirmed") }}</tag
-                >
-                <tag
-                  variant="danger"
-                  v-if="event?.status === EventStatus.CANCELLED"
-                  >{{ t("Event cancelled") }}</tag
-                >
-              </span>
+              <div class="inline-flex items-center gap-1">
+                <p v-if="event?.status !== EventStatus.CONFIRMED">
+                  <tag
+                    variant="warning"
+                    v-if="event?.status === EventStatus.TENTATIVE"
+                    >{{ t("Event to be confirmed") }}</tag
+                  >
+                  <tag
+                    variant="danger"
+                    v-if="event?.status === EventStatus.CANCELLED"
+                    >{{ t("Event cancelled") }}</tag
+                  >
+                </p>
+                <p class="flex gap-1 items-center" dir="auto">
+                  <tag v-if="eventCategory" class="category" capitalize>{{
+                    eventCategory
+                  }}</tag>
+                  <router-link
+                    v-for="tag in event?.tags ?? []"
+                    :key="tag.title"
+                    :to="{ name: RouteName.TAG, params: { tag: tag.title } }"
+                  >
+                    <tag>{{ tag.title }}</tag>
+                  </router-link>
+                </p>
+                <tag variant="warning" size="medium" v-if="event?.draft"
+                  >{{ t("Draft") }}
+                </tag>
+              </div>
             </div>
 
             <div class="">
@@ -375,7 +374,7 @@
         ref="reportModal"
         :close-button-aria-label="t('Close')"
       >
-        <report-modal
+        <ReportModal
           :on-confirm="reportEvent"
           :title="t('Report this event')"
           :outside-domain="organizerDomain"
@@ -456,7 +455,7 @@
               <o-field :label="t('Message')">
                 <o-input
                   type="textarea"
-                  size="is-medium"
+                  size="medium"
                   v-model="messageForConfirmation"
                   minlength="10"
                 ></o-input>
@@ -522,35 +521,28 @@ import {
   FETCH_EVENT,
   JOIN_EVENT,
 } from "@/graphql/event";
-import { CURRENT_ACTOR_CLIENT, PERSON_STATUS_GROUP } from "@/graphql/actor";
-import { EventModel, IEvent } from "@/types/event.model";
+import { IEvent } from "@/types/event.model";
 import {
   displayName,
   IActor,
   IPerson,
-  Person,
   usernameWithDomain,
 } from "@/types/actor";
 import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
 import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
 import MultiCard from "@/components/Event/MultiCard.vue";
 import ReportModal from "@/components/Report/ReportModal.vue";
-import { IReport } from "@/types/report.model";
-import { CREATE_REPORT } from "@/graphql/report";
-import EventMixin from "@/mixins/event";
-import IdentityPicker from "../Account/IdentityPicker.vue";
+import IdentityPicker from "@/views/Account/IdentityPicker.vue";
 import ParticipationSection from "@/components/Participation/ParticipationSection.vue";
 import RouteName from "@/router/name";
 import CommentTree from "@/components/Comment/CommentTree.vue";
 import "intersection-observer";
-import { CONFIG } from "@/graphql/config";
 import {
   AnonymousParticipationNotFoundError,
   getLeaveTokenForParticipation,
   isParticipatingInThisEvent,
   removeAnonymousParticipation,
 } from "@/services/AnonymousParticipationStorage";
-import { IConfig } from "@/types/config.model";
 import Tag from "@/components/Tag.vue";
 import EventMetadataSidebar from "@/components/Event/EventMetadataSidebar.vue";
 import EventBanner from "@/components/Event/EventBanner.vue";
@@ -560,12 +552,9 @@ import { IParticipant } from "@/types/participant.model";
 import { ApolloCache, FetchResult } from "@apollo/client/core";
 import { IEventMetadataDescription } from "@/types/event-metadata";
 import { eventMetaDataList } from "@/services/EventMetadata";
-import { USER_SETTINGS } from "@/graphql/user";
-import { IUser } from "@/types/current-user.model";
 import { useDeleteEvent, useFetchEvent } from "@/composition/apollo/event";
 import {
   computed,
-  handleError,
   onMounted,
   ref,
   watch,
@@ -601,24 +590,26 @@ import { useI18n } from "vue-i18n";
 import { useProgrammatic } from "@oruga-ui/oruga-next";
 import { Dialog } from "@/plugins/dialog";
 import { Notifier } from "@/plugins/notifier";
+import { AbsintheGraphQLErrors } from "@/types/errors.model";
+import { useHead } from "@vueuse/head";
 
 const ShareEventModal = defineAsyncComponent(
   () => import("@/components/Event/ShareEventModal.vue")
 );
 const IntegrationTwitch = defineAsyncComponent(
-  () => import("@/components/Event/Integrations/Twitch.vue")
+  () => import("@/components/Event/Integrations/TwitchIntegration.vue")
 );
 const IntegrationPeertube = defineAsyncComponent(
-  () => import("@/components/Event/Integrations/PeerTube.vue")
+  () => import("@/components/Event/Integrations/PeerTubeIntegration.vue")
 );
 const IntegrationYoutube = defineAsyncComponent(
-  () => import("@/components/Event/Integrations/YouTube.vue")
+  () => import("@/components/Event/Integrations/YouTubeIntegration.vue")
 );
 const IntegrationJitsiMeet = defineAsyncComponent(
-  () => import("@/components/Event/Integrations/JitsiMeet.vue")
+  () => import("@/components/Event/Integrations/JitsiMeetIntegration.vue")
 );
 const IntegrationEtherpad = defineAsyncComponent(
-  () => import("@/components/Event/Integrations/Etherpad.vue")
+  () => import("@/components/Event/Integrations/EtherpadIntegration.vue")
 );
 
 const props = defineProps<{
@@ -1057,8 +1048,8 @@ const triggerShare = (): void => {
         title: event.value?.title,
         url: event.value?.url,
       })
-      .then(() => console.log("Successful share"))
-      .catch((error: any) => console.log("Error sharing", error));
+      .then(() => console.debug("Successful share"))
+      .catch((error: any) => console.debug("Error sharing", error));
   } else {
     isShareModalActive.value = true;
     // send popup
@@ -1067,7 +1058,7 @@ const triggerShare = (): void => {
   // @ts-ignore-end
 };
 
-const handleErrors = (errors: any[]): void => {
+const handleErrors = (errors: AbsintheGraphQLErrors): void => {
   if (
     errors.some((error) => error.status_code === 404) ||
     errors.some(({ message }) => message.includes("has invalid value $uuid"))
@@ -1076,7 +1067,9 @@ const handleErrors = (errors: any[]): void => {
   }
 };
 
-onFetchEventError(({ graphQlErrors }) => handleErrors(graphQLErrors));
+onFetchEventError(({ graphQLErrors }) =>
+  handleErrors(graphQLErrors as AbsintheGraphQLErrors)
+);
 
 const actorIsParticipant = computed((): boolean => {
   if (actorIsOrganizer.value) return true;
@@ -1108,7 +1101,7 @@ const canManageEvent = computed((): boolean => {
   return actorIsOrganizer.value || hasGroupPrivileges.value;
 });
 
-const endDate = computed((): Date | undefined => {
+const endDate = computed((): string | undefined => {
   return event.value?.endsOn && event.value.endsOn > event.value.beginsOn
     ? event.value.endsOn
     : event.value?.beginsOn;
@@ -1211,6 +1204,11 @@ const eventCategory = computed((): string | undefined => {
     return eventCategory.id === event.value?.category;
   })?.label as string;
 });
+
+useHead({
+  title: computed(() => eventTitle.value ?? ""),
+  meta: [{ name: "description", content: eventDescription.value }],
+});
 </script>
 <style lang="scss" scoped>
 @use "@/styles/_mixins" as *;
@@ -1380,10 +1378,6 @@ a.participations-link {
   text-decoration: none;
 }
 
-.event-status .tag {
-  font-size: 1rem;
-}
-
 .no-border {
   border: 0;
   cursor: auto;
diff --git a/js/src/views/Event/GroupEvents.vue b/js/src/views/Event/GroupEvents.vue
index 79f5a55c3..fb3cc1c1b 100644
--- a/js/src/views/Event/GroupEvents.vue
+++ b/js/src/views/Event/GroupEvents.vue
@@ -70,7 +70,7 @@
                   )
                 }}
               </p>
-              <o-button type="is-text" tag="a" :href="group.url">
+              <o-button variant="text" tag="a" :href="group.url">
                 {{ $t("View the group profile on the original instance") }}
               </o-button>
             </div>
diff --git a/js/src/views/Event/MyEvents.vue b/js/src/views/Event/MyEventsView.vue
similarity index 98%
rename from js/src/views/Event/MyEvents.vue
rename to js/src/views/Event/MyEventsView.vue
index b56b0ac9b..bbe053447 100644
--- a/js/src/views/Event/MyEvents.vue
+++ b/js/src/views/Event/MyEventsView.vue
@@ -20,7 +20,9 @@
     </div>
     <!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
     <div class="wrapper flex flex-wrap gap-4 items-start">
-      <div class="event-filter text-violet-1 flex-auto md:flex-none">
+      <div
+        class="event-filter rounded p-3 flex-auto md:flex-none bg-zinc-300 dark:bg-zinc-700"
+      >
         <o-field>
           <o-switch v-model="showUpcoming">{{
             showUpcoming ? t("Upcoming events") : t("Past events")
@@ -56,10 +58,12 @@
               ? t('Showing events starting on')
               : t('Showing events before')
           "
+          labelFor="events-start-datepicker"
         >
           <o-datepicker
             v-model="dateFilter"
             :first-day-of-week="firstDayOfWeek"
+            id="events-start-datepicker"
           />
           <o-button
             @click="dateFilter = new Date()"
@@ -469,9 +473,6 @@ section {
 
   .event-filter {
     grid-area: filter;
-    background: lightgray;
-    border-radius: 5px;
-    padding: 0.75rem 1.25rem 0.25rem;
 
     // @include desktop {
     //   padding: 2rem 1.25rem;
diff --git a/js/src/views/Event/Participants.vue b/js/src/views/Event/ParticipantsView.vue
similarity index 84%
rename from js/src/views/Event/Participants.vue
rename to js/src/views/Event/ParticipantsView.vue
index 7dfdbd76c..7b4beeb78 100644
--- a/js/src/views/Event/Participants.vue
+++ b/js/src/views/Event/ParticipantsView.vue
@@ -55,16 +55,22 @@
               :key="format"
               aria-role="listitem"
               @click="
-                exportParticipants({
-                  eventId: event?.id,
-                  format,
-                })
+                exportParticipants(
+                  {
+                    eventId: event?.id,
+                    format,
+                  },
+                  { context: { type: format } }
+                )
               "
               @keyup.enter="
-                exportParticipants({
-                  eventId: event.value?.id,
-                  format,
-                })
+                exportParticipants(
+                  {
+                    eventId: event?.id,
+                    format,
+                  },
+                  { context: { type: format } }
+                )
               "
             >
               <button class="dropdown-button">
@@ -81,9 +87,9 @@
       ref="queueTable"
       detailed
       detail-key="id"
-      :checked-rows.sync="checkedRows"
+      v-model:checked-rows="checkedRows"
       checkable
-      :is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
+      :is-row-checkable="(row: IParticipant) => row.role !== ParticipantRole.CREATOR"
       checkbox-position="left"
       :show-detail-icon="false"
       :loading="participantsLoading"
@@ -100,37 +106,38 @@
       backend-sorting
       :default-sort-direction="'desc'"
       :default-sort="['insertedAt', 'desc']"
-      @page-change="(newPage) => (page = newPage)"
-      @sort="(field, order) => emit('sort', field, order)"
+      @page-change="(newPage: number) => (page = newPage)"
+      @sort="(field: string, order: string) => emit('sort', field, order)"
     >
       <o-table-column
         field="actor.preferredUsername"
         :label="t('Participant')"
         v-slot="props"
       >
-        <article class="media">
-          <figure
-            class="media-left image is-48x48"
-            v-if="props.row.actor.avatar"
-          >
-            <img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
+        <article>
+          <figure v-if="props.row.actor.avatar">
+            <img
+              class="rounded"
+              :src="props.row.actor.avatar.url"
+              alt=""
+              height="48"
+              width="48"
+            />
           </figure>
           <Incognito
             v-else-if="props.row.actor.preferredUsername === 'anonymous'"
             :size="48"
           />
           <AccountCircle v-else :size="48" />
-          <div class="media-content">
+          <div>
             <div class="prose dark:prose-invert">
               <span v-if="props.row.actor.preferredUsername !== 'anonymous'">
                 <span v-if="props.row.actor.name">{{
                   props.row.actor.name
                 }}</span
                 ><br />
-                <span class="is-size-7 has-text-grey-dark"
-                  >@{{ usernameWithDomain(props.row.actor) }}</span
-                >
-              </span>
+                >@{{ usernameWithDomain(props.row.actor) }}</span
+              >
               <span v-else>
                 {{ t("Anonymous participant") }}
               </span>
@@ -139,30 +146,30 @@
         </article>
       </o-table-column>
       <o-table-column field="role" :label="t('Role')" v-slot="props">
-        <b-tag
+        <tag
           variant="primary"
           v-if="props.row.role === ParticipantRole.CREATOR"
         >
           {{ t("Organizer") }}
-        </b-tag>
-        <b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
+        </tag>
+        <tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
           {{ t("Participant") }}
-        </b-tag>
-        <b-tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
+        </tag>
+        <tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
           {{ t("Not confirmed") }}
-        </b-tag>
-        <b-tag
+        </tag>
+        <tag
           variant="warning"
           v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
         >
           {{ t("Not approved") }}
-        </b-tag>
-        <b-tag
+        </tag>
+        <tag
           variant="danger"
           v-else-if="props.row.role === ParticipantRole.REJECTED"
         >
           {{ t("Rejected") }}
-        </b-tag>
+        </tag>
       </o-table-column>
       <o-table-column
         field="metadata.message"
@@ -250,17 +257,17 @@
 
 <script lang="ts" setup>
 import { ParticipantRole } from "@/types/enums";
-import { IParticipant } from "../../types/participant.model";
-import { IEvent, IEventParticipantStats } from "../../types/event.model";
+import { IParticipant } from "@/types/participant.model";
+import { IEvent } from "@/types/event.model";
 import {
   EXPORT_EVENT_PARTICIPATIONS,
   PARTICIPANTS,
   UPDATE_PARTICIPANT,
-} from "../../graphql/event";
-import { usernameWithDomain } from "../../types/actor";
-import { nl2br } from "../../utils/html";
-import { asyncForEach } from "../../utils/asyncForEach";
-import RouteName from "../../router/name";
+} from "@/graphql/event";
+import { usernameWithDomain } from "@/types/actor";
+import { nl2br } from "@/utils/html";
+import { asyncForEach } from "@/utils/asyncForEach";
+import RouteName from "@/router/name";
 import { useCurrentActorClient } from "@/composition/apollo/actor";
 import { useParticipantsExportFormats } from "@/composition/config";
 import { useMutation, useQuery } from "@vue/apollo-composable";
@@ -276,6 +283,7 @@ import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import Incognito from "vue-material-design-icons/Incognito.vue";
 import EmptyContent from "@/components/Utils/EmptyContent.vue";
 import { Notifier } from "@/plugins/notifier";
+import Tag from "@/components/Tag.vue";
 
 const PARTICIPANTS_PER_PAGE = 10;
 const MESSAGE_ELLIPSIS_LENGTH = 130;
@@ -286,6 +294,8 @@ const props = defineProps<{
   eventId: string;
 }>();
 
+const emit = defineEmits(["sort"]);
+
 const { t } = useI18n({ useScope: "global" });
 
 const { currentActor } = useCurrentActorClient();
@@ -307,11 +317,9 @@ const role = useRouteQuery(
   enumTransformer(ParticipantRole)
 );
 
-const limit = ref(PARTICIPANTS_PER_PAGE);
-
 const checkedRows = ref<IParticipant[]>([]);
 
-// const queueTable = ref(null);
+const queueTable = ref();
 
 const { result: participantsResult, loading: participantsLoading } = useQuery<{
   event: IEvent;
@@ -333,10 +341,10 @@ const { result: participantsResult, loading: participantsLoading } = useQuery<{
 
 const event = computed(() => participantsResult.value?.event);
 
-const participantStats = computed((): IEventParticipantStats | null => {
-  if (!event.value) return null;
-  return event.value.participantStats;
-});
+// const participantStats = computed((): IEventParticipantStats | null => {
+//   if (!event.value) return null;
+//   return event.value.participantStats;
+// });
 
 const { mutate: updateParticipant, onError: onUpdateParticipantError } =
   useMutation(UPDATE_PARTICIPANT);
@@ -373,14 +381,14 @@ const {
   onError: onExportParticipantsMutationError,
 } = useMutation(EXPORT_EVENT_PARTICIPATIONS);
 
-onExportParticipantsMutationDone(({ data }) => {
+onExportParticipantsMutationDone(({ data, context }) => {
   const link =
     window.origin +
     "/exports/" +
-    type.toLowerCase() +
+    context?.type.toLowerCase() +
     "/" +
-    exportEventParticipants;
-  console.log(link);
+    data?.exportEventParticipants;
+  console.debug(link);
   const a = document.createElement("a");
   a.style.display = "none";
   document.body.appendChild(a);
@@ -449,7 +457,7 @@ const toggleQueueDetails = (row: IParticipant): void => {
   }
 };
 
-const openDetailedRows = <Record<string, boolean>>{};
+const openDetailedRows = ref<Record<string, boolean>>({});
 </script>
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
@@ -485,21 +493,4 @@ nav.breadcrumb {
     text-decoration: none;
   }
 }
-
-button.dropdown-button {
-  &:hover {
-    background-color: #f5f5f5;
-    color: #0a0a0a;
-  }
-  width: 100%;
-  display: flex;
-  flex: 1;
-  background: white;
-  border: none;
-  cursor: pointer;
-  color: #4a4a4a;
-  font-size: 0.875rem;
-  line-height: 1.5;
-  padding: 0.375rem 1rem;
-}
 </style>
diff --git a/js/src/views/Group/Create.vue b/js/src/views/Group/CreateView.vue
similarity index 81%
rename from js/src/views/Group/Create.vue
rename to js/src/views/Group/CreateView.vue
index 0e43eeedf..6d231376b 100644
--- a/js/src/views/Group/Create.vue
+++ b/js/src/views/Group/CreateView.vue
@@ -1,6 +1,6 @@
 <template>
   <section class="container mx-auto">
-    <h1>{{ $t("Create a new group") }}</h1>
+    <h1>{{ t("Create a new group") }}</h1>
 
     <o-notification
       variant="danger"
@@ -11,7 +11,7 @@
     </o-notification>
 
     <form @submit.prevent="createGroup">
-      <o-field :label="$t('Group display name')" label-for="group-display-name">
+      <o-field :label="t('Group display name')" label-for="group-display-name">
         <o-input
           aria-required="true"
           required
@@ -22,7 +22,7 @@
 
       <div class="field">
         <label class="label" for="group-preferred-username">{{
-          $t("Federated Group Name")
+          t("Federated Group Name")
         }}</label>
         <div class="field-body">
           <o-field
@@ -40,7 +40,7 @@
               :useHtml5Validation="true"
               :validation-message="
                 group.preferredUsername
-                  ? $t(
+                  ? t(
                       'Only alphanumeric lowercased characters and underscores are supported.'
                     )
                   : null
@@ -64,7 +64,7 @@
       </div>
 
       <o-field
-        :label="$t('Description')"
+        :label="t('Description')"
         label-for="group-summary"
         :message="summaryErrors[0]"
         :type="summaryErrors[1]"
@@ -73,26 +73,26 @@
       </o-field>
 
       <div>
-        <b>{{ $t("Avatar") }}</b>
+        <b>{{ t("Avatar") }}</b>
         <picture-upload
-          :textFallback="$t('Avatar')"
+          :textFallback="t('Avatar')"
           v-model="avatarFile"
           :maxSize="avatarMaxSize"
         />
       </div>
 
       <div>
-        <b>{{ $t("Banner") }}</b>
+        <b>{{ t("Banner") }}</b>
         <picture-upload
-          :textFallback="$t('Banner')"
+          :textFallback="t('Banner')"
           v-model="bannerFile"
           :maxSize="bannerMaxSize"
         />
       </div>
 
-      <button class="button is-primary" native-type="submit">
-        {{ $t("Create my group") }}
-      </button>
+      <o-button variant="primary" native-type="submit">
+        {{ t("Create my group") }}
+      </o-button>
     </form>
   </section>
 </template>
@@ -105,7 +105,6 @@ import PictureUpload from "../../components/PictureUpload.vue";
 import { ErrorResponse } from "@/types/errors.model";
 import { ServerParseError } from "@apollo/client/link/http";
 import { useCurrentActorClient } from "@/composition/apollo/actor";
-import { useUploadLimits } from "@/composition/apollo/config";
 import { computed, inject, reactive, ref, watch } from "vue";
 import { useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
@@ -116,9 +115,9 @@ import {
   useHost,
 } from "@/composition/config";
 import { Notifier } from "@/plugins/notifier";
+import { useHead } from "@vueuse/head";
 
 const { currentActor } = useCurrentActorClient();
-const { uploadLimits } = useUploadLimits();
 
 const { t } = useI18n({ useScope: "global" });
 
@@ -146,34 +145,14 @@ const bannerMaxSize = useBannerMaxSize();
 
 const notifier = inject<Notifier>("notifier");
 
-const createGroup = async (): Promise<void> => {
-  errors.value = [];
-  fieldErrors.preferred_username = undefined;
-  fieldErrors.summary = undefined;
-  const variables = buildVariables();
-  const { onDone, onError } = useCreateGroup(variables);
+watch(
+  () => group.value.name,
+  (newGroupName) => {
+    group.value.preferredUsername = convertToUsername(newGroupName);
+  }
+);
 
-  onDone(() => {
-    notifier?.success(
-      t("Group {displayName} created", {
-        displayName: displayName(group),
-      })
-    );
-
-    router.push({
-      name: RouteName.GROUP,
-      params: { preferredUsername: usernameWithDomain(group.value) },
-    });
-  });
-
-  onError((err) => handleError(err as unknown as ErrorResponse));
-};
-
-watch(group, (newGroup) => {
-  group.value.preferredUsername = convertToUsername(newGroup.name);
-});
-
-const buildVariables = () => {
+const buildVariables = computed(() => {
   let avatarObj = {};
   let bannerObj = {};
 
@@ -212,7 +191,7 @@ const buildVariables = () => {
     ...avatarObj,
     ...bannerObj,
   };
-};
+});
 
 const handleError = (err: ErrorResponse) => {
   if (err?.networkError?.name === "ServerParseError") {
@@ -241,7 +220,7 @@ const handleError = (err: ErrorResponse) => {
 
 const summaryErrors = computed(() => {
   const message = fieldErrors.summary ? fieldErrors.summary : undefined;
-  const type = fieldErrors.summary ? "is-danger" : undefined;
+  const type = fieldErrors.summary ? "danger" : undefined;
   return [message, type];
 });
 
@@ -251,9 +230,33 @@ const preferredUsernameErrors = computed(() => {
     : t(
         "Only alphanumeric lowercased characters and underscores are supported."
       );
-  const type = fieldErrors.preferred_username ? "is-danger" : undefined;
+  const type = fieldErrors.preferred_username ? "danger" : undefined;
   return [message, type];
 });
+
+const { onDone, onError, mutate } = useCreateGroup();
+
+onDone(() => {
+  notifier?.success(
+    t("Group {displayName} created", {
+      displayName: displayName(group.value),
+    })
+  );
+
+  router.push({
+    name: RouteName.GROUP,
+    params: { preferredUsername: usernameWithDomain(group.value) },
+  });
+});
+
+onError((err) => handleError(err as unknown as ErrorResponse));
+
+const createGroup = async (): Promise<void> => {
+  errors.value = [];
+  fieldErrors.preferred_username = undefined;
+  fieldErrors.summary = undefined;
+  mutate(buildVariables.value);
+};
 </script>
 
 <style>
diff --git a/js/src/views/Group/GroupMembers.vue b/js/src/views/Group/GroupMembers.vue
index 6eef4a257..a90c87317 100644
--- a/js/src/views/Group/GroupMembers.vue
+++ b/js/src/views/Group/GroupMembers.vue
@@ -25,6 +25,7 @@
       class="container mx-auto section"
       v-if="group && isCurrentActorAGroupAdmin"
     >
+      <h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
       <form @submit.prevent="inviteMember">
         <o-field
           :label="t('Invite a new member')"
@@ -54,14 +55,14 @@
           </o-field>
         </o-field>
       </form>
-      <h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
       <o-field
+        class="my-2"
         :label="t('Status')"
         horizontal
         label-for="group-members-status-filter"
       >
         <o-select v-model="roles" id="group-members-status-filter">
-          <option value="">
+          <option :value="undefined">
             {{ t("Everything") }}
           </option>
           <option :value="MemberRole.ADMINISTRATOR">
@@ -103,7 +104,7 @@
         :default-sort-direction="'desc'"
         :default-sort="['insertedAt', 'desc']"
         @page-change="loadMoreMembers"
-        @sort="(field, order) => $emit('sort', field, order)"
+        @sort="(field: string, order: string) => emit('sort', field, order)"
       >
         <o-table-column
           field="actor.preferredUsername"
@@ -123,7 +124,7 @@
             <AccountCircle v-else :size="48" />
 
             <div class="">
-              <div class="">
+              <div class="text-start">
                 <span v-if="props.row.actor.name">{{
                   props.row.actor.name
                 }}</span
@@ -134,39 +135,39 @@
           </article>
         </o-table-column>
         <o-table-column field="role" :label="t('Role')" v-slot="props">
-          <b-tag
+          <tag
             variant="info"
             v-if="props.row.role === MemberRole.ADMINISTRATOR"
           >
             {{ t("Administrator") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="info"
             v-else-if="props.row.role === MemberRole.MODERATOR"
           >
             {{ t("Moderator") }}
-          </b-tag>
-          <b-tag v-else-if="props.row.role === MemberRole.MEMBER">
+          </tag>
+          <tag v-else-if="props.row.role === MemberRole.MEMBER">
             {{ t("Member") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="warning"
             v-else-if="props.row.role === MemberRole.NOT_APPROVED"
           >
             {{ t("Not approved") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="danger"
             v-else-if="props.row.role === MemberRole.REJECTED"
           >
             {{ t("Rejected") }}
-          </b-tag>
-          <b-tag
+          </tag>
+          <tag
             variant="warning"
             v-else-if="props.row.role === MemberRole.INVITED"
           >
             {{ t("Invited") }}
-          </b-tag>
+          </tag>
         </o-table-column>
         <o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
           <span class="has-text-centered">
@@ -253,13 +254,12 @@ import EmptyContent from "@/components/Utils/EmptyContent.vue";
 import { useHead } from "@vueuse/head";
 import { useI18n } from "vue-i18n";
 import { useMutation, useQuery } from "@vue/apollo-composable";
-import { computed, inject, ref, watch } from "vue";
+import { computed, inject, ref } from "vue";
 import {
   enumTransformer,
   integerTransformer,
   useRouteQuery,
 } from "vue-use-route-query";
-import { useRoute, useRouter } from "vue-router";
 import {
   useCurrentActorClient,
   usePersonStatusGroup,
@@ -267,6 +267,7 @@ import {
 import { formatTimeString, formatDateString } from "@/filters/datetime";
 import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
 import { Notifier } from "@/plugins/notifier";
+import Tag from "@/components/Tag.vue";
 
 const { t } = useI18n({ useScope: "global" });
 
@@ -276,9 +277,10 @@ useHead({
 
 const props = defineProps<{ preferredUsername: string }>();
 
+const emit = defineEmits(["sort"]);
+
 const { currentActor } = useCurrentActorClient();
 
-const loading = ref(true);
 const newMemberUsername = ref("");
 const inviteError = ref("");
 const page = useRouteQuery("page", 1, integerTransformer);
@@ -339,9 +341,6 @@ const inviteMember = async (): Promise<void> => {
   });
 };
 
-const router = useRouter();
-const route = useRoute();
-
 const loadMoreMembers = async (): Promise<void> => {
   await fetchMoreGroupMembers({
     // New variables
@@ -368,13 +367,13 @@ const {
   ],
 }));
 
-onRemoveMemberDone(() => {
+onRemoveMemberDone(({ context }) => {
   let message = t("The member was removed from the group {group}", {
     group: displayName(group.value),
   }) as string;
-  if (oldMember.role === MemberRole.NOT_APPROVED) {
+  if (context?.oldMember.role === MemberRole.NOT_APPROVED) {
     message = t("The membership request from {profile} was rejected", {
-      group: displayName(oldMember.actor),
+      group: displayName(context?.oldMember.actor),
     }) as string;
   }
   notifier?.success(message);
@@ -388,10 +387,15 @@ onRemoveMemberError((error) => {
 });
 
 const removeMember = (oldMember: IMember) => {
-  mutateRemoveMember({
-    groupId: group.value?.id,
-    memberId: oldMember.id,
-  });
+  mutateRemoveMember(
+    {
+      groupId: group.value?.id,
+      memberId: oldMember.id,
+    },
+    {
+      context: { oldMember },
+    }
+  );
 };
 
 const promoteMember = (member: IMember): void => {
@@ -465,7 +469,7 @@ onUpdateMutationDone(({ data, context }) => {
       successMessage = "The member role was updated to administrator";
       break;
     case MemberRole.MEMBER:
-      if (oldMember.role === MemberRole.NOT_APPROVED) {
+      if (context?.oldMember.role === MemberRole.NOT_APPROVED) {
         successMessage = "The member was approved";
       } else {
         successMessage = "The member role was updated to simple member";
@@ -488,11 +492,14 @@ const updateMember = async (
   oldMember: IMember,
   role: MemberRole
 ): Promise<void> => {
-  updateMemberMutation({
-    memberId: oldMember.id as string,
-    role,
-    oldRole: oldMember.role,
-  });
+  updateMemberMutation(
+    {
+      memberId: oldMember.id as string,
+      role,
+      oldRole: oldMember.role,
+    },
+    { context: { oldMember } }
+  );
 };
 
 const isCurrentActorAGroupAdmin = computed((): boolean => {
@@ -500,10 +507,10 @@ const isCurrentActorAGroupAdmin = computed((): boolean => {
 });
 
 const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
-  const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
+  const rolesToConsider = Array.isArray(givenRole) ? givenRole : [givenRole];
   return (
     personMemberships.value?.total > 0 &&
-    roles.includes(personMemberships.value?.elements[0].role)
+    rolesToConsider.includes(personMemberships.value?.elements[0].role)
   );
 };
 
diff --git a/js/src/views/Group/GroupSettings.vue b/js/src/views/Group/GroupSettings.vue
index bae70ac2b..d1590f87b 100644
--- a/js/src/views/Group/GroupSettings.vue
+++ b/js/src/views/Group/GroupSettings.vue
@@ -11,60 +11,60 @@
         {
           name: RouteName.GROUP_SETTINGS,
           params: { preferredUsername: usernameWithDomain(group) },
-          text: $t('Settings'),
+          text: t('Settings'),
         },
         {
           name: RouteName.GROUP_PUBLIC_SETTINGS,
           params: { preferredUsername: usernameWithDomain(group) },
-          text: $t('Group settings'),
+          text: t('Group settings'),
         },
       ]"
     />
     <o-loading :active="loading" />
     <section
-      class="container mx-auto section"
+      class="container mx-auto mb-6"
       v-if="group && isCurrentActorAGroupAdmin"
     >
-      <form @submit.prevent="updateGroup">
-        <o-field :label="$t('Group name')" label-for="group-settings-name">
+      <form @submit.prevent="updateGroup(buildVariables)" v-if="editableGroup">
+        <o-field :label="t('Group name')" label-for="group-settings-name">
           <o-input v-model="editableGroup.name" id="group-settings-name" />
         </o-field>
-        <o-field :label="$t('Group short description')">
+        <o-field :label="t('Group short description')">
           <Editor
             mode="basic"
             v-model="editableGroup.summary"
             :maxSize="500"
-            :aria-label="$t('Group description body')"
+            :aria-label="t('Group description body')"
             v-if="currentActor"
             :currentActor="currentActor"
         /></o-field>
-        <o-field :label="$t('Avatar')">
+        <o-field :label="t('Avatar')">
           <picture-upload
-            :textFallback="$t('Avatar')"
+            :textFallback="t('Avatar')"
             v-model="avatarFile"
             :defaultImage="group.avatar"
             :maxSize="avatarMaxSize"
           />
         </o-field>
 
-        <o-field :label="$t('Banner')">
+        <o-field :label="t('Banner')">
           <picture-upload
-            :textFallback="$t('Banner')"
+            :textFallback="t('Banner')"
             v-model="bannerFile"
             :defaultImage="group.banner"
             :maxSize="bannerMaxSize"
           />
         </o-field>
-        <p class="label">{{ $t("Group visibility") }}</p>
+        <p class="label">{{ t("Group visibility") }}</p>
         <div class="field">
           <o-radio
             v-model="editableGroup.visibility"
             name="groupVisibility"
             :native-value="GroupVisibility.PUBLIC"
           >
-            {{ $t("Visible everywhere on the web") }}<br />
+            {{ t("Visible everywhere on the web") }}<br />
             <small>{{
-              $t(
+              t(
                 "The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
               )
             }}</small>
@@ -75,9 +75,9 @@
             v-model="editableGroup.visibility"
             name="groupVisibility"
             :native-value="GroupVisibility.UNLISTED"
-            >{{ $t("Only accessible through link") }}<br />
+            >{{ t("Only accessible through link") }}<br />
             <small>{{
-              $t(
+              t(
                 "You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
               )
             }}</small>
@@ -86,7 +86,7 @@
             <code>{{ group.url }}</code>
             <o-tooltip
               v-if="canShowCopyButton"
-              :label="$t('URL copied to clipboard')"
+              :label="t('URL copied to clipboard')"
               :active="showCopiedTooltip"
               always
               variant="success"
@@ -103,16 +103,16 @@
           </p>
         </div>
 
-        <p class="label">{{ $t("New members") }}</p>
+        <p class="label">{{ t("New members") }}</p>
         <div class="field">
           <o-radio
             v-model="editableGroup.openness"
             name="groupOpenness"
             :native-value="Openness.OPEN"
           >
-            {{ $t("Anyone can join freely") }}<br />
+            {{ t("Anyone can join freely") }}<br />
             <small>{{
-              $t(
+              t(
                 "Anyone wanting to be a member from your group will be able to from your group page."
               )
             }}</small>
@@ -123,9 +123,9 @@
             v-model="editableGroup.openness"
             name="groupOpenness"
             :native-value="Openness.MODERATED"
-            >{{ $t("Moderate new members") }}<br />
+            >{{ t("Moderate new members") }}<br />
             <small>{{
-              $t(
+              t(
                 "Anyone can request being a member, but an administrator needs to approve the membership."
               )
             }}</small>
@@ -136,9 +136,9 @@
             v-model="editableGroup.openness"
             name="groupOpenness"
             :native-value="Openness.INVITE_ONLY"
-            >{{ $t("Manually invite new members") }}<br />
+            >{{ t("Manually invite new members") }}<br />
             <small>{{
-              $t(
+              t(
                 "The only way for your group to get new members is if an admininistrator invites them."
               )
             }}</small>
@@ -146,26 +146,26 @@
         </div>
 
         <o-field
-          :label="$t('Followers')"
-          :message="$t('Followers will receive new public events and posts.')"
+          :label="t('Followers')"
+          :message="t('Followers will receive new public events and posts.')"
         >
           <o-checkbox v-model="editableGroup.manuallyApprovesFollowers">
-            {{ $t("Manually approve new followers") }}
+            {{ t("Manually approve new followers") }}
           </o-checkbox>
         </o-field>
 
         <full-address-auto-complete
-          :label="$t('Group address')"
+          :label="t('Group address')"
           v-model="currentAddress"
           :hideMap="true"
         />
 
         <div class="flex flex-wrap gap-2 my-2">
           <o-button native-type="submit" variant="primary">{{
-            $t("Update group")
+            t("Update group")
           }}</o-button>
           <o-button @click="confirmDeleteGroup" variant="danger">{{
-            $t("Delete group")
+            t("Delete group")
           }}</o-button>
         </div>
       </form>
@@ -178,7 +178,7 @@
       </o-notification>
     </section>
     <o-notification v-else-if="!loading">
-      {{ $t("You are not an administrator for this group.") }}
+      {{ t("You are not an administrator for this group.") }}
     </o-notification>
   </div>
 </template>
@@ -187,7 +187,7 @@
 import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
 import PictureUpload from "@/components/PictureUpload.vue";
 import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
-import { Group, IGroup, usernameWithDomain, displayName } from "@/types/actor";
+import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
 import { Address, IAddress } from "@/types/address.model";
 import { ServerParseError } from "@apollo/client/link/http";
 import { ErrorResponse } from "@apollo/client/link/error";
@@ -208,13 +208,19 @@ import { Dialog } from "@/plugins/dialog";
 import { useHead } from "@vueuse/head";
 import { Notifier } from "@/plugins/notifier";
 
-const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const props = defineProps<{ preferredUsername: string }>();
 
 const { currentActor } = useCurrentActorClient();
 
-const { group, loading } = useGroup(props.preferredUsername);
+const {
+  group,
+  loading,
+  onResult: onGroupResult,
+} = useGroup(props.preferredUsername);
 
 const { t } = useI18n({ useScope: "global" });
 
@@ -231,20 +237,17 @@ const errors = ref<string[]>([]);
 
 const showCopiedTooltip = ref(false);
 
-const editableGroup = ref<IGroup>(new Group());
+const editableGroup = ref<IGroup>();
 
-const updateGroup = async (): Promise<void> => {
-  const variables = buildVariables();
-  const { onDone, onError } = useUpdateGroup(variables);
+const { onDone, onError, mutate: updateGroup } = useUpdateGroup();
 
-  onDone(() => {
-    notifier?.success(t("Group settings saved") as string);
-  });
+onDone(() => {
+  notifier?.success(t("Group settings saved"));
+});
 
-  onError((err) => {
-    handleError(err as unknown as ErrorResponse);
-  });
-};
+onError((err) => {
+  handleError(err as unknown as ErrorResponse);
+});
 
 const copyURL = async (): Promise<void> => {
   await window.navigator.clipboard.writeText(group.value?.url ?? "");
@@ -254,28 +257,40 @@ const copyURL = async (): Promise<void> => {
   }, 2000);
 };
 
-watch(group, async (oldGroup: IGroup, newGroup: IGroup) => {
-  try {
-    if (
-      oldGroup?.avatar !== undefined &&
-      oldGroup?.avatar !== newGroup?.avatar
-    ) {
-      avatarFile.value = await buildFileFromIMedia(group.value?.avatar);
-    }
-    if (
-      oldGroup?.banner !== undefined &&
-      oldGroup?.banner !== newGroup?.banner
-    ) {
-      bannerFile.value = await buildFileFromIMedia(group.value?.banner);
-    }
-  } catch (e) {
-    // Catch errors while building media
-    console.error(e);
-  }
-  editableGroup.value = { ...group.value };
+onGroupResult(({ data }) => {
+  editableGroup.value = data.group;
 });
 
-const buildVariables = () => {
+watch(
+  group,
+  async (newGroup: IGroup, oldGroup: IGroup) => {
+    console.debug("watching group");
+    if (!newGroup) return;
+    try {
+      if (
+        oldGroup?.avatar !== undefined &&
+        oldGroup?.avatar !== newGroup?.avatar
+      ) {
+        avatarFile.value = await buildFileFromIMedia(newGroup?.avatar);
+      }
+      if (
+        oldGroup?.banner !== undefined &&
+        oldGroup?.banner !== newGroup?.banner
+      ) {
+        bannerFile.value = await buildFileFromIMedia(newGroup?.banner);
+      }
+    } catch (e) {
+      // Catch errors while building media
+      console.error(e);
+    }
+    editableGroup.value = { ...newGroup };
+  },
+  {
+    immediate: true,
+  }
+);
+
+const buildVariables = computed(() => {
   let avatarObj = {};
   let bannerObj = {};
   const variables = { ...editableGroup.value };
@@ -309,7 +324,7 @@ const buildVariables = () => {
         media: {
           name: avatarFile.value?.name,
           alt: `${editableGroup.value?.preferredUsername}'s avatar`,
-          file: avatarFile,
+          file: avatarFile.value,
         },
       },
     };
@@ -321,13 +336,13 @@ const buildVariables = () => {
         media: {
           name: bannerFile.value?.name,
           alt: `${editableGroup.value?.preferredUsername}'s banner`,
-          file: bannerFile,
+          file: bannerFile.value,
         },
       },
     };
   }
   return {
-    id: group.value?.id,
+    id: group.value?.id ?? "",
     name: editableGroup.value?.name,
     summary: editableGroup.value?.summary,
     visibility: editableGroup.value?.visibility,
@@ -337,7 +352,7 @@ const buildVariables = () => {
     ...avatarObj,
     ...bannerObj,
   };
-};
+});
 
 const canShowCopyButton = computed((): boolean => {
   return window.isSecureContext;
@@ -348,7 +363,9 @@ const currentAddress = computed({
     return new Address(editableGroup.value?.physicalAddress);
   },
   set(address: IAddress) {
-    editableGroup.value.physicalAddress = address;
+    if (editableGroup.value) {
+      editableGroup.value.physicalAddress = address;
+    }
   },
 });
 
diff --git a/js/src/views/Group/Group.vue b/js/src/views/Group/GroupView.vue
similarity index 95%
rename from js/src/views/Group/Group.vue
rename to js/src/views/Group/GroupView.vue
index 927ff8454..2ab2eb47e 100644
--- a/js/src/views/Group/Group.vue
+++ b/js/src/views/Group/GroupView.vue
@@ -21,7 +21,7 @@
           <div class="flex self-center h-0 mt-4 items-end">
             <figure class="" v-if="group.avatar">
               <img
-                class="rounded-full border"
+                class="rounded-full border h-32 w-32"
                 :src="group.avatar.url"
                 alt=""
                 width="128"
@@ -382,7 +382,7 @@
       </header>
     </div>
     <div
-      v-if="isCurrentActorAGroupMember && !previewPublic"
+      v-if="isCurrentActorAGroupMember && !previewPublic && group"
       class="block-container flex gap-2 flex-wrap mt-3"
     >
       <!-- Private things -->
@@ -419,14 +419,20 @@
       <aside class="group-metadata">
         <div class="sticky">
           <o-notification v-if="group.domain && !isCurrentActorAGroupMember">
-            {{
-              t(
-                "This profile is from another instance, the informations shown here may be incomplete."
-              )
-            }}
-            <a :href="group.url" rel="noopener noreferrer external">{{
-              t("View full profile")
-            }}</a>
+            <p>
+              {{
+                t(
+                  "This profile is from another instance, the informations shown here may be incomplete."
+                )
+              }}
+            </p>
+            <o-button
+              variant="text"
+              tag="a"
+              :href="group.url"
+              rel="noopener noreferrer external"
+              >{{ t("View full profile") }}</o-button
+            >
           </o-notification>
           <event-metadata-block
             :title="t('About')"
@@ -480,7 +486,7 @@
                 </div>
                 <o-button
                   class="map-show-button"
-                  type="is-text"
+                  variant="text"
                   @click="showMap = !showMap"
                   @keyup.enter="showMap = !showMap"
                   v-if="physicalAddress.geom"
@@ -528,7 +534,7 @@
               <o-button
                 tag="router-link"
                 class="my-2 self-center"
-                type="is-text"
+                variant="text"
                 :to="{
                   name: RouteName.GROUP_EVENTS,
                   params: { preferredUsername: usernameWithDomain(group) },
@@ -543,7 +549,7 @@
             <o-button
               tag="router-link"
               class="my-4"
-              type="is-text"
+              variant="text"
               v-if="organizedEvents.total > 0"
               :to="{
                 name: RouteName.GROUP_EVENTS,
@@ -579,7 +585,7 @@
             class="self-center my-2"
             v-if="posts.total > 0"
             tag="router-link"
-            type="is-text"
+            variant="text"
             :to="{
               name: RouteName.POSTS,
               params: { preferredUsername: usernameWithDomain(group) },
@@ -606,6 +612,7 @@
     </div>
     <o-modal v-if="group" v-model:active="isReportModalActive">
       <report-modal
+        ref="reportModalRef"
         :on-confirm="reportGroup"
         :title="t('Report this group')"
         :outside-domain="group.domain"
@@ -637,7 +644,7 @@ import { JOIN_GROUP } from "@/graphql/member";
 import { MemberRole, Openness, PostVisibility } from "@/types/enums";
 import { IMember } from "@/types/actor/member.model";
 import RouteName from "../../router/name";
-import ReportModal from "../../components/Report/ReportModal.vue";
+import ReportModal from "@/components/Report/ReportModal.vue";
 import {
   GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
   PERSON_STATUS_GROUP,
@@ -648,11 +655,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
 import { Paginate } from "@/types/paginate";
 import { IEvent } from "@/types/event.model";
 import { IPost } from "@/types/post.model";
-import {
-  FOLLOW_GROUP,
-  UNFOLLOW_GROUP,
-  UPDATE_GROUP_FOLLOW,
-} from "@/graphql/followers";
+import { FOLLOW_GROUP, UPDATE_GROUP_FOLLOW } from "@/graphql/followers";
 import { useAnonymousReportsConfig } from "../../composition/apollo/config";
 import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
 import { useCurrentActorClient } from "@/composition/apollo/actor";
@@ -670,10 +673,10 @@ import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.v
 import { useI18n } from "vue-i18n";
 import { useCreateReport } from "@/composition/apollo/report";
 import { useHead } from "@vueuse/head";
-import Discussions from "@/components/Group/Sections/Discussions.vue";
-import Resources from "@/components/Group/Sections/Resources.vue";
-import Posts from "@/components/Group/Sections/Posts.vue";
-import Events from "@/components/Group/Sections/Events.vue";
+import Discussions from "@/components/Group/Sections/DiscussionsSection.vue";
+import Resources from "@/components/Group/Sections/ResourcesSection.vue";
+import Posts from "@/components/Group/Sections/PostsSection.vue";
+import Events from "@/components/Group/Sections/EventsSection.vue";
 import { Dialog } from "@/plugins/dialog";
 import { Notifier } from "@/plugins/notifier";
 
@@ -718,28 +721,32 @@ subscribeToMore<{ actorId: string; group: string }>({
 const person = computed(() => result.value?.person);
 
 const MapLeaflet = defineAsyncComponent(
-  () => import("../../components/Map.vue")
+  () => import("@/components/LeafletMap.vue")
 );
 const ShareGroupModal = defineAsyncComponent(
-  () => import("../../components/Group/ShareGroupModal.vue")
+  () => import("@/components/Group/ShareGroupModal.vue")
 );
 
 const showMap = ref(false);
 const isReportModalActive = ref(false);
+const reportModalRef = ref();
 const isShareModalActive = ref(false);
 const previewPublic = ref(false);
 
 const notifier = inject<Notifier>("notifier");
 
-watch(currentActor, (watchedCurrentActor: IActor, oldActor: IActor) => {
-  if (
-    watchedCurrentActor.id &&
-    oldActor &&
-    watchedCurrentActor.id !== oldActor.id
-  ) {
-    refetchGroup();
+watch(
+  currentActor,
+  (watchedCurrentActor: IActor | undefined, oldActor: IActor | undefined) => {
+    if (
+      watchedCurrentActor?.id &&
+      oldActor &&
+      watchedCurrentActor?.id !== oldActor.id
+    ) {
+      refetchGroup();
+    }
   }
-});
+);
 
 const { mutate: joinGroupMutation, onError: onJoinGroupError } =
   useMutation(JOIN_GROUP);
@@ -785,7 +792,7 @@ const dialog = inject<Dialog>("dialog");
 
 const openLeaveGroupModal = async (): Promise<void> => {
   dialog?.confirm({
-    type: "danger",
+    variant: "danger",
     title: t("Leave group"),
     message: t(
       "Are you sure you want to leave the group {groupName}? You'll loose access to this group's private content. This action cannot be undone.",
@@ -902,7 +909,7 @@ const toggleFollowNotify = () => {
 
 const reportGroup = async (content: string, forward: boolean) => {
   isReportModalActive.value = false;
-  reportModal.value.close();
+  reportModalRef.value.close();
 
   const {
     mutate: createReportMutation,
@@ -937,8 +944,8 @@ const triggerShare = (): void => {
         title: displayName(group.value),
         url: group.value?.url,
       })
-      .then(() => console.log("Successful share"))
-      .catch((error: any) => console.log("Error sharing", error));
+      .then(() => console.debug("Successful share"))
+      .catch((error: any) => console.debug("Error sharing", error));
   } else {
     isShareModalActive.value = true;
     // send popup
@@ -1100,7 +1107,7 @@ const isCurrentActorAPendingGroupMember = computed((): boolean => {
 });
 
 const currentActorFollow = computed((): IFollower | undefined => {
-  if (person?.value && person?.value?.follows?.total > 0) {
+  if (person?.value?.follows?.total && person?.value?.follows?.total > 0) {
     return person?.value?.follows?.elements[0];
   }
   return undefined;
diff --git a/js/src/views/Group/Settings.vue b/js/src/views/Group/SettingsView.vue
similarity index 95%
rename from js/src/views/Group/Settings.vue
rename to js/src/views/Group/SettingsView.vue
index 0d39aca36..b12325d34 100644
--- a/js/src/views/Group/Settings.vue
+++ b/js/src/views/Group/SettingsView.vue
@@ -2,7 +2,7 @@
   <div class="container mx-auto">
     <h1 class="">{{ t("Settings") }}</h1>
     <div class="flex flex-wrap gap-2">
-      <aside class="max-w-xs flex-1">
+      <aside class="sm:max-w-xs flex-1 min-w-[320px]">
         <ul>
           <SettingMenuSection
             :title="t('Settings')"
diff --git a/js/src/views/Group/Timeline.vue b/js/src/views/Group/TimelineView.vue
similarity index 86%
rename from js/src/views/Group/Timeline.vue
rename to js/src/views/Group/TimelineView.vue
index 7d97b20f6..fdc66aab1 100644
--- a/js/src/views/Group/Timeline.vue
+++ b/js/src/views/Group/TimelineView.vue
@@ -11,7 +11,7 @@
         {
           name: RouteName.TIMELINE,
           params: { preferredUsername: usernameWithDomain(group) },
-          text: $t('Activity'),
+          text: t('Activity'),
         },
       ]"
     />
@@ -20,51 +20,51 @@
       <o-field>
         <o-radio v-model="activityType" :native-value="undefined">
           <TimelineText />
-          {{ $t("All activities") }}</o-radio
+          {{ t("All activities") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.MEMBER">
           <o-icon icon="account-multiple-plus"></o-icon>
-          {{ $t("Members") }}</o-radio
+          {{ t("Members") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.GROUP">
           <o-icon icon="cog"></o-icon>
-          {{ $t("Settings") }}</o-radio
+          {{ t("Settings") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.EVENT">
           <o-icon icon="calendar"></o-icon>
-          {{ $t("Events") }}</o-radio
+          {{ t("Events") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.POST">
           <o-icon icon="bullhorn"></o-icon>
-          {{ $t("Posts") }}</o-radio
+          {{ t("Posts") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.DISCUSSION">
           <o-icon icon="chat"></o-icon>
-          {{ $t("Discussions") }}</o-radio
+          {{ t("Discussions") }}</o-radio
         >
         <o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
           <o-icon icon="link"></o-icon>
-          {{ $t("Resources") }}</o-radio
+          {{ t("Resources") }}</o-radio
         >
       </o-field>
       <o-field>
         <o-radio v-model="activityAuthor" :native-value="undefined">
           <TimelineText />
-          {{ $t("All activities") }}</o-radio
+          {{ t("All activities") }}</o-radio
         >
         <o-radio
           v-model="activityAuthor"
           :native-value="ActivityAuthorFilter.SELF"
         >
           <o-icon icon="account"></o-icon>
-          {{ $t("From yourself") }}</o-radio
+          {{ t("From yourself") }}</o-radio
         >
         <o-radio
           v-model="activityAuthor"
           :native-value="ActivityAuthorFilter.BY"
         >
           <o-icon icon="account-multiple"></o-icon>
-          {{ $t("By others") }}</o-radio
+          {{ t("By others") }}</o-radio
         >
       </o-field>
       <transition-group name="timeline-list" tag="div">
@@ -78,25 +78,20 @@
             width="300px"
             height="48px"
           />
-          <h2 class="is-size-3 has-text-weight-bold" v-else-if="isToday(date)">
+          <h2 v-else-if="isToday(date)">
             <span v-tooltip="formatDateString(date)">
-              {{ $t("Today") }}
+              {{ t("Today") }}
             </span>
           </h2>
-          <h2
-            class="is-size-3 has-text-weight-bold"
-            v-else-if="isYesterday(date)"
-          >
-            <span v-tooltip="formatDateString(date)">{{
-              $t("Yesterday")
-            }}</span>
+          <h2 v-else-if="isYesterday(date)">
+            <span v-tooltip="formatDateString(date)">{{ t("Yesterday") }}</span>
           </h2>
-          <h2 v-else class="is-size-3 has-text-weight-bold">
+          <h2 v-else>
             {{ formatDateString(date) }}
           </h2>
           <ul>
             <li v-for="activityItem in activityItems" :key="activityItem.id">
-              <skeleton-activity-item v-if="activityItem.skeleton" />
+              <skeleton-activity-item v-if="activityItem.type === 'skeleton'" />
               <component
                 v-else
                 :is="component(activityItem.type)"
@@ -113,14 +108,14 @@
           activity.elements.length >= activity.total
         "
       >
-        {{ $t("No more activity to display.") }}
+        {{ t("No more activity to display.") }}
       </empty-content>
       <empty-content
         v-if="!loading && activity.total === 0"
         icon="timeline-text"
       >
         {{
-          $t(
+          t(
             "There is no activity yet. Start doing some things to see activity appear here."
           )
         }}
@@ -129,7 +124,7 @@
       <o-button
         v-if="activity.elements.length < activity.total"
         @click="loadMore"
-        >{{ $t("Load more activities") }}</o-button
+        >{{ t("Load more activities") }}</o-button
       >
     </section>
   </div>
@@ -154,14 +149,16 @@ import { formatDateString } from "@/filters/datetime";
 const PAGINATION_LIMIT = 25;
 const SKELETON_DAY_ITEMS = 2;
 const SKELETON_ITEMS_PER_DAY = 5;
-type IActivitySkeleton = IActivity | { skeleton: string };
+type IActivitySkeleton =
+  | IActivity
+  | { skeleton: string; id: string; type: "skeleton" };
 
 enum ActivityAuthorFilter {
   SELF = "SELF",
   BY = "BY",
 }
 
-type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
+// type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
 
 const props = defineProps<{ preferredUsername: string }>();
 
@@ -230,12 +227,12 @@ const activity = computed((): Paginate<IActivitySkeleton> => {
     total: 0,
     elements: skeletons.value.map((skeleton) => ({
       skeleton,
+      id: skeleton,
+      type: "skeleton",
     })),
   };
 });
 
-const limit = PAGINATION_LIMIT;
-
 const component = (type: ActivityType): any | undefined => {
   switch (type) {
     case ActivityType.EVENT:
@@ -309,11 +306,11 @@ const activities = computed((): Record<string, IActivitySkeleton[]> => {
 const isIActivity = (object: IActivitySkeleton): object is IActivity => {
   return !("skeleton" in object);
 };
-const getRandomInt = (min: number, max: number): number => {
-  min = Math.ceil(min);
-  max = Math.floor(max);
-  return Math.floor(Math.random() * (max - min) + min);
-};
+// const getRandomInt = (min: number, max: number): number => {
+//   min = Math.ceil(min);
+//   max = Math.floor(max);
+//   return Math.floor(Math.random() * (max - min) + min);
+// };
 
 const isToday = (dateString: string): boolean => {
   const now = new Date();
diff --git a/js/src/views/HomeView.vue b/js/src/views/HomeView.vue
index 3bc0a8895..d6dedd4d2 100644
--- a/js/src/views/HomeView.vue
+++ b/js/src/views/HomeView.vue
@@ -130,7 +130,8 @@
   <!-- Recent events -->
   <CloseEvents @doGeoLoc="performGeoLocation()" :userLocation="userLocation" />
   <CloseGroups :userLocation="userLocation" @doGeoLoc="performGeoLocation()" />
-  <LastEvents v-if="config" :instanceName="config.name" />
+  <OnlineEvents />
+  <LastEvents v-if="instanceName" :instanceName="instanceName" />
   <!-- Unlogged content section -->
   <picture v-if="!currentUser?.isLoggedIn">
     <source
@@ -169,30 +170,23 @@
 </template>
 
 <script lang="ts" setup>
-import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
-import { Paginate } from "@/types/paginate";
+import { ParticipantRole } from "@/types/enums";
 import { IParticipant } from "../types/participant.model";
-import { FETCH_EVENTS } from "../graphql/event";
 import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
 import MultiCard from "../components/Event/MultiCard.vue";
 import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
 import { IPerson, displayName } from "../types/actor";
-import {
-  ICurrentUser,
-  IUser,
-  IUserSettings,
-} from "../types/current-user.model";
+import { ICurrentUser, IUser } from "../types/current-user.model";
 import { CURRENT_USER_CLIENT } from "../graphql/user";
 import { HOME_USER_QUERIES } from "../graphql/home";
 import RouteName from "../router/name";
 import { IEvent } from "../types/event.model";
-import { CONFIG } from "../graphql/config";
-import { IConfig } from "../types/config.model";
 // import { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
 import CloseEvents from "@/components/Local/CloseEvents.vue";
 import CloseGroups from "@/components/Local/CloseGroups.vue";
 import LastEvents from "@/components/Local/LastEvents.vue";
-import { computed, onMounted, reactive, watch } from "vue";
+import OnlineEvents from "@/components/Local/OnlineEvents.vue";
+import { computed, onMounted, reactive, ref, watch } from "vue";
 import { useMutation, useQuery } from "@vue/apollo-composable";
 import { useRouter } from "vue-router";
 import { REVERSE_GEOCODE } from "@/graphql/address";
@@ -208,17 +202,18 @@ import UnloggedIntroduction from "@/components/Home/UnloggedIntroduction.vue";
 import SearchFields from "@/components/Home/SearchFields.vue";
 import { useHead } from "@vueuse/head";
 import { geoHashToCoords } from "@/utils/location";
+import { useServerProvidedLocation } from "@/composition/apollo/config";
+import { ABOUT } from "@/graphql/config";
+import { IConfig } from "@/types/config.model";
 
-const { result: resultEvents } = useQuery<{ events: Paginate<IEvent> }>(
-  FETCH_EVENTS,
-  {
-    orderBy: EventSortField.INSERTED_AT,
-    direction: SortDirection.DESC,
-  }
-);
-const events = computed(
-  () => resultEvents.value?.events || { total: 0, elements: [] }
-);
+const { result: aboutConfigResult } = useQuery<{
+  config: Pick<
+    IConfig,
+    "name" | "description" | "slogan" | "registrationsOpen"
+  >;
+}>(ABOUT);
+
+const config = computed(() => aboutConfigResult.value?.config);
 
 const { result: currentActorResult } = useQuery<{ currentActor: IPerson }>(
   CURRENT_ACTOR_CLIENT
@@ -233,9 +228,7 @@ const { result: currentUserResult } = useQuery<{
 
 const currentUser = computed(() => currentUserResult.value?.currentUser);
 
-const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
-
-const config = computed<IConfig | undefined>(() => configResult.value?.config);
+const instanceName = computed(() => config.value?.name);
 
 const { result: userResult } = useQuery<{ loggedUser: IUser }>(
   HOME_USER_QUERIES,
@@ -254,14 +247,8 @@ const currentUserParticipations = computed(
   () => loggedUser.value?.participations.elements
 );
 
-const instanceName = computed((): string | undefined => config.value?.name);
-const welcomeBack = computed<boolean>(
-  () => window.localStorage.getItem("welcome-back") === "yes"
-);
-
-const newRegisteredUser = computed<boolean>(
-  () => window.localStorage.getItem("new-registered-user") === "yes"
-);
+const location = ref(null);
+const search = ref("");
 
 const isToday = (date: string): boolean => {
   return new Date(date).toDateString() === new Date().toDateString();
@@ -338,10 +325,6 @@ const goingToEvents = computed<Map<string, Map<string, IParticipant>>>(() => {
   );
 });
 
-const loggedUserSettings = computed<IUserSettings | undefined>(() => {
-  return loggedUser.value?.settings;
-});
-
 const canShowMyUpcomingEvents = computed<boolean>(() => {
   return currentActor.value?.id != undefined && goingToEvents.value.size > 0;
 });
@@ -362,11 +345,16 @@ const filteredFollowedGroupsEvents = computed<IEvent[]>(() => {
     .slice(0, 4);
 });
 
+const welcomeBack = ref(false);
+const newRegisteredUser = ref(false);
+
 onMounted(() => {
   if (window.localStorage.getItem("welcome-back")) {
+    welcomeBack.value = true;
     window.localStorage.removeItem("welcome-back");
   }
   if (window.localStorage.getItem("new-registered-user")) {
+    newRegisteredUser.value = true;
     window.localStorage.removeItem("new-registered-user");
   }
 });
@@ -393,7 +381,7 @@ const userSettingsLocationGeoHash = computed(
 );
 
 // The location provided by the server
-const serverLocation = computed(() => config.value?.location);
+const { location: serverLocation } = useServerProvidedLocation();
 
 // The coords from the user location or the server provided location
 const coords = computed(() => {
@@ -496,7 +484,8 @@ onReverseGeocodeResult((result) => {
 
 const fetchAndSaveCurrentLocationName = async ({
   coords: { latitude, longitude, accuracy },
-}: GeolocationPosition) => {
+}: // eslint-disable-next-line no-undef
+GeolocationPosition) => {
   reverseGeoCodeInformation.latitude = latitude;
   reverseGeoCodeInformation.longitude = longitude;
   reverseGeoCodeInformation.accuracy = accuracy;
diff --git a/js/src/views/Interact.vue b/js/src/views/InteractView.vue
similarity index 93%
rename from js/src/views/Interact.vue
rename to js/src/views/InteractView.vue
index c18d71f5a..e8be781d7 100644
--- a/js/src/views/Interact.vue
+++ b/js/src/views/InteractView.vue
@@ -35,13 +35,12 @@ import RouteName from "../router/name";
 import { GraphQLError } from "graphql";
 import { useQuery } from "@vue/apollo-composable";
 import { computed, reactive } from "vue";
-import { useRoute, useRouter } from "vue-router";
+import { useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { useRouteQuery } from "vue-use-route-query";
 import { useHead } from "@vueuse/head";
 
 const router = useRouter();
-const route = useRoute();
 const { t } = useI18n({ useScope: "global" });
 
 const uri = useRouteQuery("uri", "");
@@ -49,9 +48,9 @@ const uri = useRouteQuery("uri", "");
 const isURI = computed((): boolean => {
   try {
     const url = new URL(uri.value);
-    return !(url instanceof URL);
+    return url instanceof URL;
   } catch (e) {
-    return true;
+    return false;
   }
 });
 
@@ -65,7 +64,7 @@ const { onResult, onError, loading } = useQuery<{
     uri: uri.value,
   }),
   () => ({
-    enabled: isURI.value !== false,
+    enabled: isURI.value === true,
   })
 );
 
diff --git a/js/src/views/Moderation/Logs.vue b/js/src/views/Moderation/LogsView.vue
similarity index 100%
rename from js/src/views/Moderation/Logs.vue
rename to js/src/views/Moderation/LogsView.vue
diff --git a/js/src/views/Moderation/Report.vue b/js/src/views/Moderation/Report.vue
deleted file mode 100644
index a368acc35..000000000
--- a/js/src/views/Moderation/Report.vue
+++ /dev/null
@@ -1,526 +0,0 @@
-<template>
-  <div>
-    <breadcrumbs-nav
-      v-if="report"
-      :links="[
-        {
-          name: RouteName.MODERATION,
-          text: t('Moderation'),
-        },
-        {
-          name: RouteName.REPORTS,
-          text: t('Reports'),
-        },
-        {
-          name: RouteName.REPORT,
-          params: { id: report.id },
-          text: t('Report #{reportNumber}', { reportNumber: report.id }),
-        },
-      ]"
-    />
-    <section>
-      <o-notification
-        title="Error"
-        variant="danger"
-        v-for="error in errors"
-        :key="error"
-      >
-        {{ error }}
-      </o-notification>
-      <div class="container mx-auto" v-if="report">
-        <div class="flex flex-wrap gap-2">
-          <o-button
-            v-if="report.status !== ReportStatusEnum.RESOLVED"
-            @click="updateReport(ReportStatusEnum.RESOLVED)"
-            variant="primary"
-            >{{ t("Mark as resolved") }}</o-button
-          >
-          <o-button
-            v-if="report.status !== ReportStatusEnum.OPEN"
-            @click="updateReport(ReportStatusEnum.OPEN)"
-            variant="success"
-            >{{ t("Reopen") }}</o-button
-          >
-          <o-button
-            v-if="report.status !== ReportStatusEnum.CLOSED"
-            @click="updateReport(ReportStatusEnum.CLOSED)"
-            variant="danger"
-            >{{ t("Close") }}</o-button
-          >
-        </div>
-        <div class="w-full">
-          <table class="table w-full">
-            <tbody>
-              <tr v-if="report.reported.__typename === 'Group'">
-                <td>{{ t("Reported group") }}</td>
-                <td>
-                  <router-link
-                    :to="{
-                      name: RouteName.ADMIN_GROUP_PROFILE,
-                      params: { id: report.reported.id },
-                    }"
-                  >
-                    <img
-                      v-if="report.reported.avatar"
-                      class="image"
-                      :src="report.reported.avatar.url"
-                      alt=""
-                    />
-                    {{ displayNameAndUsername(report.reported) }}
-                  </router-link>
-                </td>
-              </tr>
-              <tr v-else>
-                <td>
-                  {{ t("Reported identity") }}
-                </td>
-                <td>
-                  <router-link
-                    :to="{
-                      name: RouteName.ADMIN_PROFILE,
-                      params: { id: report.reported.id },
-                    }"
-                  >
-                    <img
-                      v-if="report.reported.avatar"
-                      class="image"
-                      :src="report.reported.avatar.url"
-                      alt=""
-                    />
-                    {{ displayNameAndUsername(report.reported) }}
-                  </router-link>
-                </td>
-              </tr>
-              <tr>
-                <td>{{ t("Reported by") }}</td>
-                <td v-if="report.reporter.type === ActorType.APPLICATION">
-                  {{ report.reporter.domain }}
-                </td>
-                <td v-else>
-                  <router-link
-                    :to="{
-                      name: RouteName.ADMIN_PROFILE,
-                      params: { id: report.reporter.id },
-                    }"
-                  >
-                    <img
-                      v-if="report.reporter.avatar"
-                      class="image"
-                      :src="report.reporter.avatar.url"
-                      alt=""
-                    />
-                    {{ displayNameAndUsername(report.reporter) }}
-                  </router-link>
-                </td>
-              </tr>
-              <tr>
-                <td>{{ t("Reported") }}</td>
-                <td>{{ formatDateTimeString(report.insertedAt) }}</td>
-              </tr>
-              <tr v-if="report.updatedAt !== report.insertedAt">
-                <td>{{ t("Updated") }}</td>
-                <td>{{ formatDateTimeString(report.updatedAt) }}</td>
-              </tr>
-              <tr>
-                <td>{{ t("Status") }}</td>
-                <td>
-                  <span v-if="report.status === ReportStatusEnum.OPEN">{{
-                    t("Open")
-                  }}</span>
-                  <span v-else-if="report.status === ReportStatusEnum.CLOSED">
-                    {{ t("Closed") }}
-                  </span>
-                  <span v-else-if="report.status === ReportStatusEnum.RESOLVED">
-                    {{ t("Resolved") }}
-                  </span>
-                  <span v-else>{{ t("Unknown") }}</span>
-                </td>
-              </tr>
-              <tr v-if="report.event && report.comments.length > 0">
-                <td>{{ t("Event") }}</td>
-                <td>
-                  <router-link
-                    :to="{
-                      name: RouteName.EVENT,
-                      params: { uuid: report.event.uuid },
-                    }"
-                  >
-                    {{ report.event.title }}
-                  </router-link>
-                  <span class="is-pulled-right">
-                    <!--                                    <o-button-->
-                    <!--                                            tag="router-link"-->
-                    <!--                                            variant="primary"-->
-                    <!--                                            :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
-                    <!--                                            icon-left="pencil"-->
-                    <!--                                            size="small">{{ t('Edit') }}</o-button>-->
-                    <o-button
-                      variant="danger"
-                      @click="confirmEventDelete()"
-                      icon-left="delete"
-                      size="small"
-                      >{{ t("Delete") }}</o-button
-                    >
-                  </span>
-                </td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-
-        <div class="">
-          <p v-if="report.content" v-html="nl2br(report.content)" />
-          <p v-else>{{ t("No comment") }}</p>
-        </div>
-
-        <div class="" v-if="report.event && report.comments.length === 0">
-          <router-link
-            :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid } }"
-          >
-            <h3 class="title">{{ report.event.title }}</h3>
-            <p v-html="report.event.description" />
-          </router-link>
-          <!--                <o-button-->
-          <!--                        tag="router-link"-->
-          <!--                        variant="primary"-->
-          <!--                        :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
-          <!--                        icon-left="pencil"-->
-          <!--                        size="small">{{ t('Edit') }}</o-button>-->
-          <o-button
-            variant="danger"
-            @click="confirmEventDelete()"
-            icon-left="delete"
-            size="small"
-            >{{ t("Delete") }}</o-button
-          >
-        </div>
-
-        <div v-if="report.comments.length > 0">
-          <ul v-for="comment in report.comments" :key="comment.id">
-            <li>
-              <div class="" v-if="comment">
-                <article class="flex gap-1">
-                  <div class="">
-                    <figure
-                      class=""
-                      v-if="comment.actor && comment.actor.avatar"
-                    >
-                      <img
-                        :src="comment.actor.avatar.url"
-                        alt=""
-                        width="48"
-                        height="48"
-                      />
-                    </figure>
-                    <AccountCircle :size="48" v-else />
-                  </div>
-                  <div class="">
-                    <div class="prose dark:prose-invert">
-                      <span v-if="comment.actor">
-                        <strong>{{ comment.actor.name }}</strong>
-                        <small>@{{ comment.actor.preferredUsername }}</small>
-                      </span>
-                      <span v-else>{{ t("Unknown actor") }}</span>
-                      <br />
-                      <p v-html="comment.text" />
-                    </div>
-                    <o-button
-                      variant="danger"
-                      @click="confirmCommentDelete(comment)"
-                      icon-left="delete"
-                      size="small"
-                      >{{ t("Delete") }}</o-button
-                    >
-                  </div>
-                </article>
-              </div>
-            </li>
-          </ul>
-        </div>
-
-        <h2 v-if="report.notes.length > 0">{{ t("Notes") }}</h2>
-        <div
-          class="box note"
-          v-for="note in report.notes"
-          :id="`note-${note.id}`"
-          :key="note.id"
-        >
-          <p>{{ note.content }}</p>
-          <router-link
-            :to="{
-              name: RouteName.ADMIN_PROFILE,
-              params: { id: note.moderator.id },
-            }"
-          >
-            <img
-              alt=""
-              class="rounded-full"
-              :src="note.moderator.avatar.url"
-              v-if="note.moderator.avatar"
-            />
-            @{{ note.moderator.preferredUsername }}
-          </router-link>
-          <br />
-          <small>
-            <a :href="`#note-${note.id}`" v-if="note.insertedAt">
-              {{ formatDateTimeString(note.insertedAt) }}
-            </a>
-          </small>
-        </div>
-
-        <form
-          @submit="
-            createReportNoteMutation({
-              reportId: report?.id,
-              content: noteContent,
-            })
-          "
-        >
-          <o-field :label="t('New note')" label-for="newNoteInput">
-            <o-input
-              type="textarea"
-              v-model="noteContent"
-              id="newNoteInput"
-            ></o-input>
-          </o-field>
-          <o-button class="mt-2" type="submit">{{ t("Add a note") }}</o-button>
-        </form>
-      </div>
-    </section>
-  </div>
-</template>
-<script lang="ts" setup>
-import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report";
-import { IReport, IReportNote } from "@/types/report.model";
-import { displayNameAndUsername } from "@/types/actor";
-import { DELETE_EVENT } from "@/graphql/event";
-import uniq from "lodash/uniq";
-import { nl2br } from "@/utils/html";
-import { DELETE_COMMENT } from "@/graphql/comment";
-import { IComment } from "@/types/comment.model";
-import { ActorType, ReportStatusEnum } from "@/types/enums";
-import RouteName from "@/router/name";
-import { GraphQLError } from "graphql";
-import { ApolloCache, FetchResult } from "@apollo/client/core";
-import { useMutation, useQuery } from "@vue/apollo-composable";
-import { useCurrentActorClient } from "@/composition/apollo/actor";
-import { useHead } from "@vueuse/head";
-import { useI18n } from "vue-i18n";
-import { useRouter } from "vue-router";
-import { ref, computed, inject } from "vue";
-import { formatDateTimeString } from "@/filters/datetime";
-import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
-import { Dialog } from "@/plugins/dialog";
-import { Notifier } from "@/plugins/notifier";
-
-const router = useRouter();
-
-const props = defineProps<{ reportId: string }>();
-
-const { currentActor } = useCurrentActorClient();
-
-const { result: reportResult, onError: onReportQueryError } = useQuery<{
-  report: IReport;
-}>(REPORT, () => ({
-  id: props.reportId,
-}));
-
-const report = computed(() => reportResult.value?.report);
-
-onReportQueryError(({ graphQLErrors }) => {
-  errors.value = uniq(
-    graphQLErrors.map(({ message }: GraphQLError) => message)
-  );
-});
-
-const { t } = useI18n({ useScope: "global" });
-
-useHead({
-  title: computed(() => t("Report")),
-});
-
-const notifier = inject<Notifier>("notifier");
-
-const errors = ref<string[]>([]);
-
-const noteContent = ref("");
-
-const {
-  mutate: createReportNoteMutation,
-  onDone: createReportNoteMutationDone,
-  onError: createReportNoteMutationError,
-} = useMutation<{
-  createReportNote: IReportNote;
-}>(CREATE_REPORT_NOTE, () => ({
-  update: (
-    store: ApolloCache<{ createReportNote: IReportNote }>,
-    { data }: FetchResult
-  ) => {
-    if (data == null) return;
-    const cachedData = store.readQuery<{ report: IReport }>({
-      query: REPORT,
-      variables: { id: report.value.id },
-    });
-    if (cachedData == null) return;
-    const { report } = cachedData;
-    if (report === null) {
-      console.error("Cannot update event notes cache, because of null value.");
-      return;
-    }
-    const note = data.createReportNote;
-    note.moderator = currentActor.value;
-
-    report.notes = report.notes.concat([note]);
-
-    store.writeQuery({
-      query: REPORT,
-      variables: { id: report.value.id },
-      data: { report },
-    });
-  },
-}));
-
-createReportNoteMutationDone(() => {
-  noteContent.value = "";
-});
-
-createReportNoteMutationError((error) => {
-  console.error(error);
-});
-
-const dialog = inject<Dialog>("dialog");
-
-const confirmEventDelete = (): void => {
-  dialog?.confirm({
-    title: t("Deleting event"),
-    message: t(
-      "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
-    ),
-    confirmText: t("Delete Event"),
-    type: "danger",
-    hasIcon: true,
-    onConfirm: () => deleteEvent(),
-  });
-};
-
-const confirmCommentDelete = (comment: IComment): void => {
-  dialog?.confirm({
-    title: t("Deleting comment"),
-    message: t(
-      "Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
-    ),
-    confirmText: t("Delete Comment"),
-    type: "danger",
-    hasIcon: true,
-    onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
-  });
-};
-
-const {
-  mutate: deleteEventMutation,
-  onDone: deleteEventMutationDone,
-  onError: deleteEventMutationError,
-} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT);
-
-deleteEventMutationDone(() => {
-  const eventTitle = report.value?.event?.title;
-  notifier?.success(
-    t("Event {eventTitle} deleted", {
-      eventTitle,
-    })
-  );
-});
-
-deleteEventMutationError((error) => {
-  console.error(error);
-});
-
-const deleteEvent = async (): Promise<void> => {
-  if (!report.value?.event?.id) return;
-
-  deleteEventMutation({ eventId: report.value.event.id });
-};
-
-const {
-  mutate: deleteCommentMutation,
-  onDone: deleteCommentMutationDone,
-  onError: deleteCommentMutationError,
-} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT);
-
-deleteCommentMutationDone(() => {
-  notifier?.success(t("Comment deleted") as string);
-});
-
-deleteCommentMutationError((error) => {
-  console.error(error);
-});
-
-const {
-  mutate: updateReportMutation,
-  onDone: onUpdateReportMutation,
-  onError: onUpdateReportError,
-} = useMutation(UPDATE_REPORT, () => ({
-  update: (
-    store: ApolloCache<{ updateReportStatus: IReport }>,
-    { data }: FetchResult
-  ) => {
-    if (data == null) return;
-    const reportCachedData = store.readQuery<{ report: IReport }>({
-      query: REPORT,
-      variables: { id: report.value.id },
-    });
-    if (reportCachedData == null) return;
-    const { report } = reportCachedData;
-    if (report === null) {
-      console.error("Cannot update event notes cache, because of null value.");
-      return;
-    }
-    const updatedReport = {
-      ...report,
-      status: data.updateReportStatus.status,
-    };
-
-    store.writeQuery({
-      query: REPORT,
-      variables: { id: report.value.id },
-      data: { report: updatedReport },
-    });
-  },
-}));
-
-onUpdateReportMutation(() => {
-  router.push({ name: RouteName.REPORTS });
-});
-
-onUpdateReportError((error) => {
-  console.error(error);
-});
-
-const updateReport = async (status: ReportStatusEnum): Promise<void> => {
-  updateReportMutation({
-    reportId: report.value?.id,
-    status,
-  });
-};
-</script>
-<style lang="scss" scoped>
-tbody td img.image,
-.note img.image {
-  display: inline;
-  height: 1.5em;
-  vertical-align: text-bottom;
-}
-
-.dialog .modal-card-foot {
-  justify-content: flex-end;
-}
-
-.box a {
-  text-decoration: none;
-  color: inherit;
-}
-
-td > a {
-  text-decoration: none;
-}
-</style>
diff --git a/js/src/views/Moderation/ReportList.vue b/js/src/views/Moderation/ReportListView.vue
similarity index 83%
rename from js/src/views/Moderation/ReportList.vue
rename to js/src/views/Moderation/ReportListView.vue
index 43af17e90..71a301146 100644
--- a/js/src/views/Moderation/ReportList.vue
+++ b/js/src/views/Moderation/ReportListView.vue
@@ -4,35 +4,35 @@
       :links="[
         {
           name: RouteName.MODERATION,
-          text: $t('Moderation'),
+          text: t('Moderation'),
         },
         {
           name: RouteName.REPORTS,
-          text: $t('Reports'),
+          text: t('Reports'),
         },
       ]"
     />
     <section>
       <div class="flex flex-wrap gap-2">
-        <o-field :label="$t('Report status')">
+        <o-field :label="t('Report status')">
           <o-radio v-model="status" :native-value="ReportStatusEnum.OPEN">{{
-            $t("Open")
+            t("Open")
           }}</o-radio>
           <o-radio v-model="status" :native-value="ReportStatusEnum.RESOLVED">{{
-            $t("Resolved")
+            t("Resolved")
           }}</o-radio>
           <o-radio v-model="status" :native-value="ReportStatusEnum.CLOSED">{{
-            $t("Closed")
+            t("Closed")
           }}</o-radio>
         </o-field>
         <o-field
-          :label="$t('Domain')"
+          :label="t('Domain')"
           label-for="domain-filter"
           class="flex-auto"
         >
           <o-input
             id="domain-filter"
-            :placeholder="$t('mobilizon-instance.tld')"
+            :placeholder="t('mobilizon-instance.tld')"
             :value="filterDomain"
             @input="debouncedUpdateDomainFilter"
           />
@@ -53,21 +53,21 @@
           inline
           v-if="status === ReportStatusEnum.OPEN"
         >
-          {{ $t("No open reports yet") }}
+          {{ t("No open reports yet") }}
         </empty-content>
         <empty-content
           icon="chat-alert"
           inline
           v-if="status === ReportStatusEnum.RESOLVED"
         >
-          {{ $t("No resolved reports yet") }}
+          {{ t("No resolved reports yet") }}
         </empty-content>
         <empty-content
           icon="chat-alert"
           inline
           v-if="status === ReportStatusEnum.CLOSED"
         >
-          {{ $t("No closed reports yet") }}
+          {{ t("No closed reports yet") }}
         </empty-content>
       </div>
       <o-pagination
@@ -75,10 +75,10 @@
         v-model="page"
         :simple="true"
         :per-page="REPORT_PAGE_LIMIT"
-        :aria-next-label="$t('Next page')"
-        :aria-previous-label="$t('Previous page')"
-        :aria-page-label="$t('Page')"
-        :aria-current-label="$t('Current page')"
+        :aria-next-label="t('Next page')"
+        :aria-previous-label="t('Previous page')"
+        :aria-page-label="t('Page')"
+        :aria-current-label="t('Current page')"
       >
       </o-pagination>
     </section>
@@ -96,7 +96,7 @@ import debounce from "lodash/debounce";
 import { useQuery } from "@vue/apollo-composable";
 import { useI18n } from "vue-i18n";
 import { useHead } from "@vueuse/head";
-import { computed, ref } from "vue";
+import { computed } from "vue";
 import {
   enumTransformer,
   integerTransformer,
@@ -132,7 +132,7 @@ useHead({
   title: computed(() => t("Reports")),
 });
 
-const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
+// const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
 
 const updateDomainFilter = (event: InputEvent) => {
   filterDomain.value = event.target?.value;
diff --git a/js/src/views/Moderation/ReportView.vue b/js/src/views/Moderation/ReportView.vue
new file mode 100644
index 000000000..19443dd92
--- /dev/null
+++ b/js/src/views/Moderation/ReportView.vue
@@ -0,0 +1,543 @@
+<template>
+  <breadcrumbs-nav
+    v-if="report"
+    :links="[
+      {
+        name: RouteName.MODERATION,
+        text: t('Moderation'),
+      },
+      {
+        name: RouteName.REPORTS,
+        text: t('Reports'),
+      },
+      {
+        name: RouteName.REPORT,
+        params: { id: report.id },
+        text: t('Report #{reportNumber}', { reportNumber: report.id }),
+      },
+    ]"
+  />
+  <o-notification
+    title="Error"
+    variant="danger"
+    v-for="error in errors"
+    :key="error"
+  >
+    {{ error }}
+  </o-notification>
+  <div class="container mx-auto" v-if="report">
+    <div class="flex flex-wrap gap-2 my-2">
+      <o-button
+        v-if="report.status !== ReportStatusEnum.RESOLVED"
+        @click="updateReport(ReportStatusEnum.RESOLVED)"
+        variant="primary"
+        >{{ t("Mark as resolved") }}</o-button
+      >
+      <o-button
+        v-if="report.status !== ReportStatusEnum.OPEN"
+        @click="updateReport(ReportStatusEnum.OPEN)"
+        variant="success"
+        >{{ t("Reopen") }}</o-button
+      >
+      <o-button
+        v-if="report.status !== ReportStatusEnum.CLOSED"
+        @click="updateReport(ReportStatusEnum.CLOSED)"
+        variant="danger"
+        >{{ t("Close") }}</o-button
+      >
+    </div>
+    <section class="w-full">
+      <table class="table w-full">
+        <tbody>
+          <tr v-if="report.reported.type === ActorType.GROUP">
+            <td>{{ t("Reported group") }}</td>
+            <td>
+              <router-link
+                :to="{
+                  name: RouteName.ADMIN_GROUP_PROFILE,
+                  params: { id: report.reported.id },
+                }"
+              >
+                <img
+                  v-if="report.reported.avatar"
+                  class="image"
+                  :src="report.reported.avatar.url"
+                  alt=""
+                />
+                {{ displayNameAndUsername(report.reported) }}
+              </router-link>
+            </td>
+          </tr>
+          <tr v-else>
+            <td>
+              {{ t("Reported identity") }}
+            </td>
+            <td>
+              <router-link
+                :to="{
+                  name: RouteName.ADMIN_PROFILE,
+                  params: { id: report.reported.id },
+                }"
+              >
+                <img
+                  v-if="report.reported.avatar"
+                  class="image"
+                  :src="report.reported.avatar.url"
+                  alt=""
+                />
+                {{ displayNameAndUsername(report.reported) }}
+              </router-link>
+            </td>
+          </tr>
+          <tr>
+            <td>{{ t("Reported by") }}</td>
+            <td v-if="report.reporter.type === ActorType.APPLICATION">
+              {{ report.reporter.domain }}
+            </td>
+            <td v-else>
+              <router-link
+                :to="{
+                  name: RouteName.ADMIN_PROFILE,
+                  params: { id: report.reporter.id },
+                }"
+              >
+                <img
+                  v-if="report.reporter.avatar"
+                  class="image"
+                  :src="report.reporter.avatar.url"
+                  alt=""
+                />
+                {{ displayNameAndUsername(report.reporter) }}
+              </router-link>
+            </td>
+          </tr>
+          <tr>
+            <td>{{ t("Reported") }}</td>
+            <td>{{ formatDateTimeString(report.insertedAt) }}</td>
+          </tr>
+          <tr v-if="report.updatedAt !== report.insertedAt">
+            <td>{{ t("Updated") }}</td>
+            <td>{{ formatDateTimeString(report.updatedAt) }}</td>
+          </tr>
+          <tr>
+            <td>{{ t("Status") }}</td>
+            <td>
+              <span v-if="report.status === ReportStatusEnum.OPEN">{{
+                t("Open")
+              }}</span>
+              <span v-else-if="report.status === ReportStatusEnum.CLOSED">
+                {{ t("Closed") }}
+              </span>
+              <span v-else-if="report.status === ReportStatusEnum.RESOLVED">
+                {{ t("Resolved") }}
+              </span>
+              <span v-else>{{ t("Unknown") }}</span>
+            </td>
+          </tr>
+          <tr v-if="report.event && report.comments.length > 0">
+            <td>{{ t("Event") }}</td>
+            <td>
+              <router-link
+                :to="{
+                  name: RouteName.EVENT,
+                  params: { uuid: report.event.uuid },
+                }"
+              >
+                {{ report.event.title }}
+              </router-link>
+              <span>
+                <!--                                    <o-button-->
+                <!--                                            tag="router-link"-->
+                <!--                                            variant="primary"-->
+                <!--                                            :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
+                <!--                                            icon-left="pencil"-->
+                <!--                                            size="small">{{ t('Edit') }}</o-button>-->
+                <o-button
+                  variant="danger"
+                  @click="confirmEventDelete()"
+                  icon-left="delete"
+                  size="small"
+                  >{{ t("Delete") }}</o-button
+                >
+              </span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </section>
+
+    <section>
+      <h2>{{ t("Report reason") }}</h2>
+      <div class="dark:bg-zinc-700 p-2 rounded my-2">
+        <div class="flex gap-1">
+          <figure class="" v-if="report.reported.avatar">
+            <img
+              alt=""
+              :src="report.reported.avatar.url"
+              class="rounded-full"
+              width="48"
+              height="48"
+            />
+          </figure>
+          <AccountCircle v-else :size="48" />
+          <div class="">
+            <p class="" v-if="report.reported.name">
+              {{ report.reported.name }}
+            </p>
+            <p class="">@{{ usernameWithDomain(report.reported) }}</p>
+          </div>
+        </div>
+        <div
+          class="prose dark:prose-invert"
+          v-if="report.content"
+          v-html="nl2br(report.content)"
+        />
+        <p v-else>{{ t("No comment") }}</p>
+      </div>
+    </section>
+
+    <section class="" v-if="report.event && report.comments.length === 0">
+      <h2>{{ t("Reported content") }}</h2>
+      <EventCard :event="report.event" mode="row" class="my-2" />
+      <!--                <o-button-->
+      <!--                        tag="router-link"-->
+      <!--                        variant="primary"-->
+      <!--                        :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
+      <!--                        icon-left="pencil"-->
+      <!--                        size="small">{{ t('Edit') }}</o-button>-->
+      <o-button
+        variant="danger"
+        @click="confirmEventDelete()"
+        icon-left="delete"
+        size="small"
+        >{{ t("Delete") }}</o-button
+      >
+    </section>
+
+    <div v-if="report.comments.length > 0">
+      <ul v-for="comment in report.comments" :key="comment.id">
+        <li>
+          <div class="" v-if="comment">
+            <article class="flex gap-1">
+              <div class="">
+                <figure class="" v-if="comment.actor && comment.actor.avatar">
+                  <img
+                    :src="comment.actor.avatar.url"
+                    alt=""
+                    width="48"
+                    height="48"
+                  />
+                </figure>
+                <AccountCircle :size="48" v-else />
+              </div>
+              <div class="">
+                <div class="prose dark:prose-invert">
+                  <span v-if="comment.actor">
+                    <strong>{{ comment.actor.name }}</strong>
+                    <small>@{{ comment.actor.preferredUsername }}</small>
+                  </span>
+                  <span v-else>{{ t("Unknown actor") }}</span>
+                  <br />
+                  <p v-html="comment.text" />
+                </div>
+                <o-button
+                  variant="danger"
+                  @click="confirmCommentDelete(comment)"
+                  icon-left="delete"
+                  size="small"
+                  >{{ t("Delete") }}</o-button
+                >
+              </div>
+            </article>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <section>
+      <h2>{{ t("Notes") }}</h2>
+      <div
+        class="box note"
+        v-for="note in report.notes"
+        :id="`note-${note.id}`"
+        :key="note.id"
+      >
+        <p>{{ note.content }}</p>
+        <router-link
+          :to="{
+            name: RouteName.ADMIN_PROFILE,
+            params: { id: note.moderator.id },
+          }"
+        >
+          <img
+            alt=""
+            class="rounded-full"
+            :src="note.moderator.avatar.url"
+            v-if="note.moderator.avatar"
+          />
+          @{{ note.moderator.preferredUsername }}
+        </router-link>
+        <br />
+        <small>
+          <a :href="`#note-${note.id}`" v-if="note.insertedAt">
+            {{ formatDateTimeString(note.insertedAt) }}
+          </a>
+        </small>
+      </div>
+
+      <form
+        @submit="
+          createReportNoteMutation({
+            reportId: report?.id,
+            content: noteContent,
+          })
+        "
+      >
+        <o-field :label="t('New note')" label-for="newNoteInput">
+          <o-input
+            type="textarea"
+            v-model="noteContent"
+            id="newNoteInput"
+          ></o-input>
+        </o-field>
+        <o-button class="mt-2" type="submit">{{ t("Add a note") }}</o-button>
+      </form>
+    </section>
+  </div>
+</template>
+<script lang="ts" setup>
+import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report";
+import { IReport, IReportNote } from "@/types/report.model";
+import { displayNameAndUsername, usernameWithDomain } from "@/types/actor";
+import { DELETE_EVENT } from "@/graphql/event";
+import uniq from "lodash/uniq";
+import { nl2br } from "@/utils/html";
+import { DELETE_COMMENT } from "@/graphql/comment";
+import { IComment } from "@/types/comment.model";
+import { ActorType, ReportStatusEnum } from "@/types/enums";
+import RouteName from "@/router/name";
+import { GraphQLError } from "graphql";
+import { ApolloCache, FetchResult } from "@apollo/client/core";
+import { useMutation, useQuery } from "@vue/apollo-composable";
+import { useCurrentActorClient } from "@/composition/apollo/actor";
+import { useHead } from "@vueuse/head";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { ref, computed, inject } from "vue";
+import { formatDateTimeString } from "@/filters/datetime";
+import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
+import { Dialog } from "@/plugins/dialog";
+import { Notifier } from "@/plugins/notifier";
+import EventCard from "@/components/Event/EventCard.vue";
+
+const router = useRouter();
+
+const props = defineProps<{ reportId: string }>();
+
+const { currentActor } = useCurrentActorClient();
+
+const { result: reportResult, onError: onReportQueryError } = useQuery<{
+  report: IReport;
+}>(REPORT, () => ({
+  id: props.reportId,
+}));
+
+const report = computed(() => reportResult.value?.report);
+
+onReportQueryError(({ graphQLErrors }) => {
+  errors.value = uniq(
+    graphQLErrors.map(({ message }: GraphQLError) => message)
+  );
+});
+
+const { t } = useI18n({ useScope: "global" });
+
+useHead({
+  title: computed(() => t("Report")),
+});
+
+const notifier = inject<Notifier>("notifier");
+
+const errors = ref<string[]>([]);
+
+const noteContent = ref("");
+
+const {
+  mutate: createReportNoteMutation,
+  onDone: createReportNoteMutationDone,
+  onError: createReportNoteMutationError,
+} = useMutation<{
+  createReportNote: IReportNote;
+}>(CREATE_REPORT_NOTE, () => ({
+  update: (
+    store: ApolloCache<{ createReportNote: IReportNote }>,
+    { data }: FetchResult
+  ) => {
+    if (data == null) return;
+    const cachedData = store.readQuery<{ report: IReport }>({
+      query: REPORT,
+      variables: { id: report.value?.id },
+    });
+    if (cachedData == null) return;
+    const { report: cachedReport } = cachedData;
+    if (cachedReport === null) {
+      console.error("Cannot update event notes cache, because of null value.");
+      return;
+    }
+    const note = data.createReportNote;
+    note.moderator = currentActor.value;
+
+    cachedReport.notes = cachedReport.notes.concat([note]);
+
+    store.writeQuery({
+      query: REPORT,
+      variables: { id: report.value?.id },
+      data: { report },
+    });
+  },
+}));
+
+createReportNoteMutationDone(() => {
+  noteContent.value = "";
+});
+
+createReportNoteMutationError((error) => {
+  console.error(error);
+});
+
+const dialog = inject<Dialog>("dialog");
+
+const confirmEventDelete = (): void => {
+  dialog?.confirm({
+    title: t("Deleting event"),
+    message: t(
+      "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
+    ),
+    confirmText: t("Delete Event"),
+    variant: "danger",
+    hasIcon: true,
+    onConfirm: () => deleteEvent(),
+  });
+};
+
+const confirmCommentDelete = (comment: IComment): void => {
+  dialog?.confirm({
+    title: t("Deleting comment"),
+    message: t(
+      "Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
+    ),
+    confirmText: t("Delete Comment"),
+    variant: "danger",
+    hasIcon: true,
+    onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
+  });
+};
+
+const {
+  mutate: deleteEventMutation,
+  onDone: deleteEventMutationDone,
+  onError: deleteEventMutationError,
+} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT);
+
+deleteEventMutationDone(() => {
+  const eventTitle = report.value?.event?.title;
+  notifier?.success(
+    t("Event {eventTitle} deleted", {
+      eventTitle,
+    })
+  );
+});
+
+deleteEventMutationError((error) => {
+  console.error(error);
+});
+
+const deleteEvent = async (): Promise<void> => {
+  if (!report.value?.event?.id) return;
+
+  deleteEventMutation({ eventId: report.value.event.id });
+};
+
+const {
+  mutate: deleteCommentMutation,
+  onDone: deleteCommentMutationDone,
+  onError: deleteCommentMutationError,
+} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT);
+
+deleteCommentMutationDone(() => {
+  notifier?.success(t("Comment deleted") as string);
+});
+
+deleteCommentMutationError((error) => {
+  console.error(error);
+});
+
+const {
+  mutate: updateReportMutation,
+  onDone: onUpdateReportMutation,
+  onError: onUpdateReportError,
+} = useMutation(UPDATE_REPORT, () => ({
+  update: (
+    store: ApolloCache<{ updateReportStatus: IReport }>,
+    { data }: FetchResult
+  ) => {
+    if (data == null) return;
+    const reportCachedData = store.readQuery<{ report: IReport }>({
+      query: REPORT,
+      variables: { id: report.value?.id },
+    });
+    if (reportCachedData == null) return;
+    const { report: cachedReport } = reportCachedData;
+    if (cachedReport === null) {
+      console.error("Cannot update event notes cache, because of null value.");
+      return;
+    }
+    const updatedReport = {
+      ...cachedReport,
+      status: data.updateReportStatus.status,
+    };
+
+    store.writeQuery({
+      query: REPORT,
+      variables: { id: report.value?.id },
+      data: { report: updatedReport },
+    });
+  },
+}));
+
+onUpdateReportMutation(() => {
+  router.push({ name: RouteName.REPORTS });
+});
+
+onUpdateReportError((error) => {
+  console.error(error);
+});
+
+const updateReport = async (status: ReportStatusEnum): Promise<void> => {
+  updateReportMutation({
+    reportId: report.value?.id,
+    status,
+  });
+};
+</script>
+<style lang="scss" scoped>
+tbody td img.image,
+.note img.image {
+  display: inline;
+  height: 1.5em;
+  vertical-align: text-bottom;
+}
+
+.dialog .modal-card-foot {
+  justify-content: flex-end;
+}
+
+.box a {
+  text-decoration: none;
+  color: inherit;
+}
+
+td > a {
+  text-decoration: none;
+}
+</style>
diff --git a/js/src/views/Posts/Edit.vue b/js/src/views/Posts/EditView.vue
similarity index 92%
rename from js/src/views/Posts/Edit.vue
rename to js/src/views/Posts/EditView.vue
index 301a7cef1..14b402018 100644
--- a/js/src/views/Posts/Edit.vue
+++ b/js/src/views/Posts/EditView.vue
@@ -83,12 +83,13 @@
         <div class="container mx-auto">
           <div class="navbar-menu flex flex-wrap py-2">
             <div class="flex flex-wrap justify-end ml-auto gap-1">
-              <o-button type="is-text" @click="$router.go(-1)">{{
+              <o-button variant="text" @click="$router.go(-1)">{{
                 $t("Cancel")
               }}</o-button>
               <o-button
                 v-if="isUpdate"
-                type="is-danger is-outlined"
+                variant="danger"
+                outlined
                 @click="openDeletePostModal"
                 >{{ $t("Delete post") }}</o-button
               >
@@ -140,7 +141,7 @@ import {
 } from "../../graphql/post";
 
 import { IPost } from "../../types/post.model";
-import Editor from "../../components/Editor.vue";
+import Editor from "../../components/TextEditor.vue";
 import { displayName, IActor, usernameWithDomain } from "../../types/actor";
 import TagInput from "../../components/Event/TagInput.vue";
 import RouteName from "../../router/name";
@@ -191,11 +192,13 @@ onMounted(async () => {
   pictureFile.value = await buildFileFromIMedia(post.value?.picture);
 });
 
-watch(post, async (newPost: IPost, oldPost: IPost) => {
-  if (oldPost?.picture !== newPost.picture) {
+watch(post, async (newPost: IPost | undefined, oldPost: IPost | undefined) => {
+  if (oldPost?.picture !== newPost?.picture) {
     pictureFile.value = await buildFileFromIMedia(post.value?.picture);
   }
-  editablePost.value = { ...post.value };
+  if (newPost) {
+    editablePost.value = { ...newPost };
+  }
 });
 
 const router = useRouter();
@@ -370,7 +373,7 @@ const dialog = inject<Dialog>("dialog");
 
 const openDeletePostModal = async (): Promise<void> => {
   dialog?.confirm({
-    type: "danger",
+    variant: "danger",
     title: t("Delete post"),
     message: t(
       "Are you sure you want to delete this post? This action cannot be reverted."
@@ -382,11 +385,8 @@ const openDeletePostModal = async (): Promise<void> => {
   });
 };
 
-const {
-  mutate: deletePost,
-  onDone: onDeletePostDone,
-  onError: onDeletePostError,
-} = useMutation(DELETE_POST);
+const { mutate: deletePost, onDone: onDeletePostDone } =
+  useMutation(DELETE_POST);
 
 onDeletePostDone(({ data }) => {
   if (data && post.value?.attributedTo) {
@@ -405,27 +405,3 @@ useHead({
   ),
 });
 </script>
-<style lang="scss" scoped>
-@use "@/styles/_mixins" as *;
-
-form {
-  nav.navbar {
-    // min-height: 2rem !important;
-
-    .container {
-      // min-height: 2rem;
-
-      .navbar-menu,
-      .navbar-end {
-        // display: flex !important;
-        // flex-wrap: wrap;
-      }
-
-      .navbar-end {
-        // justify-content: flex-end;
-        // @include margin-left(auto);
-      }
-    }
-  }
-}
-</style>
diff --git a/js/src/views/Posts/List.vue b/js/src/views/Posts/ListView.vue
similarity index 100%
rename from js/src/views/Posts/List.vue
rename to js/src/views/Posts/ListView.vue
diff --git a/js/src/views/Posts/Post.vue b/js/src/views/Posts/PostView.vue
similarity index 95%
rename from js/src/views/Posts/Post.vue
rename to js/src/views/Posts/PostView.vue
index f6ae5da06..329044a60 100644
--- a/js/src/views/Posts/Post.vue
+++ b/js/src/views/Posts/PostView.vue
@@ -11,12 +11,12 @@
         >
           <div class="flex-1">
             <div class="inline">
-              <b-tag
+              <tag
                 class="mr-2"
                 variant="warning"
-                size="is-medium"
+                size="medium"
                 v-if="post.draft"
-                >{{ $t("Draft") }}</b-tag
+                >{{ $t("Draft") }}</tag
               >
               <h1 class="inline" :lang="post.language">
                 {{ post.title }}
@@ -191,7 +191,7 @@
       has-modal-card
       ref="reportModal"
     >
-      <report-modal
+      <ReportModal
         :on-confirm="reportPost"
         :title="$t('Report this post')"
         :outside-domain="groupDomain"
@@ -224,7 +224,6 @@ import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
 import ActorInline from "@/components/Account/ActorInline.vue";
 import { formatDistanceToNowStrict, Locale } from "date-fns";
 import SharePostModal from "@/components/Post/SharePostModal.vue";
-import { CREATE_REPORT } from "@/graphql/report";
 import ReportModal from "@/components/Report/ReportModal.vue";
 import { useAnonymousReportsConfig } from "@/composition/apollo/config";
 import {
@@ -283,13 +282,6 @@ const isShareModalActive = ref(false);
 const isReportModalActive = ref(false);
 const reportModal = ref();
 
-const isCurrentActorMember = computed((): boolean => {
-  if (!post.value?.attributedTo || !memberships.value) return false;
-  return memberships.value.elements
-    .map(({ parent: { id } }) => id)
-    .includes(post.value?.attributedTo.id);
-});
-
 const isInstanceModerator = computed((): boolean => {
   return (
     currentUser.value?.role !== undefined &&
@@ -313,8 +305,8 @@ const triggerShare = (): void => {
         title: post.value?.title,
         url: post.value?.url,
       })
-      .then(() => console.log("Successful share"))
-      .catch((error: any) => console.log("Error sharing", error));
+      .then(() => console.debug("Successful share"))
+      .catch((error: any) => console.debug("Error sharing", error));
   } else {
     isShareModalActive.value = true;
     // send popup
@@ -382,7 +374,7 @@ const dialog = inject<Dialog>("dialog");
 
 const openDeletePostModal = async (): Promise<void> => {
   dialog?.confirm({
-    type: "danger",
+    variant: "danger",
     title: t("Delete post"),
     message: t(
       "Are you sure you want to delete this post? This action cannot be reverted."
@@ -396,11 +388,8 @@ const openDeletePostModal = async (): Promise<void> => {
 
 const router = useRouter();
 
-const {
-  mutate: deletePost,
-  onDone: onDeletePostDone,
-  onError: onDeletePostError,
-} = useMutation(DELETE_POST);
+const { mutate: deletePost, onDone: onDeletePostDone } =
+  useMutation(DELETE_POST);
 
 onDeletePostDone(({ data }) => {
   if (data && post.value?.attributedTo) {
diff --git a/js/src/views/Resources/ResourceFolder.vue b/js/src/views/Resources/ResourceFolder.vue
index f5c6d16ca..9210adea2 100644
--- a/js/src/views/Resources/ResourceFolder.vue
+++ b/js/src/views/Resources/ResourceFolder.vue
@@ -9,11 +9,11 @@
 
           <o-dropdown-item aria-role="listitem" @click="createFolderModal">
             <Folder />
-            {{ $t("New folder") }}
+            {{ t("New folder") }}
           </o-dropdown-item>
           <o-dropdown-item aria-role="listitem" @click="createLinkModal">
             <Link />
-            {{ $t("New link") }}
+            {{ t("New link") }}
           </o-dropdown-item>
           <hr
             role="presentation"
@@ -48,21 +48,21 @@
       :total="resource.children.total"
       v-model="page"
       :per-page="RESOURCES_PER_PAGE"
-      :aria-next-label="$t('Next page')"
-      :aria-previous-label="$t('Previous page')"
-      :aria-page-label="$t('Page')"
-      :aria-current-label="$t('Current page')"
+      :aria-next-label="t('Next page')"
+      :aria-previous-label="t('Previous page')"
+      :aria-page-label="t('Page')"
+      :aria-current-label="t('Current page')"
     >
     </o-pagination>
     <o-modal
       v-model:active="renameModal"
       has-modal-card
-      :close-button-aria-label="$t('Close')"
+      :close-button-aria-label="t('Close')"
     >
       <div class="w-full md:w-[640px]">
         <section>
           <form @submit.prevent="renameResource">
-            <o-field :label="$t('Title')">
+            <o-field :label="t('Title')">
               <o-input
                 ref="resourceRenameInput"
                 aria-required="true"
@@ -70,9 +70,7 @@
               />
             </o-field>
 
-            <o-button native-type="submit">{{
-              $t("Rename resource")
-            }}</o-button>
+            <o-button native-type="submit">{{ t("Rename resource") }}</o-button>
           </form>
         </section>
       </div>
@@ -80,7 +78,7 @@
     <o-modal
       v-model:active="moveModal"
       has-modal-card
-      :close-button-aria-label="$t('Close')"
+      :close-button-aria-label="t('Close')"
     >
       <div class="w-full md:w-[640px]">
         <section>
@@ -96,36 +94,41 @@
     <o-modal
       v-model:active="createResourceModal"
       has-modal-card
-      :close-button-aria-label="$t('Close')"
+      :close-button-aria-label="t('Close')"
       trap-focus
     >
-      <div class="w-full md:w-[640px]">
-        <section>
-          <o-notification variant="danger" v-if="modalError">
-            {{ modalError }}
-          </o-notification>
-          <form @submit.prevent="createResource">
-            <o-field :label="$t('Title')" label-for="new-resource-title">
-              <o-input
-                ref="modalNewResourceInput"
-                aria-required="true"
-                v-model="newResource.title"
-                id="new-resource-title"
-              />
-            </o-field>
+      <section class="w-full md:w-[640px]">
+        <o-notification variant="danger" v-if="modalError">
+          {{ modalError }}
+        </o-notification>
+        <form @submit.prevent="createResource">
+          <p v-if="newResource.type !== 'folder'">
+            {{
+              t("The pad will be created on {service}", {
+                service: newResourceHost,
+              })
+            }}
+          </p>
+          <o-field :label="t('Title')" label-for="new-resource-title">
+            <o-input
+              ref="modalNewResourceInput"
+              aria-required="true"
+              v-model="newResource.title"
+              id="new-resource-title"
+            />
+          </o-field>
 
-            <o-button native-type="submit">{{
-              createResourceButtonLabel
-            }}</o-button>
-          </form>
-        </section>
-      </div>
+          <o-button class="mt-2" native-type="submit">{{
+            createResourceButtonLabel
+          }}</o-button>
+        </form>
+      </section>
     </o-modal>
     <o-modal
       v-model:active="createLinkResourceModal"
       has-modal-card
       aria-modal
-      :close-button-aria-label="$t('Close')"
+      :close-button-aria-label="t('Close')"
       trap-focus
       :width="640"
     >
@@ -135,7 +138,7 @@
             {{ modalError }}
           </o-notification>
           <form @submit.prevent="createResource">
-            <o-field expanded :label="$t('URL')" label-for="new-resource-url">
+            <o-field expanded :label="t('URL')" label-for="new-resource-url">
               <o-input
                 id="new-resource-url"
                 type="url"
@@ -150,7 +153,7 @@
               <resource-item :resource="newResource" :preview="true" />
             </div>
 
-            <o-field :label="$t('Title')" label-for="new-resource-link-title">
+            <o-field :label="t('Title')" label-for="new-resource-link-title">
               <o-input
                 aria-required="true"
                 v-model="newResource.title"
@@ -158,10 +161,7 @@
               />
             </o-field>
 
-            <o-field
-              :label="$t('Description')"
-              label-for="new-resource-summary"
-            >
+            <o-field :label="t('Description')" label-for="new-resource-summary">
               <o-input
                 type="textarea"
                 v-model="newResource.summary"
@@ -170,7 +170,7 @@
             </o-field>
 
             <o-button native-type="submit" class="mt-2">{{
-              $t("Create resource")
+              t("Create resource")
             }}</o-button>
           </form>
         </section>
@@ -246,8 +246,6 @@ onGetResourceError(({ graphQLErrors }) => {
   handleErrors(graphQLErrors);
 });
 
-const { currentActor } = useCurrentActorClient();
-
 const { resourceProviders } = useResourceProviders();
 
 const { t } = useI18n({ useScope: "global" });
@@ -489,7 +487,7 @@ const { mutate: updateResourceMutation } = useMutation<{
     if (!data || data.updateResource == null || parentPath == null) return;
     if (!resource.value?.actor) return;
 
-    console.log("Removing ressource from old parent");
+    console.debug("Removing ressource from old parent");
     const oldParentCachedData = store.readQuery<{ resource: IResource }>({
       query: GET_RESOURCE,
       variables: {
@@ -525,11 +523,11 @@ const { mutate: updateResourceMutation } = useMutation<{
         },
       },
     });
-    console.log("Finished removing ressource from old parent");
+    console.debug("Finished removing ressource from old parent");
 
-    console.log("Adding resource to new parent");
+    console.debug("Adding resource to new parent");
     if (!updatedResource.parent || !updatedResource.parent.path) {
-      console.log("No cache found for new parent");
+      console.debug("No cache found for new parent");
       return;
     }
     const newParentCachedData = store.readQuery<{ resource: IResource }>({
@@ -562,7 +560,7 @@ const { mutate: updateResourceMutation } = useMutation<{
         },
       },
     });
-    console.log("Finished adding resource to new parent");
+    console.debug("Finished adding resource to new parent");
   },
 }));
 
@@ -633,12 +631,17 @@ const breadcrumbLinks = computed(() => {
   return links;
 });
 
+const newResourceHost = computed(() => {
+  if (!newResource.resourceUrl) return;
+  return new URL(newResource.resourceUrl).host;
+});
+
 useHead({
   title: computed(() =>
     isRoot.value
       ? t("Resources")
       : t("{folder} - Resources", {
-          folder: lastFragment,
+          folder: lastFragment.value,
         })
   ),
 });
diff --git a/js/src/views/SearchView.vue b/js/src/views/SearchView.vue
index cfff58bbb..4220f3889 100644
--- a/js/src/views/SearchView.vue
+++ b/js/src/views/SearchView.vue
@@ -1,9 +1,17 @@
 <template>
+  <div class="max-w-4xl mx-auto">
+    <SearchFields
+      class="md:ml-10 mr-2"
+      v-model:search="searchDebounced"
+      v-model:location="location"
+      :locationDefaultText="locationName"
+    />
+  </div>
   <div
     class="container mx-auto md:py-3 md:px-4 flex flex-col lg:flex-row gap-x-5 gap-y-1"
   >
     <aside
-      class="flex-none lg:block lg:sticky top-8 rounded-md px-2 pt-2 w-full lg:w-80 flex-col justify-between mt-2 lg:pb-10 lg:px-8 overflow-y-auto dark:text-slate-100 bg-white dark:bg-mbz-purple"
+      class="flex-none lg:block lg:sticky top-8 rounded-md w-full lg:w-80 flex-col justify-between mt-2 lg:pb-10 lg:px-8 overflow-y-auto dark:text-slate-100 bg-white dark:bg-mbz-purple"
     >
       <o-button
         @click="toggleFilters"
@@ -16,7 +24,7 @@
       <form
         @submit.prevent="doNewSearch"
         :class="{ hidden: filtersPanelOpened }"
-        class="lg:block mt-2"
+        class="lg:block mt-4 px-2"
       >
         <p class="sr-only">{{ t("Type") }}</p>
         <ul
@@ -53,6 +61,46 @@
           </li>
         </ul>
 
+        <div
+          class="py-4 border-b border-gray-200 dark:border-gray-500"
+          v-show="globalSearchEnabled"
+        >
+          <fieldset class="flex flex-col">
+            <legend class="sr-only">{{ t("Search target") }}</legend>
+
+            <div>
+              <input
+                id="internalTarget"
+                v-model="searchTarget"
+                type="radio"
+                name="searchTarget"
+                :value="SearchTargets.INTERNAL"
+                class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
+              />
+              <label
+                for="internalTarget"
+                class="ml-3 font-medium text-gray-900 dark:text-gray-300"
+                >{{ t("In this instance's network") }}</label
+              >
+            </div>
+            <div>
+              <input
+                id="globalTarget"
+                v-model="searchTarget"
+                type="radio"
+                name="searchTarget"
+                :value="SearchTargets.GLOBAL"
+                class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
+              />
+              <label
+                for="globalTarget"
+                class="ml-3 font-medium text-gray-900 dark:text-gray-300"
+                >{{ t("On the Fediverse") }}</label
+              >
+            </div>
+          </fieldset>
+        </div>
+
         <div
           class="py-4 border-b border-gray-200 dark:border-gray-500"
           v-show="contentType !== 'GROUPS'"
@@ -374,81 +422,48 @@
       </form>
     </aside>
     <div class="flex-1 px-2">
-      <o-tabs type="boxed" v-if="contentType == ContentType.ALL">
-        <o-tab-item>
-          <template #header>
-            <Calendar />
-
-            <span>
-              {{ t("Events") }}
-              <b-tag rounded>{{ searchEvents?.total }}</b-tag>
-            </span>
-          </template>
-          <div v-if="searchEvents && searchEvents.total > 0">
-            <multi-card class="my-4" :events="searchEvents?.elements" />
-            <div
-              class="pagination"
-              v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
-            >
-              <o-pagination
-                :total="searchEvents.total"
-                v-model:current="eventPage"
-                :per-page="EVENT_PAGE_LIMIT"
-                :aria-next-label="t('Next page')"
-                :aria-previous-label="t('Previous page')"
-                :aria-page-label="t('Page')"
-                :aria-current-label="t('Current page')"
-              >
-              </o-pagination>
-            </div>
-          </div>
-          <o-notification v-else-if="searchLoading === false" variant="info">
-            <p>{{ t("No events found") }}</p>
-            <p v-if="searchIsUrl && !currentUser?.id">
-              {{
-                t(
-                  "Only registered users may fetch remote events from their URL."
-                )
-              }}
-            </p>
-          </o-notification>
-        </o-tab-item>
-
-        <o-tab-item>
-          <template #header>
-            <AccountMultiple />
-            <span>
-              {{ t("Groups") }} <b-tag rounded>{{ searchGroups?.total }}</b-tag>
-            </span>
-          </template>
-          <o-notification v-if="features && !features.groups" variant="danger">
-            {{ t("Groups are not enabled on this instance.") }}
-          </o-notification>
-          <div v-else-if="searchGroups && searchGroups?.total > 0">
-            <multi-group-card class="my-4" :groups="searchGroups?.elements" />
-            <div class="pagination">
-              <o-pagination
-                :total="searchGroups?.total"
-                v-model:current="groupPage"
-                :per-page="GROUP_PAGE_LIMIT"
-                :aria-next-label="t('Next page')"
-                :aria-previous-label="t('Previous page')"
-                :aria-page-label="t('Page')"
-                :aria-current-label="t('Current page')"
-              >
-              </o-pagination>
-            </div>
-          </div>
-          <o-notification v-else-if="searchLoading === false" variant="danger">
-            {{ t("No groups found") }}
-          </o-notification>
-        </o-tab-item>
-      </o-tabs>
-      <div v-else-if="contentType === ContentType.EVENTS">
-        <div v-if="searchEvents && searchEvents.total > 0">
-          <multi-card class="my-4" :events="searchEvents?.elements" />
+      <template v-if="contentType === ContentType.ALL">
+        <o-notification v-if="features && !features.groups" variant="danger">
+          {{ t("Groups are not enabled on this instance.") }}
+        </o-notification>
+        <div v-else-if="searchGroups && searchGroups?.total > 0">
+          <GroupCard
+            v-for="group in searchGroups?.elements"
+            :group="group"
+            :key="group.id"
+            :isRemoteGroup="group.__typename === 'GroupResult'"
+            :isLoggedIn="currentUser?.isLoggedIn"
+            mode="row"
+          />
           <o-pagination
-            v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
+            v-if="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
+            :total="searchGroups?.total"
+            v-model:current="groupPage"
+            :per-page="GROUP_PAGE_LIMIT"
+            :aria-next-label="t('Next page')"
+            :aria-previous-label="t('Previous page')"
+            :aria-page-label="t('Page')"
+            :aria-current-label="t('Current page')"
+          >
+          </o-pagination>
+        </div>
+        <o-notification v-else-if="searchLoading === false" variant="danger">
+          {{ t("No groups found") }}
+        </o-notification>
+        <div v-if="searchEvents && searchEvents.total > 0">
+          <event-card
+            mode="row"
+            v-for="event in searchEvents?.elements"
+            :event="event"
+            :key="event.uuid"
+            :options="{
+              isRemoteEvent: event.__typename === 'EventResult',
+              isLoggedIn: currentUser?.isLoggedIn,
+            }"
+            class="my-4"
+          />
+          <o-pagination
+            v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
             :total="searchEvents.total"
             v-model:current="eventPage"
             :per-page="EVENT_PAGE_LIMIT"
@@ -467,13 +482,55 @@
             }}
           </p>
         </o-notification>
-      </div>
-      <div v-else-if="contentType === ContentType.GROUPS">
+      </template>
+      <template v-else-if="contentType === ContentType.EVENTS">
+        <template v-if="searchEvents && searchEvents.total > 0">
+          <event-card
+            mode="row"
+            v-for="event in searchEvents?.elements"
+            :event="event"
+            :key="event.uuid"
+            :options="{
+              isRemoteEvent: event.__typename === 'EventResult',
+              isLoggedIn: currentUser?.isLoggedIn,
+            }"
+            class="my-4"
+          />
+          <o-pagination
+            v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
+            :total="searchEvents.total"
+            v-model:current="eventPage"
+            :per-page="EVENT_PAGE_LIMIT"
+            :aria-next-label="t('Next page')"
+            :aria-previous-label="t('Previous page')"
+            :aria-page-label="t('Page')"
+            :aria-current-label="t('Current page')"
+          >
+          </o-pagination>
+        </template>
+        <o-notification v-else-if="searchLoading === false" variant="info">
+          <p>{{ t("No events found") }}</p>
+          <p v-if="searchIsUrl && !currentUser?.id">
+            {{
+              t("Only registered users may fetch remote events from their URL.")
+            }}
+          </p>
+        </o-notification>
+      </template>
+      <template v-else-if="contentType === ContentType.GROUPS">
         <o-notification v-if="features && !features.groups" variant="danger">
           {{ t("Groups are not enabled on this instance.") }}
         </o-notification>
-        <div v-else-if="searchGroups && searchGroups?.total > 0">
-          <multi-group-card class="my-4" :groups="searchGroups?.elements" />
+
+        <template v-else-if="searchGroups && searchGroups?.total > 0">
+          <GroupCard
+            v-for="group in searchGroups?.elements"
+            :group="group"
+            :key="group.id"
+            :isRemoteGroup="group.__typename === 'GroupResult'"
+            :isLoggedIn="currentUser?.isLoggedIn"
+            mode="row"
+          />
           <o-pagination
             v-show="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
             :total="searchGroups?.total"
@@ -485,11 +542,11 @@
             :aria-current-label="t('Current page')"
           >
           </o-pagination>
-        </div>
+        </template>
         <o-notification v-else-if="searchLoading === false" variant="danger">
           {{ t("No groups found") }}
         </o-notification>
-      </div>
+      </template>
     </div>
   </div>
 </template>
@@ -508,18 +565,17 @@ import {
   startOfMonth,
   eachWeekendOfInterval,
 } from "date-fns";
-import { ContentType, EventStatus } from "@/types/enums";
-import MultiCard from "../components/Event/MultiCard.vue";
-import { IEvent } from "../types/event.model";
-import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
-import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
-import { Paginate } from "../types/paginate";
-import { IGroup } from "../types/actor";
-import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
+import { ContentType, EventStatus, SearchTargets } from "@/types/enums";
+import EventCard from "@/components/Event/EventCard.vue";
+import { IEvent } from "@/types/event.model";
+import { SEARCH_EVENTS_AND_GROUPS } from "@/graphql/search";
+import { Paginate } from "@/types/paginate";
+import { IGroup } from "@/types/actor";
+import GroupCard from "@/components/Group/GroupCard.vue";
 import { CURRENT_USER_CLIENT } from "@/graphql/user";
 import { ICurrentUser } from "@/types/current-user.model";
 import { useQuery } from "@vue/apollo-composable";
-import { computed, inject, ref } from "vue";
+import { computed, inject, ref, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import {
   floatTransformer,
@@ -538,11 +594,35 @@ import type { Locale } from "date-fns";
 import FilterSection from "@/components/Search/filters/FilterSection.vue";
 import { listShortDisjunctionFormatter } from "@/utils/listFormat";
 import langs from "@/i18n/langs.json";
-import { useEventCategories, useFeatures } from "@/composition/apollo/config";
-import geohash from "ngeohash";
+import {
+  useEventCategories,
+  useFeatures,
+  useSearchConfig,
+} from "@/composition/apollo/config";
 import { coordsToGeoHash } from "@/utils/location";
+import SearchFields from "@/components/Home/SearchFields.vue";
+import { refDebounced } from "@vueuse/core";
+import { IAddress } from "@/types/address.model";
+import { IConfig } from "@/types/config.model";
 
 const search = useRouteQuery("search", "");
+const searchDebounced = refDebounced(search, 1000);
+const locationName = useRouteQuery("locationName", null);
+const location = ref<IAddress | null>(null);
+
+watch(location, (newLocation) => {
+  console.debug("location change");
+  if (newLocation?.geom) {
+    latitude.value = parseFloat(newLocation?.geom.split(";")[1]);
+    longitude.value = parseFloat(newLocation?.geom.split(";")[0]);
+    locationName.value = newLocation?.description;
+  } else {
+    console.debug("location emptied");
+    latitude.value = undefined;
+    longitude.value = undefined;
+    locationName.value = null;
+  }
+});
 
 interface ISearchTimeOption {
   label: string;
@@ -580,6 +660,11 @@ const statusOneOf = useRouteQuery(
   arrayTransformer
 );
 const languageOneOf = useRouteQuery("languageOneOf", [], arrayTransformer);
+const searchTarget = useRouteQuery(
+  "target",
+  SearchTargets.INTERNAL,
+  enumTransformer(SearchTargets)
+);
 
 const EVENT_PAGE_LIMIT = 16;
 
@@ -667,8 +752,8 @@ const dateOptions: Record<string, ISearchTimeOption> = {
   },
   any: {
     label: t("Any day") as string,
-    start: undefined,
-    end: undefined,
+    start: new Date().toISOString(),
+    end: null,
   },
 };
 
@@ -688,9 +773,9 @@ const end = computed((): string | undefined | null => {
 
 const searchIsUrl = computed((): boolean => {
   let url;
-  if (!search.value) return false;
+  if (!searchDebounced.value) return false;
   try {
-    url = new URL(search.value);
+    url = new URL(searchDebounced.value);
   } catch (_) {
     return false;
   }
@@ -820,21 +905,48 @@ const geoHashLocation = computed(() =>
 
 const radius = computed(() => Number.parseInt(distance.value.slice(0, -3)));
 
+const { searchConfig, onResult: onSearchConfigResult } = useSearchConfig();
+
+onSearchConfigResult(({ data }) =>
+  handleSearchConfigChanged(data?.config?.search)
+);
+
+const handleSearchConfigChanged = (
+  searchConfigChanged: IConfig["search"] | undefined
+) => {
+  if (
+    searchConfigChanged?.global?.isEnabled &&
+    searchConfigChanged?.global?.isDefault
+  ) {
+    searchTarget.value = SearchTargets.GLOBAL;
+  }
+};
+
+watch(searchConfig, (newSearchConfig) =>
+  handleSearchConfigChanged(newSearchConfig)
+);
+
+const globalSearchEnabled = computed(
+  () => searchConfig.value?.global?.isEnabled
+);
+
 const { result: searchElementsResult, loading: searchLoading } = useQuery<{
   searchEvents: Paginate<IEvent>;
   searchGroups: Paginate<IGroup>;
 }>(SEARCH_EVENTS_AND_GROUPS, () => ({
-  term: search.value,
+  term: searchDebounced.value,
   tags: props.tag,
   location: geoHashLocation.value,
   beginsOn: start.value,
   endsOn: end.value,
-  radius: radius.value,
+  radius: geoHashLocation.value ? radius.value : undefined,
   eventPage: eventPage.value,
   groupPage: groupPage.value,
   limit: EVENT_PAGE_LIMIT,
-  type: isOnline.value ? "ONLINE" : "IN_PERSON",
+  type: isOnline.value ? "ONLINE" : undefined,
   categoryOneOf: categoryOneOf.value,
   statusOneOf: statusOneOf.value,
+  languageOneOf: languageOneOf.value,
+  searchTarget: searchTarget.value,
 }));
 </script>
diff --git a/js/src/views/Settings/NotificationsView.vue b/js/src/views/Settings/NotificationsView.vue
index 258883d65..6d4626192 100644
--- a/js/src/views/Settings/NotificationsView.vue
+++ b/js/src/views/Settings/NotificationsView.vue
@@ -281,7 +281,7 @@
           </o-tooltip>
           <o-button
             icon-left="refresh"
-            type="is-text"
+            variant="text"
             @click="openRegenerateFeedTokensConfirmation"
             @keyup.enter="openRegenerateFeedTokensConfirmation"
             >{{ $t("Regenerate new links") }}</o-button
@@ -291,7 +291,7 @@
       <div v-else>
         <o-button
           icon-left="refresh"
-          type="is-text"
+          variant="text"
           @click="generateFeedTokens"
           @keyup.enter="generateFeedTokens"
           >{{ $t("Create new links") }}</o-button
@@ -739,7 +739,7 @@ const {
 } = useMutation(UNREGISTER_PUSH_MUTATION);
 
 onUnregisterPushMutationDone(({ data }) => {
-  console.log(data);
+  console.debug(data);
   subscribed.value = false;
 });
 
diff --git a/js/src/views/Settings/PreferencesView.vue b/js/src/views/Settings/PreferencesView.vue
index 49d7fafe5..e4eaf9013 100644
--- a/js/src/views/Settings/PreferencesView.vue
+++ b/js/src/views/Settings/PreferencesView.vue
@@ -171,7 +171,7 @@ const locale = computed({
         locale: newLocale,
       });
       saveLocaleData(newLocale);
-      console.log("changing locale", i18nLocale, newLocale);
+      console.debug("changing locale", i18nLocale, newLocale);
       i18nLocale.value = newLocale;
     }
   },
diff --git a/js/src/views/Todos/TodoLists.vue b/js/src/views/Todos/TodoLists.vue
index f12756c06..4d3805218 100644
--- a/js/src/views/Todos/TodoLists.vue
+++ b/js/src/views/Todos/TodoLists.vue
@@ -40,7 +40,7 @@
         <router-link
           :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
         >
-          <h3 class="is-size-3">
+          <h3>
             {{
               $t(
                 "{title} ({count} todos)",
diff --git a/js/src/views/User/LoginView.vue b/js/src/views/User/LoginView.vue
index 923e01939..89fc25e9a 100644
--- a/js/src/views/User/LoginView.vue
+++ b/js/src/views/User/LoginView.vue
@@ -96,7 +96,7 @@
             name: RouteName.SEND_PASSWORD_RESET,
             params: { email: credentials.email },
           }"
-          >{{ t("Forgot your password ?") }}</o-button
+          >{{ t("Forgot your password?") }}</o-button
         >
         <o-button
           tag="router-link"
@@ -145,6 +145,7 @@ import AuthProviders from "@/components/User/AuthProviders.vue";
 import RouteName from "@/router/name";
 import { LoginError, LoginErrorCode } from "@/types/enums";
 import { useCurrentUserClient } from "@/composition/apollo/user";
+import { useHead } from "@vueuse/head";
 
 const props = withDefaults(
   defineProps<{
@@ -198,7 +199,9 @@ onLoginMutationDone(async (result) => {
     router.push(redirect.value);
     return;
   }
+  console.debug("No redirect, going to homepage");
   if (window.localStorage) {
+    console.debug("Has localstorage, setting welcome back");
     window.localStorage.setItem("welcome-back", "yes");
   }
   router.replace({ name: RouteName.HOME });
@@ -235,7 +238,6 @@ const { onDone: onCurrentUserMutationDone, mutate: updateCurrentUserMutation } =
   useMutation(UPDATE_CURRENT_USER_CLIENT);
 
 onCurrentUserMutationDone(async () => {
-  console.debug("saved current user client, now for actor client");
   try {
     await initializeCurrentActor();
   } catch (err: any) {
@@ -252,7 +254,6 @@ onCurrentUserMutationDone(async () => {
 });
 
 const setupClientUserAndActors = async (login: ILogin): Promise<void> => {
-  console.debug("setuping client user and actors after login", login);
   updateCurrentUserMutation({
     id: login.user.id,
     email: credentials.email,
@@ -276,7 +277,7 @@ const caseWarningText = computed<string | undefined>(() => {
 
 const caseWarningType = computed<string | undefined>(() => {
   if (hasCaseWarning.value) {
-    return "is-warning";
+    return "warning";
   }
   return undefined;
 });
@@ -306,4 +307,8 @@ onMounted(() => {
     router.push("/");
   }
 });
+
+useHead({
+  title: computed(() => t("Login")),
+});
 </script>
diff --git a/js/src/views/User/RegisterView.vue b/js/src/views/User/RegisterView.vue
index c55c0abf1..625480c32 100644
--- a/js/src/views/User/RegisterView.vue
+++ b/js/src/views/User/RegisterView.vue
@@ -209,14 +209,14 @@ import RouteName from "../../router/name";
 import { IConfig } from "../../types/config.model";
 import { CONFIG } from "../../graphql/config";
 import AuthProviders from "../../components/User/AuthProviders.vue";
-import { AbsintheGraphQLError } from "../../types/apollo";
 import { computed, reactive, ref, watch } from "vue";
 import { useMutation, useQuery } from "@vue/apollo-composable";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { useHead } from "@vueuse/head";
+import { AbsintheGraphQLErrors } from "@/types/errors.model";
 
-type errorType = "is-danger" | "is-warning";
+type errorType = "danger" | "warning";
 type errorMessage = { type: errorType; message: string };
 type credentialsType = { email: string; password: string; locale: string };
 
@@ -271,24 +271,25 @@ onDone(() => {
 });
 
 onError((error) => {
-  // @ts-ignore
-  error.graphQLErrors.forEach(({ field, message }: AbsintheGraphQLError) => {
-    switch (field) {
-      case "email":
-        emailErrors.value.push({
-          type: "is-danger" as errorType,
-          message: message[0] as string,
-        });
-        break;
-      case "password":
-        passwordErrors.value.push({
-          type: "is-danger" as errorType,
-          message: message[0] as string,
-        });
-        break;
-      default:
+  (error.graphQLErrors as AbsintheGraphQLErrors).forEach(
+    ({ field, message }) => {
+      switch (field) {
+        case "email":
+          emailErrors.value.push({
+            type: "danger" as errorType,
+            message: message[0] as string,
+          });
+          break;
+        case "password":
+          passwordErrors.value.push({
+            type: "danger" as errorType,
+            message: message[0] as string,
+          });
+          break;
+        default:
+      }
     }
-  });
+  );
   sendingForm.value = false;
 });
 
@@ -310,10 +311,10 @@ const submit = async (): Promise<void> => {
 watch(credentials, () => {
   if (credentials.email !== credentials.email.toLowerCase()) {
     const error = {
-      type: "is-warning" as errorType,
+      type: "warning" as errorType,
       message: t(
         "Emails usually don't contain capitals, make sure you haven't made a typo."
-      ) as string,
+      ),
     };
     emailErrors.value = [error];
   }
@@ -322,9 +323,9 @@ watch(credentials, () => {
 const maxErrorType = (errors: errorMessage[]): errorType | undefined => {
   if (!errors || errors.length === 0) return undefined;
   return errors.reduce<errorType>((acc, error) => {
-    if (error.type === "is-danger" || acc === "is-danger") return "is-danger";
-    return "is-warning";
-  }, "is-warning");
+    if (error.type === "danger" || acc === "danger") return "danger";
+    return "warning";
+  }, "warning");
 };
 
 const errorEmailType = computed((): errorType | undefined => {
diff --git a/js/src/views/User/SettingsOnboard.vue b/js/src/views/User/SettingsOnboard.vue
index 9b2a886a2..97ed777f1 100644
--- a/js/src/views/User/SettingsOnboard.vue
+++ b/js/src/views/User/SettingsOnboard.vue
@@ -43,7 +43,7 @@
       <o-button
         v-if="stepIndex >= 2"
         variant="success"
-        size="is-big"
+        size="big"
         tag="router-link"
         :to="{ name: RouteName.HOME }"
       >
diff --git a/js/tailwind.config.js b/js/tailwind.config.js
index a4e82cfe0..9517e79f0 100644
--- a/js/tailwind.config.js
+++ b/js/tailwind.config.js
@@ -9,9 +9,58 @@ module.exports = {
         tag: "rgb(var(--color-tag) / <alpha-value>)",
         "frama-violet": "#725794",
         "frama-orange": "#cc4e13",
-        "mbz-yellow": "#ffd599",
-        "mbz-purple": "#424056",
-        "mbz-bluegreen": "#1e7d97",
+        "mbz-yellow": {
+          DEFAULT: "#FFD599",
+          50: "#FFFFFF",
+          100: "#FFFFFF",
+          200: "#FFFFFF",
+          300: "#FFF7EB",
+          400: "#FFE6C2",
+          500: "#FFD599",
+          600: "#FFBE61",
+          700: "#FFA729",
+          800: "#F08D00",
+          900: "#B86C00",
+        },
+        "mbz-yellow-alt": {
+          DEFAULT: "#FAB12D",
+          50: "#FEF4E0",
+          100: "#FEECCC",
+          200: "#FDDDA5",
+          300: "#FCCF7D",
+          400: "#FBC055",
+          500: "#FAB12D",
+          600: "#E99806",
+          700: "#B37404",
+          800: "#7C5103",
+          900: "#452D02",
+        },
+        "mbz-purple": {
+          DEFAULT: "#424056",
+          50: "#9C9AB4",
+          100: "#918EAB",
+          200: "#7A779A",
+          300: "#666385",
+          400: "#54516D",
+          500: "#424056",
+          600: "#292836",
+          700: "#111016",
+          800: "#000000",
+          900: "#000000",
+        },
+        "mbz-bluegreen": {
+          DEFAULT: "#1E7D97",
+          50: "#86D2E7",
+          100: "#75CCE4",
+          200: "#53BFDD",
+          300: "#31B2D6",
+          400: "#2599B9",
+          500: "#1E7D97",
+          600: "#155668",
+          700: "#0B3039",
+          800: "#02090B",
+          900: "#000000",
+        },
         "violet-1": "#3a384c",
         "violet-2": "#474467",
         "violet-3": "#3c376e",
diff --git a/js/tests/e2e/login.spec.ts b/js/tests/e2e/login.spec.ts
new file mode 100644
index 000000000..8b32d4a94
--- /dev/null
+++ b/js/tests/e2e/login.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from "@playwright/test";
+
+test("Login has everything we need", async ({ page }) => {
+  await page.goto("/login");
+
+  // Expect a title "to contain" a substring.
+  await expect(page).toHaveTitle(/Login/);
+
+  const forgotPasswordLink = page.locator("a", {
+    hasText: "Forgot your password?",
+  });
+
+  const reAskInstructionsLink = page.locator("a", {
+    hasText: "Didn't receive the instructions?",
+  });
+
+  const registerLink = page.locator("a", { hasText: "Create an account" });
+
+  await expect(forgotPasswordLink).toBeVisible();
+  await expect(reAskInstructionsLink).toBeVisible();
+  await expect(registerLink).toBeVisible();
+
+  await expect(page.locator("form .field label").first()).toHaveText("Email");
+  await expect(page.locator("form .field label").nth(1)).toHaveText("Password");
+
+  await forgotPasswordLink.click();
+  await page.waitForURL("/password-reset/send");
+  await expect(page.url()).toContain("/password-reset/send");
+  await page.goBack();
+
+  await reAskInstructionsLink.click();
+  await page.waitForURL("/resend-instructions");
+  await expect(page.url()).toContain("/resend-instructions");
+  await page.goBack();
+
+  await registerLink.click();
+  await page.waitForURL("/register/user");
+  await expect(page.url()).toContain("/register/user");
+  await page.goBack();
+});
+
+test("Login rejects unknown users properly", async ({ page }) => {
+  await page.goto("/login");
+
+  await page.locator("#email").fill("hello@me.com");
+  await page.locator("#password").fill("some password");
+
+  const loginButton = page.locator("form button", { hasText: "Login" });
+
+  await expect(loginButton).toHaveAttribute("type", "submit");
+
+  await loginButton.click();
+
+  await expect(page.locator(".notification-danger")).toHaveText(
+    "User not found"
+  );
+});
+
+test("Tries to login with valid credentials", async ({ page, context }) => {
+  await page.goto("/login");
+
+  await page.locator("#email").fill("user@provider.org");
+  await page.locator("#password").fill("valid_passw0rd");
+
+  const loginButton = page.locator("form button", { hasText: "Login" });
+
+  await expect(loginButton).toHaveAttribute("type", "submit");
+
+  await loginButton.click();
+  await page.waitForURL("/");
+  await expect(new URL(page.url()).pathname).toBe("/");
+  expect((await context.storageState()).origins[0].localStorage).toBe("toto");
+});
diff --git a/js/tests/unit/specs/components/Comment/__snapshots__/CommentTree.spec.ts.snap b/js/tests/unit/specs/components/Comment/__snapshots__/CommentTree.spec.ts.snap
index e650afff1..7636fdb19 100644
--- a/js/tests/unit/specs/components/Comment/__snapshots__/CommentTree.spec.ts.snap
+++ b/js/tests/unit/specs/components/Comment/__snapshots__/CommentTree.spec.ts.snap
@@ -1,67 +1,67 @@
 // Vitest Snapshot v1
 
 exports[`CommentTree > renders a comment tree with comments 1`] = `
-"<div data-v-1d76124d=\\"\\">
-  <form class=\\"\\" data-v-1d76124d=\\"\\">
+"<div data-v-5d0380ab=\\"\\">
+  <form class=\\"\\" data-v-5d0380ab=\\"\\">
     <!--v-if-->
-    <article class=\\"flex flex-wrap items-start gap-2\\" data-v-1d76124d=\\"\\">
-      <figure class=\\"\\" data-v-1d76124d=\\"\\">
-        <identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-1d76124d=\\"\\"></identity-picker-wrapper-stub>
+    <article class=\\"flex flex-wrap items-start gap-2\\" data-v-5d0380ab=\\"\\">
+      <figure class=\\"\\" data-v-5d0380ab=\\"\\">
+        <identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-5d0380ab=\\"\\"></identity-picker-wrapper-stub>
       </figure>
-      <div class=\\"flex-1\\" data-v-1d76124d=\\"\\">
-        <div class=\\"flex flex-col gap-2\\" data-v-1d76124d=\\"\\">
-          <div class=\\"editor-wrapper\\" data-v-1d76124d=\\"\\">
-            <editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-1d76124d=\\"\\"></editor-stub>
+      <div class=\\"flex-1\\" data-v-5d0380ab=\\"\\">
+        <div class=\\"flex flex-col gap-2\\" data-v-5d0380ab=\\"\\">
+          <div class=\\"editor-wrapper\\" data-v-5d0380ab=\\"\\">
+            <editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-5d0380ab=\\"\\"></editor-stub>
             <!--v-if-->
           </div>
           <!--v-if-->
         </div>
       </div>
-      <div class=\\"\\" data-v-1d76124d=\\"\\">
-        <o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-1d76124d=\\"\\"></o-button-stub>
+      <div class=\\"\\" data-v-5d0380ab=\\"\\">
+        <o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-5d0380ab=\\"\\"></o-button-stub>
       </div>
     </article>
   </form>
-  <transition-group-stub data-v-1d76124d=\\"\\">
-    <transition-group-stub data-v-1d76124d=\\"\\">
-      <comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-1d76124d=\\"\\"></comment-stub>
-      <comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-1d76124d=\\"\\"></comment-stub>
+  <transition-group-stub data-v-5d0380ab=\\"\\">
+    <transition-group-stub data-v-5d0380ab=\\"\\">
+      <comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-5d0380ab=\\"\\"></comment-stub>
+      <comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-5d0380ab=\\"\\"></comment-stub>
     </transition-group-stub>
   </transition-group-stub>
 </div>"
 `;
 
 exports[`CommentTree > renders a loading comment tree 1`] = `
-"<div data-v-1d76124d=\\"\\">
+"<div data-v-5d0380ab=\\"\\">
   <!--v-if-->
-  <p class=\\"text-center\\" data-v-1d76124d=\\"\\">Loading comments…</p>
+  <p class=\\"text-center\\" data-v-5d0380ab=\\"\\">Loading comments…</p>
 </div>"
 `;
 
 exports[`CommentTree > renders an empty comment tree 1`] = `
-"<div data-v-1d76124d=\\"\\">
-  <form class=\\"\\" data-v-1d76124d=\\"\\">
+"<div data-v-5d0380ab=\\"\\">
+  <form class=\\"\\" data-v-5d0380ab=\\"\\">
     <!--v-if-->
-    <article class=\\"flex flex-wrap items-start gap-2\\" data-v-1d76124d=\\"\\">
-      <figure class=\\"\\" data-v-1d76124d=\\"\\">
-        <identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-1d76124d=\\"\\"></identity-picker-wrapper-stub>
+    <article class=\\"flex flex-wrap items-start gap-2\\" data-v-5d0380ab=\\"\\">
+      <figure class=\\"\\" data-v-5d0380ab=\\"\\">
+        <identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-5d0380ab=\\"\\"></identity-picker-wrapper-stub>
       </figure>
-      <div class=\\"flex-1\\" data-v-1d76124d=\\"\\">
-        <div class=\\"flex flex-col gap-2\\" data-v-1d76124d=\\"\\">
-          <div class=\\"editor-wrapper\\" data-v-1d76124d=\\"\\">
-            <editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-1d76124d=\\"\\"></editor-stub>
+      <div class=\\"flex-1\\" data-v-5d0380ab=\\"\\">
+        <div class=\\"flex flex-col gap-2\\" data-v-5d0380ab=\\"\\">
+          <div class=\\"editor-wrapper\\" data-v-5d0380ab=\\"\\">
+            <editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-5d0380ab=\\"\\"></editor-stub>
             <!--v-if-->
           </div>
           <!--v-if-->
         </div>
       </div>
-      <div class=\\"\\" data-v-1d76124d=\\"\\">
-        <o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-1d76124d=\\"\\"></o-button-stub>
+      <div class=\\"\\" data-v-5d0380ab=\\"\\">
+        <o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-5d0380ab=\\"\\"></o-button-stub>
       </div>
     </article>
   </form>
-  <transition-group-stub data-v-1d76124d=\\"\\">
-    <empty-content-stub icon=\\"comment\\" descriptionclasses=\\"\\" inline=\\"true\\" center=\\"false\\" data-v-1d76124d=\\"\\"></empty-content-stub>
+  <transition-group-stub data-v-5d0380ab=\\"\\">
+    <empty-content-stub icon=\\"comment\\" descriptionclasses=\\"\\" inline=\\"true\\" center=\\"false\\" data-v-5d0380ab=\\"\\"></empty-content-stub>
   </transition-group-stub>
 </div>"
 `;
diff --git a/js/tests/unit/specs/components/Participation/ParticipationSection.spec.ts b/js/tests/unit/specs/components/Participation/ParticipationSection.spec.ts
index e63066b9a..94a59ff4e 100644
--- a/js/tests/unit/specs/components/Participation/ParticipationSection.spec.ts
+++ b/js/tests/unit/specs/components/Participation/ParticipationSection.spec.ts
@@ -94,7 +94,7 @@ describe("ParticipationSection", () => {
       "Cancel anonymous participation"
     );
 
-    wrapper.find(".event-participation small .is-clickable").trigger("click");
+    wrapper.find(".event-participation small span").trigger("click");
     expect(
       wrapper
         .findComponent({ ref: "anonymous-participation-modal" })
@@ -123,7 +123,7 @@ describe("ParticipationSection", () => {
       "Cancel anonymous participation"
     );
 
-    wrapper.find(".event-participation small .is-clickable").trigger("click");
+    wrapper.find(".event-participation small span").trigger("click");
 
     await wrapper.vm.$nextTick();
     const modal = wrapper.findComponent({
diff --git a/js/tests/unit/specs/components/Post/__snapshots__/PostListItem.spec.ts.snap b/js/tests/unit/specs/components/Post/__snapshots__/PostListItem.spec.ts.snap
index 2f35050bb..0507869a5 100644
--- a/js/tests/unit/specs/components/Post/__snapshots__/PostListItem.spec.ts.snap
+++ b/js/tests/unit/specs/components/Post/__snapshots__/PostListItem.spec.ts.snap
@@ -1,11 +1,11 @@
 // Vitest Snapshot v1
 
 exports[`PostListItem > renders post list item with basic informations 1`] = `
-"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-3b2c1ec0=\\"\\">
+"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
   <!--v-if-->
-  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-3b2c1ec0=\\"\\">
-    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-3b2c1ec0=\\"\\">My Blog Post</h3>
-    <p class=\\"flex gap-2\\" data-v-3b2c1ec0=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-3b2c1ec0=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-3b2c1ec0=\\"\\">Dec 2, 2020</span></p>
+  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
+    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
+    <p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
     <!--v-if-->
     <!--v-if-->
   </div>
@@ -13,24 +13,24 @@ exports[`PostListItem > renders post list item with basic informations 1`] = `
 `;
 
 exports[`PostListItem > renders post list item with publisher name 1`] = `
-"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-3b2c1ec0=\\"\\">
+"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
   <!--v-if-->
-  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-3b2c1ec0=\\"\\">
-    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-3b2c1ec0=\\"\\">My Blog Post</h3>
-    <p class=\\"flex gap-2\\" data-v-3b2c1ec0=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-3b2c1ec0=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-3b2c1ec0=\\"\\">Dec 2, 2020</span></p>
+  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
+    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
+    <p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
     <!--v-if-->
-    <p class=\\"flex gap-1\\" data-v-3b2c1ec0=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon account-edit-icon\\" role=\\"img\\" data-v-3b2c1ec0=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M21.7,13.35L20.7,14.35L18.65,12.3L19.65,11.3C19.86,11.09 20.21,11.09 20.42,11.3L21.7,12.58C21.91,12.79 21.91,13.14 21.7,13.35M12,18.94L18.06,12.88L20.11,14.93L14.06,21H12V18.94M12,14C7.58,14 4,15.79 4,18V20H10V18.11L14,14.11C13.34,14.03 12.67,14 12,14M12,4A4,4 0 0,0 8,8A4,4 0 0,0 12,12A4,4 0 0,0 16,8A4,4 0 0,0 12,4Z\\"><!--v-if--></path></svg></span>Published by <b class=\\"\\" data-v-3b2c1ec0=\\"\\">An author</b></p>
+    <p class=\\"flex gap-1\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon account-edit-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M21.7,13.35L20.7,14.35L18.65,12.3L19.65,11.3C19.86,11.09 20.21,11.09 20.42,11.3L21.7,12.58C21.91,12.79 21.91,13.14 21.7,13.35M12,18.94L18.06,12.88L20.11,14.93L14.06,21H12V18.94M12,14C7.58,14 4,15.79 4,18V20H10V18.11L14,14.11C13.34,14.03 12.67,14 12,14M12,4A4,4 0 0,0 8,8A4,4 0 0,0 12,12A4,4 0 0,0 16,8A4,4 0 0,0 12,4Z\\"><!--v-if--></path></svg></span>Published by <b class=\\"\\" data-v-6ca7cc69=\\"\\">An author</b></p>
   </div>
 </a>"
 `;
 
 exports[`PostListItem > renders post list item with tags 1`] = `
-"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-3b2c1ec0=\\"\\">
+"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
   <!--v-if-->
-  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-3b2c1ec0=\\"\\">
-    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-3b2c1ec0=\\"\\">My Blog Post</h3>
-    <p class=\\"flex gap-2\\" data-v-3b2c1ec0=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-3b2c1ec0=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-3b2c1ec0=\\"\\">Dec 2, 2020</span></p>
-    <div class=\\"flex flex-wrap gap-y-0 gap-x-2\\" data-v-3b2c1ec0=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon tag-icon\\" role=\\"img\\" data-v-3b2c1ec0=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M5.5,7A1.5,1.5 0 0,1 4,5.5A1.5,1.5 0 0,1 5.5,4A1.5,1.5 0 0,1 7,5.5A1.5,1.5 0 0,1 5.5,7M21.41,11.58L12.41,2.58C12.05,2.22 11.55,2 11,2H4C2.89,2 2,2.89 2,4V11C2,11.55 2.22,12.05 2.59,12.41L11.58,21.41C11.95,21.77 12.45,22 13,22C13.55,22 14.05,21.77 14.41,21.41L21.41,14.41C21.78,14.05 22,13.55 22,13C22,12.44 21.77,11.94 21.41,11.58Z\\"><!--v-if--></path></svg></span><span class=\\"rounded-md my-1 truncate text-sm text-violet-title capitalize px-2 py-1 bg-purple-3 dark:text-violet-3\\" data-v-bb7ceecc=\\"\\" data-v-3b2c1ec0=\\"\\">A tag</span></div>
+  <div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
+    <h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
+    <p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
+    <div class=\\"flex flex-wrap gap-y-0 gap-x-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon tag-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M5.5,7A1.5,1.5 0 0,1 4,5.5A1.5,1.5 0 0,1 5.5,4A1.5,1.5 0 0,1 7,5.5A1.5,1.5 0 0,1 5.5,7M21.41,11.58L12.41,2.58C12.05,2.22 11.55,2 11,2H4C2.89,2 2,2.89 2,4V11C2,11.55 2.22,12.05 2.59,12.41L11.58,21.41C11.95,21.77 12.45,22 13,22C13.55,22 14.05,21.77 14.41,21.41L21.41,14.41C21.78,14.05 22,13.55 22,13C22,12.44 21.77,11.94 21.41,11.58Z\\"><!--v-if--></path></svg></span><span class=\\"rounded-md my-1 truncate text-sm text-violet-title px-2 py-1 bg-purple-3 dark:text-violet-3\\" data-v-1b13cba6=\\"\\" data-v-6ca7cc69=\\"\\">A tag</span></div>
     <!--v-if-->
   </div>
 </a>"
diff --git a/js/tests/unit/specs/components/Report/__snapshots__/ReportModal.spec.ts.snap b/js/tests/unit/specs/components/Report/__snapshots__/ReportModal.spec.ts.snap
index 51caa24a9..b1ca4cea6 100644
--- a/js/tests/unit/specs/components/Report/__snapshots__/ReportModal.spec.ts.snap
+++ b/js/tests/unit/specs/components/Report/__snapshots__/ReportModal.spec.ts.snap
@@ -1,16 +1,16 @@
 // Vitest Snapshot v1
 
 exports[`ReportModal > renders report modal with basic informations and submits it 1`] = `
-"<div class=\\"p-2\\" data-v-8c6db6e4=\\"\\">
+"<div class=\\"p-2\\" data-v-e0cceef3=\\"\\">
   <!--v-if-->
-  <section data-v-8c6db6e4=\\"\\">
-    <div class=\\"flex gap-1 flex-row mb-3\\" data-v-8c6db6e4=\\"\\"><span class=\\"o-icon o-icon--warning hidden md:block flex-1\\" data-v-8c6db6e4=\\"\\"><i class=\\"mdi mdi-alert 48\\"></i></span>
-      <p data-v-8c6db6e4=\\"\\">The report will be sent to the moderators of your instance. You can explain why you report this content below.</p>
+  <section data-v-e0cceef3=\\"\\">
+    <div class=\\"flex gap-1 flex-row mb-3\\" data-v-e0cceef3=\\"\\"><span class=\\"o-icon o-icon--warning hidden md:block flex-1\\" data-v-e0cceef3=\\"\\"><i class=\\"mdi mdi-alert 48\\"></i></span>
+      <p data-v-e0cceef3=\\"\\">The report will be sent to the moderators of your instance. You can explain why you report this content below.</p>
     </div>
-    <div class=\\"\\" data-v-8c6db6e4=\\"\\">
+    <div class=\\"\\" data-v-e0cceef3=\\"\\">
       <!--v-if-->
-      <div class=\\"o-field o-field--filled\\" data-v-8c6db6e4=\\"\\"><label for=\\"additonal-comments\\" class=\\"o-field__label\\">Additional comments</label>
-        <div class=\\"o-ctrl-input\\" data-v-8c6db6e4=\\"\\"><textarea id=\\"additonal-comments\\" class=\\"o-input o-input__textarea\\"></textarea>
+      <div class=\\"o-field o-field--filled\\" data-v-e0cceef3=\\"\\"><label for=\\"additonal-comments\\" class=\\"o-field__label\\">Additional comments</label>
+        <div class=\\"o-ctrl-input\\" data-v-e0cceef3=\\"\\"><textarea id=\\"additonal-comments\\" class=\\"o-input o-input__textarea\\"></textarea>
           <!--v-if-->
           <!--v-if-->
           <!--v-if-->
@@ -20,9 +20,9 @@ exports[`ReportModal > renders report modal with basic informations and submits
       <!--v-if-->
     </div>
   </section>
-  <footer class=\\"flex gap-2 py-3\\" data-v-8c6db6e4=\\"\\"><button type=\\"button\\" class=\\"o-btn\\" data-v-8c6db6e4=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Cancel</span>
+  <footer class=\\"flex gap-2 py-3\\" data-v-e0cceef3=\\"\\"><button type=\\"button\\" class=\\"o-btn\\" data-v-e0cceef3=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Cancel</span>
       <!--v-if--></span>
-    </button><button type=\\"button\\" class=\\"o-btn o-btn--primary\\" data-v-8c6db6e4=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Send the report</span>
+    </button><button type=\\"button\\" class=\\"o-btn o-btn--primary\\" data-v-e0cceef3=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Send the report</span>
       <!--v-if--></span>
     </button></footer>
 </div>"
diff --git a/js/tests/unit/specs/components/__snapshots__/navbar.spec.ts.snap b/js/tests/unit/specs/components/__snapshots__/navbar.spec.ts.snap
index ed438e289..f40e465ab 100644
--- a/js/tests/unit/specs/components/__snapshots__/navbar.spec.ts.snap
+++ b/js/tests/unit/specs/components/__snapshots__/navbar.spec.ts.snap
@@ -1,21 +1,23 @@
 // Vitest Snapshot v1
 
 exports[`App component > renders a Vue component 1`] = `
-"<nav class=\\"bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-gray-900\\" data-v-4295d220=\\"\\">
-  <div class=\\"container mx-auto flex flex-wrap justify-between items-center mx-auto\\" data-v-4295d220=\\"\\">
-    <router-link to=\\"[object Object]\\" class=\\"flex items-center\\" data-v-4295d220=\\"\\">
-      <logo-stub invert=\\"false\\" class=\\"w-40\\" data-v-4295d220=\\"\\"></logo-stub>
+"<nav class=\\"bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-800\\">
+  <div class=\\"container mx-auto flex flex-wrap items-center mx-auto gap-4\\">
+    <router-link to=\\"[object Object]\\" class=\\"flex items-center\\">
+      <mobilizon-logo-stub invert=\\"false\\" class=\\"w-40\\"></mobilizon-logo-stub>
     </router-link>
-    <!--v-if--><button type=\\"button\\" class=\\"inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600\\" aria-controls=\\"mobile-menu-2\\" aria-expanded=\\"false\\" data-v-4295d220=\\"\\"><span class=\\"sr-only\\" data-v-4295d220=\\"\\">Open main menu</span><svg class=\\"w-6 h-6\\" aria-hidden=\\"true\\" fill=\\"currentColor\\" viewBox=\\"0 0 20 20\\" xmlns=\\"http://www.w3.org/2000/svg\\" data-v-4295d220=\\"\\">
-        <path fill-rule=\\"evenodd\\" d=\\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\\" clip-rule=\\"evenodd\\" data-v-4295d220=\\"\\"></path>
+    <!--v-if--><button type=\\"button\\" class=\\"inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600\\" aria-controls=\\"mobile-menu-2\\" aria-expanded=\\"false\\"><span class=\\"sr-only\\">Open main menu</span><svg class=\\"w-6 h-6\\" aria-hidden=\\"true\\" fill=\\"currentColor\\" viewBox=\\"0 0 20 20\\" xmlns=\\"http://www.w3.org/2000/svg\\">
+        <path fill-rule=\\"evenodd\\" d=\\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\\" clip-rule=\\"evenodd\\"></path>
       </svg></button>
-    <div class=\\"justify-between items-center w-full md:flex md:w-auto md:order-1 hidden\\" id=\\"mobile-menu-2\\" data-v-4295d220=\\"\\">
-      <ul class=\\"flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:text-sm md:font-medium\\" data-v-4295d220=\\"\\">
-        <li data-v-4295d220=\\"\\">
-          <router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\" data-v-4295d220=\\"\\">Login</router-link>
+    <div class=\\"justify-between items-center w-full md:flex md:w-auto md:order-1 hidden\\" id=\\"mobile-menu-2\\">
+      <ul class=\\"flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:text-sm md:font-medium\\">
+        <!--v-if-->
+        <!--v-if-->
+        <li>
+          <router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\">Login</router-link>
         </li>
-        <li data-v-4295d220=\\"\\">
-          <router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\" data-v-4295d220=\\"\\">Register</router-link>
+        <li>
+          <router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\">Register</router-link>
         </li>
       </ul>
     </div>
diff --git a/js/tests/unit/specs/mocks/matchMedia.ts b/js/tests/unit/specs/mocks/matchMedia.ts
index 3d944b875..023757a1f 100644
--- a/js/tests/unit/specs/mocks/matchMedia.ts
+++ b/js/tests/unit/specs/mocks/matchMedia.ts
@@ -1,6 +1,6 @@
 import { vi } from "vitest";
 
-window.matchMedia = vi.fn().mockImplementation((query) => ({
+vi.stubGlobal("matchMedia", (query: string) => ({
   matches: false,
   media: query,
   onchange: null,
@@ -10,17 +10,3 @@ window.matchMedia = vi.fn().mockImplementation((query) => ({
   removeEventListener: vi.fn(),
   dispatchEvent: vi.fn(),
 }));
-
-// Object.defineProperty(window, "matchMedia", {
-//   writable: true,
-//   value: vi.fn().mockImplementation((query) => ({
-//     matches: false,
-//     media: query,
-//     onchange: null,
-//     addListener: vi.fn(), // deprecated
-//     removeListener: vi.fn(), // deprecated
-//     addEventListener: vi.fn(),
-//     removeEventListener: vi.fn(),
-//     dispatchEvent: vi.fn(),
-//   })),
-// });
diff --git a/js/vite.config.js b/js/vite.config.js
index ea799e616..ca4628a9a 100644
--- a/js/vite.config.js
+++ b/js/vite.config.js
@@ -75,6 +75,7 @@ export default defineConfig(({ command }) => {
         },
       },
       setupFiles: path.resolve(__dirname, "./tests/unit/setup.ts"),
+      include: [path.resolve(__dirname, "./tests/unit/specs/**/*.spec.ts")],
     },
   };
 });
diff --git a/js/yarn.lock b/js/yarn.lock
index ea88daf8f..8ee2ee0e8 100644
--- a/js/yarn.lock
+++ b/js/yarn.lock
@@ -1243,6 +1243,14 @@
   resolved "https://registry.yarnpkg.com/@oruga-ui/oruga-next/-/oruga-next-0.5.5.tgz#b7b5e6a54e663bb0cab6c35f5bece4697ae003bf"
   integrity sha512-LmieX0fo0hbhJQRaKf4rfUlyDUwGVYkjGmSNfbontRKfKrEmmr1w1mxraF2Orxc894gkwi64NQh/SRSm4pIE5g==
 
+"@playwright/test@^1.25.1":
+  version "1.25.1"
+  resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.25.1.tgz#9847234b6f2b0cca71962b338da7db366a1e9720"
+  integrity sha512-IJ4X0yOakXtwkhbnNzKkaIgXe6df7u3H3FnuhI9Jqh+CdO0e/lYQlDLYiyI9cnXK8E7UAppAWP+VqAv6VX7HQg==
+  dependencies:
+    "@types/node" "*"
+    playwright-core "1.25.1"
+
 "@polka/url@^1.0.0-next.20":
   version "1.0.0-next.21"
   resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
@@ -1763,6 +1771,11 @@
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
   integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
+"@types/web-bluetooth@^0.0.15":
+  version "0.0.15"
+  resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz#d60330046a6ed8a13b4a53df3813c44942ebdf72"
+  integrity sha512-w7hEHXnPMEZ+4nGKl/KDRVpxkwYxYExuHOYXyzIzCDzEZ9ZCGMAewulr9IqJu2LR4N37fcnb1XVeuZ09qgOxhA==
+
 "@typescript-eslint/eslint-plugin@^5.0.0":
   version "5.33.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz#c0a480d05211660221eda963cc844732fe9b1714"
@@ -1996,11 +2009,26 @@
   resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.2.tgz#0b5edd683366153d5bc5a91edc62f292118710eb"
   integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g==
 
+"@vueuse/core@^9.1.0":
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.1.0.tgz#f0fb13fd99768c0eb617169a2d2c1cbd5f5a52eb"
+  integrity sha512-BIroqvXEqt826aE9r3K5cox1zobuPuAzdYJ36kouC2TVhlXvFKIILgFVWrpp9HZPwB3aLzasmG3K87q7TSyXZg==
+  dependencies:
+    "@types/web-bluetooth" "^0.0.15"
+    "@vueuse/metadata" "9.1.0"
+    "@vueuse/shared" "9.1.0"
+    vue-demi "*"
+
 "@vueuse/head@^0.7.9":
   version "0.7.9"
   resolved "https://registry.yarnpkg.com/@vueuse/head/-/head-0.7.9.tgz#888ab87667ab6dbe6edba10d176fa91c1b0ec021"
   integrity sha512-5wnRiH2XIUSLLXJDLDDTcpvAg5QXgTIVZl46AU7to/T91KHsdBLHSE4WhRO7kP0jbkAhlxnx64E29cQtwBrMjg==
 
+"@vueuse/metadata@9.1.0":
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.1.0.tgz#194d4bd47f7acb91e348c0f436e678ddf7ee235b"
+  integrity sha512-8OEhlog1iaAGTD3LICZ8oBGQdYeMwByvXetOtAOZCJOzyCRSwqwdggTsmVZZ1rkgYIEqgUBk942AsAPwM21s6A==
+
 "@vueuse/router@^9.0.2":
   version "9.1.0"
   resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-9.1.0.tgz#91deeb9fb779677cbaf4826c6de588ed294a7b69"
@@ -4749,6 +4777,11 @@ plausible-tracker@^0.3.4:
   resolved "https://registry.yarnpkg.com/plausible-tracker/-/plausible-tracker-0.3.8.tgz#9b8b322cc41e0e1d6473869ef234deea365a5a40"
   integrity sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==
 
+playwright-core@1.25.1:
+  version "1.25.1"
+  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.25.1.tgz#abe56aec8bef645fba988320d9f9328fafab0446"
+  integrity sha512-lSvPCmA2n7LawD2Hw7gSCLScZ+vYRkhU8xH0AapMyzwN+ojoDqhkH/KIEUxwNu2PjPoE/fcE0wLAksdOhJ2O5g==
+
 postcss-import@^14.1.0:
   version "14.1.0"
   resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"
diff --git a/lib/graphql/api/search.ex b/lib/graphql/api/search.ex
index 28c42c95d..4ccdbae71 100644
--- a/lib/graphql/api/search.ex
+++ b/lib/graphql/api/search.ex
@@ -7,10 +7,10 @@ defmodule Mobilizon.GraphQL.API.Search do
   alias Mobilizon.Actors.Actor
   alias Mobilizon.Events
   alias Mobilizon.Events.Event
-  alias Mobilizon.Storage.Page
-
   alias Mobilizon.Federation.ActivityPub
   alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
+  alias Mobilizon.Service.GlobalSearch
+  alias Mobilizon.Storage.Page
   import Mobilizon.GraphQL.Resolvers.Event.Utils
 
   require Logger
@@ -40,23 +40,29 @@ defmodule Mobilizon.GraphQL.API.Search do
         {:ok, process_from_username(term)}
 
       true ->
-        page =
-          Actors.search_actors(
-            term,
-            [
-              actor_type: result_type,
-              radius: Map.get(args, :radius),
-              location: Map.get(args, :location),
-              minimum_visibility: Map.get(args, :minimum_visibility, :public),
-              current_actor_id: Map.get(args, :current_actor_id),
-              exclude_my_groups: Map.get(args, :exclude_my_groups, false),
-              exclude_stale_actors: true
-            ],
-            page,
-            limit
-          )
+        if is_global_search(args) do
+          service = GlobalSearch.service()
 
-        {:ok, page}
+          {:ok, service.search_groups(Keyword.new(args, fn {k, v} -> {k, v} end))}
+        else
+          page =
+            Actors.search_actors(
+              term,
+              [
+                actor_type: result_type,
+                radius: Map.get(args, :radius),
+                location: Map.get(args, :location),
+                minimum_visibility: Map.get(args, :minimum_visibility, :public),
+                current_actor_id: Map.get(args, :current_actor_id),
+                exclude_my_groups: Map.get(args, :exclude_my_groups, false),
+                exclude_stale_actors: true
+              ],
+              page,
+              limit
+            )
+
+          {:ok, page}
+        end
     end
   end
 
@@ -82,7 +88,13 @@ defmodule Mobilizon.GraphQL.API.Search do
           {:ok, %{total: 0, elements: []}}
       end
     else
-      {:ok, Events.build_events_for_search(Map.put(args, :term, term), page, limit)}
+      if is_global_search(args) do
+        service = GlobalSearch.service()
+
+        {:ok, service.search_events(Keyword.new(args, fn {k, v} -> {k, v} end))}
+      else
+        {:ok, Events.build_events_for_search(Map.put(args, :term, term), page, limit)}
+      end
     end
   end
 
@@ -136,4 +148,18 @@ defmodule Mobilizon.GraphQL.API.Search do
 
   @spec is_handle(String.t()) :: boolean
   defp is_handle(search), do: String.match?(search, ~r/@/)
+
+  defp is_global_search(%{search_target: :global}) do
+    global_search_enabled?()
+  end
+
+  defp is_global_search(_), do: global_search_enabled?() && global_search_default?()
+
+  defp global_search_enabled? do
+    Application.get_env(:mobilizon, :search) |> get_in([:global]) |> get_in([:is_enabled])
+  end
+
+  defp global_search_default? do
+    Application.get_env(:mobilizon, :search) |> get_in([:global]) |> get_in([:is_default_search])
+  end
 end
diff --git a/lib/graphql/resolvers/address.ex b/lib/graphql/resolvers/address.ex
index 8d5596a9b..db284cede 100644
--- a/lib/graphql/resolvers/address.ex
+++ b/lib/graphql/resolvers/address.ex
@@ -45,7 +45,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Address do
         _context
       ) do
     addresses =
-      Geospatial.service().geocode(longitude, latitude, lang: locale, zoom: zoom)
+      longitude
+      |> Geospatial.service().geocode(latitude, lang: locale, zoom: zoom)
       |> Enum.map(fn address ->
         picture_info =
           Pictures.service().search(address.locality || address.region || address.country)
diff --git a/lib/graphql/resolvers/config.ex b/lib/graphql/resolvers/config.ex
index 53ada9eae..f26ce8a85 100644
--- a/lib/graphql/resolvers/config.ex
+++ b/lib/graphql/resolvers/config.ex
@@ -172,7 +172,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
           get_in(Application.get_env(:web_push_encryption, :vapid_details), [:public_key])
       },
       export_formats: Config.instance_export_formats(),
-      analytics: FrontEndAnalytics.config()
+      analytics: FrontEndAnalytics.config(),
+      search: %{
+        global: %{
+          is_enabled:
+            Application.get_env(:mobilizon, :search) |> get_in([:global]) |> get_in([:is_enabled]),
+          is_default:
+            Application.get_env(:mobilizon, :search)
+            |> get_in([:global])
+            |> get_in([:is_default_search])
+        }
+      }
     }
   end
 end
diff --git a/lib/graphql/resolvers/followers.ex b/lib/graphql/resolvers/followers.ex
index 0fe127006..8999c5f5e 100644
--- a/lib/graphql/resolvers/followers.ex
+++ b/lib/graphql/resolvers/followers.ex
@@ -67,4 +67,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Followers do
   end
 
   def update_follower(_, _, _), do: {:error, :unauthenticated}
+
+  def count_followers_for_group(%Actor{type: :Group} = group, _args, _resolution) do
+    {:ok, Actors.count_followers_for_actor(group)}
+  end
 end
diff --git a/lib/graphql/resolvers/member.ex b/lib/graphql/resolvers/member.ex
index 9f479ff99..b43428d93 100644
--- a/lib/graphql/resolvers/member.ex
+++ b/lib/graphql/resolvers/member.ex
@@ -254,6 +254,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
          "You must be logged-in to remove a member"
        )}
 
+  def count_members_for_group(%Actor{type: :Group} = group, _args, _resolution) do
+    {:ok, Actors.count_members_for_group(group)}
+  end
+
   # Rejected members can be invited again
   @spec check_member_not_existant_or_rejected(String.t() | integer, String.t() | integer()) ::
           boolean()
diff --git a/lib/graphql/schema/actor.ex b/lib/graphql/schema/actor.ex
index c2fd097bb..cc28cbe2a 100644
--- a/lib/graphql/schema/actor.ex
+++ b/lib/graphql/schema/actor.ex
@@ -32,8 +32,8 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
     field(:banner, :media, description: "The actor's banner media")
 
     # These one should have a privacy setting
-    field(:followersCount, :integer, description: "Number of followers for this actor")
-    field(:followingCount, :integer, description: "Number of actors following this actor")
+    field(:followers_count, :integer, description: "Number of followers for this actor")
+    field(:following_count, :integer, description: "Number of actors following this actor")
 
     field(:media_size, :integer, description: "The total size of the media from this actor")
 
diff --git a/lib/graphql/schema/actors/application.ex b/lib/graphql/schema/actors/application.ex
index 4a8d98bae..87774005a 100644
--- a/lib/graphql/schema/actors/application.ex
+++ b/lib/graphql/schema/actors/application.ex
@@ -31,8 +31,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
     field(:banner, :media, description: "The actor's banner media")
 
     # These one should have a privacy setting
-    field(:followersCount, :integer, description: "Number of followers for this actor")
-    field(:followingCount, :integer, description: "Number of actors following this actor")
+    field(:followers_count, :integer, description: "Number of followers for this actor")
+    field(:following_count, :integer, description: "Number of actors following this actor")
 
     field(:media_size, :integer,
       resolve: &Media.actor_size/3,
diff --git a/lib/graphql/schema/actors/group.ex b/lib/graphql/schema/actors/group.ex
index 1129de042..2f2ca7e1c 100644
--- a/lib/graphql/schema/actors/group.ex
+++ b/lib/graphql/schema/actors/group.ex
@@ -29,7 +29,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
   Represents a group of actors
   """
   object :group do
-    interfaces([:actor, :interactable, :activity_object, :action_log_object])
+    interfaces([:actor, :interactable, :activity_object, :action_log_object, :group_search_result])
 
     field(:id, :id, description: "Internal ID for this group")
     field(:url, :string, description: "The ActivityPub actor's URL")
@@ -59,8 +59,17 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
     )
 
     # These one should have a privacy setting
-    field(:followersCount, :integer, description: "Number of followers for this actor")
-    field(:followingCount, :integer, description: "Number of actors following this actor")
+    field(:followers_count, :integer,
+      description: "Number of followers for this actor",
+      resolve: &Followers.count_followers_for_group/3
+    )
+
+    field(:following_count, :integer, description: "Number of follows for this actor")
+
+    field(:members_count, :integer,
+      description: "Number of members for this actor",
+      resolve: &Member.count_members_for_group/3
+    )
 
     field(:media_size, :integer,
       resolve: &Media.actor_size/3,
diff --git a/lib/graphql/schema/actors/person.ex b/lib/graphql/schema/actors/person.ex
index a7d773c71..eda059000 100644
--- a/lib/graphql/schema/actors/person.ex
+++ b/lib/graphql/schema/actors/person.ex
@@ -43,9 +43,16 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
     field(:avatar, :media, description: "The actor's avatar media")
     field(:banner, :media, description: "The actor's banner media")
 
-    # These one should have a privacy setting
-    field(:followersCount, :integer, description: "Number of followers for this actor")
-    field(:followingCount, :integer, description: "Number of actors following this actor")
+    # Persons have zero followers/followings
+    field(:followers_count, :integer,
+      description: "Number of followers for this actor",
+      resolve: fn _, _, _ -> {:ok, 0} end
+    )
+
+    field(:following_count, :integer,
+      description: "Number of actors following this actor",
+      resolve: fn _, _, _ -> {:ok, 0} end
+    )
 
     field(:media_size, :integer,
       resolve: &Media.actor_size/3,
diff --git a/lib/graphql/schema/config.ex b/lib/graphql/schema/config.ex
index c20ee1e01..27561783c 100644
--- a/lib/graphql/schema/config.ex
+++ b/lib/graphql/schema/config.ex
@@ -79,6 +79,8 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
     field(:analytics, list_of(:analytics),
       description: "Configuration for diverse analytics services"
     )
+
+    field(:search, :search_settings, description: "The instance's search settings")
   end
 
   @desc """
@@ -354,6 +356,15 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
     field(:type, :analytics_configuration_type, description: "The analytics configuration type")
   end
 
+  object :search_settings do
+    field(:global, :global_search_settings, description: "The instance's global search settings")
+  end
+
+  object :global_search_settings do
+    field(:is_enabled, :boolean, description: "Whether global search is enabled")
+    field(:is_default, :boolean, description: "Whether global search is the default")
+  end
+
   @desc """
   Export formats configuration
   """
diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex
index 9a4dd9ea3..febfffd9c 100644
--- a/lib/graphql/schema/event.ex
+++ b/lib/graphql/schema/event.ex
@@ -17,7 +17,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
 
   @desc "An event"
   object :event do
-    interfaces([:action_log_object, :interactable, :activity_object])
+    interfaces([:action_log_object, :interactable, :activity_object, :event_search_result])
     field(:id, :id, description: "Internal ID for this event")
     field(:uuid, :uuid, description: "The Event UUID")
     field(:url, :string, description: "The ActivityPub Event URL")
diff --git a/lib/graphql/schema/search.ex b/lib/graphql/schema/search.ex
index 90d043c9a..c0bbecf4a 100644
--- a/lib/graphql/schema/search.ex
+++ b/lib/graphql/schema/search.ex
@@ -7,6 +7,97 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
   alias Mobilizon.Actors.Actor
   alias Mobilizon.Events.Event
   alias Mobilizon.GraphQL.Resolvers.Search
+  alias Mobilizon.Service.GlobalSearch.{EventResult, GroupResult}
+
+  interface :event_search_result do
+    field(:id, :id, description: "Internal ID for this event")
+    field(:uuid, :uuid, description: "The Event UUID")
+    field(:url, :string, description: "The ActivityPub Event URL")
+    field(:title, :string, description: "The event's title")
+    field(:begins_on, :datetime, description: "Datetime for when the event begins")
+    field(:ends_on, :datetime, description: "Datetime for when the event ends")
+    field(:status, :event_status, description: "Status of the event")
+    field(:picture, :media, description: "The event's picture")
+    field(:physical_address, :address, description: "The event's physical address")
+    field(:attributed_to, :actor, description: "Who the event is attributed to (often a group)")
+    field(:organizer_actor, :actor, description: "The event's organizer (as a person)")
+    field(:tags, list_of(:tag), description: "The event's tags")
+    field(:category, :event_category, description: "The event's category")
+    field(:options, :event_options, description: "The event options")
+
+    resolve_type(fn
+      %Event{}, _ ->
+        :event
+
+      %EventResult{}, _ ->
+        :event_result
+
+      _, _ ->
+        nil
+    end)
+  end
+
+  @desc "Search event result"
+  object :event_result do
+    interfaces([:event_search_result])
+    field(:id, :id, description: "Internal ID for this event")
+    field(:uuid, :uuid, description: "The Event UUID")
+    field(:url, :string, description: "The ActivityPub Event URL")
+    field(:title, :string, description: "The event's title")
+    field(:begins_on, :datetime, description: "Datetime for when the event begins")
+    field(:ends_on, :datetime, description: "Datetime for when the event ends")
+    field(:status, :event_status, description: "Status of the event")
+    field(:picture, :media, description: "The event's picture")
+    field(:physical_address, :address, description: "The event's physical address")
+    field(:attributed_to, :actor, description: "Who the event is attributed to (often a group)")
+    field(:organizer_actor, :actor, description: "The event's organizer (as a person)")
+    field(:tags, list_of(:tag), description: "The event's tags")
+    field(:category, :event_category, description: "The event's category")
+    field(:options, :event_options, description: "The event options")
+  end
+
+  interface :group_search_result do
+    field(:id, :id, description: "Internal ID for this group")
+    field(:url, :string, description: "The ActivityPub actor's URL")
+    field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
+    field(:name, :string, description: "The actor's displayed name")
+    field(:domain, :string, description: "The actor's domain if (null if it's this instance)")
+    field(:summary, :string, description: "The actor's summary")
+    field(:preferred_username, :string, description: "The actor's preferred username")
+    field(:avatar, :media, description: "The actor's avatar media")
+    field(:banner, :media, description: "The actor's banner media")
+    field(:followers_count, :integer, description: "Number of followers for this actor")
+    field(:members_count, :integer, description: "Number of followers for this actor")
+    field(:physical_address, :address, description: "The type of the event's address")
+
+    resolve_type(fn
+      %Actor{type: :Group}, _ ->
+        :group
+
+      %GroupResult{}, _ ->
+        :group_result
+
+      _, _ ->
+        nil
+    end)
+  end
+
+  @desc "Search group result"
+  object :group_result do
+    interfaces([:group_search_result])
+    field(:id, :id, description: "Internal ID for this group")
+    field(:url, :string, description: "The ActivityPub actor's URL")
+    field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
+    field(:name, :string, description: "The actor's displayed name")
+    field(:domain, :string, description: "The actor's domain if (null if it's this instance)")
+    field(:summary, :string, description: "The actor's summary")
+    field(:preferred_username, :string, description: "The actor's preferred username")
+    field(:avatar, :media, description: "The actor's avatar media")
+    field(:banner, :media, description: "The actor's banner media")
+    field(:followers_count, :integer, description: "Number of followers for this actor")
+    field(:members_count, :integer, description: "Number of followers for this actor")
+    field(:physical_address, :address, description: "The type of the event's address")
+  end
 
   @desc "Search persons result"
   object :persons do
@@ -17,13 +108,13 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
   @desc "Search groups result"
   object :groups do
     field(:total, non_null(:integer), description: "Total elements")
-    field(:elements, non_null(list_of(:group)), description: "Group elements")
+    field(:elements, non_null(list_of(:group_search_result)), description: "Group elements")
   end
 
   @desc "Search events result"
   object :events do
     field(:total, non_null(:integer), description: "Total elements")
-    field(:elements, non_null(list_of(:event)), description: "Event elements")
+    field(:elements, non_null(list_of(:event_search_result)), description: "Event elements")
   end
 
   @desc """
@@ -53,6 +144,14 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
     value(:online, description: "The event will only happen online. It has no physical address")
   end
 
+  enum :search_target do
+    value(:internal,
+      description: "Search on content from this instance and from the followed instances"
+    )
+
+    value(:global, description: "Search using the global fediverse search")
+  end
+
   object :search_queries do
     @desc "Search persons"
     field :search_persons, :persons do
@@ -81,6 +180,15 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
         description: "Radius around the location to search in"
       )
 
+      arg(:language_one_of, list_of(:string),
+        description: "The list of languages this event can be in"
+      )
+
+      arg(:search_target, :search_target,
+        default_value: :internal,
+        description: "The target of the search (internal or global)"
+      )
+
       arg(:page, :integer, default_value: 1, description: "Result page")
       arg(:limit, :integer, default_value: 10, description: "Results limit per page")
 
@@ -103,6 +211,15 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
         description: "The list of statuses this event can have"
       )
 
+      arg(:language_one_of, list_of(:string),
+        description: "The list of languages this event can be in"
+      )
+
+      arg(:search_target, :search_target,
+        default_value: :internal,
+        description: "The target of the search (internal or global)"
+      )
+
       arg(:radius, :float,
         default_value: 50,
         description: "Radius around the location to search in"
diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex
index 9bd80fd33..e2910ca63 100644
--- a/lib/mobilizon/actors/actors.ex
+++ b/lib/mobilizon/actors/actors.ex
@@ -923,10 +923,10 @@ defmodule Mobilizon.Actors do
   Returns the number of members for a group
   """
   @spec count_members_for_group(Actor.t()) :: integer()
-  def count_members_for_group(%Actor{id: actor_id}) do
+  def count_members_for_group(%Actor{id: actor_id}, roles \\ @member_roles) do
     actor_id
     |> members_for_group_query()
-    # |> where([m], m.role in @member_roles)
+    |> where([m], m.role in ^roles)
     |> Repo.aggregate(:count)
   end
 
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 10babc16b..7ab4f0bc1 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -531,6 +531,7 @@ defmodule Mobilizon.Events do
     |> events_for_ends_on(args)
     |> events_for_category(args)
     |> events_for_categories(args)
+    |> events_for_languages(args)
     |> events_for_statuses(args)
     |> events_for_tags(args)
     |> events_for_location(args)
@@ -1323,13 +1324,22 @@ defmodule Mobilizon.Events do
   defp events_for_category(query, _args), do: query
 
   @spec events_for_categories(Ecto.Queryable.t(), map()) :: Ecto.Query.t()
-  defp events_for_categories(query, %{category_one_of: category_one_of}) when length(category_one_of) > 0 do
+  defp events_for_categories(query, %{category_one_of: category_one_of})
+       when length(category_one_of) > 0 do
     where(query, [q], q.category in ^category_one_of)
   end
 
   defp events_for_categories(query, _args), do: query
 
-  defp events_for_statuses(query, %{status_one_of: status_one_of}) when length(status_one_of) > 0 do
+  defp events_for_languages(query, %{language_one_of: language_one_of})
+       when length(language_one_of) > 0 do
+    where(query, [q], q.language in ^language_one_of)
+  end
+
+  defp events_for_languages(query, _args), do: query
+
+  defp events_for_statuses(query, %{status_one_of: status_one_of})
+       when length(status_one_of) > 0 do
     where(query, [q], q.status in ^status_one_of)
   end
 
@@ -1622,6 +1632,7 @@ defmodule Mobilizon.Events do
 
   def category_statistics do
     Event
+    |> filter_local_or_from_followed_instances_events()
     |> group_by([e], e.category)
     |> select([e], {e.category, count(e.id)})
     |> Repo.all()
diff --git a/lib/service/global_search/event_result.ex b/lib/service/global_search/event_result.ex
new file mode 100644
index 000000000..2d37c9378
--- /dev/null
+++ b/lib/service/global_search/event_result.ex
@@ -0,0 +1,18 @@
+defmodule Mobilizon.Service.GlobalSearch.EventResult do
+  @moduledoc """
+  The structure holding search result information about an event
+  """
+  defstruct [
+    :id,
+    :uuid,
+    :url,
+    :title,
+    :begins_on,
+    :ends_on,
+    :picture,
+    :category,
+    :tags,
+    :organizer_actor,
+    :participants
+  ]
+end
diff --git a/lib/service/global_search/global_search.ex b/lib/service/global_search/global_search.ex
new file mode 100644
index 000000000..50f0d10c4
--- /dev/null
+++ b/lib/service/global_search/global_search.ex
@@ -0,0 +1,17 @@
+defmodule Mobilizon.Service.GlobalSearch do
+  @moduledoc """
+  Module to load the service adapter defined inside the configuration.
+
+  See `Mobilizon.Service.GlobalSearch.Provider`.
+  """
+
+  @doc """
+  Returns the appropriate service adapter.
+
+  According to the config behind
+    `config :mobilizon, Mobilizon.Service.GlobalSearch,
+       service: Mobilizon.Service.GlobalSearch.Module`
+  """
+  @spec service :: module
+  def service, do: get_in(Application.get_env(:mobilizon, __MODULE__), [:service])
+end
diff --git a/lib/service/global_search/group_result.ex b/lib/service/global_search/group_result.ex
new file mode 100644
index 000000000..535abe93c
--- /dev/null
+++ b/lib/service/global_search/group_result.ex
@@ -0,0 +1,19 @@
+defmodule Mobilizon.Service.GlobalSearch.GroupResult do
+  @moduledoc """
+  The structure holding search result information about a group
+  """
+  defstruct [
+    :id,
+    :url,
+    :name,
+    :preferred_username,
+    :domain,
+    :avatar,
+    :summary,
+    :url,
+    :members_count,
+    :follower_count,
+    :type,
+    :physical_address
+  ]
+end
diff --git a/lib/service/global_search/provider.ex b/lib/service/global_search/provider.ex
new file mode 100644
index 000000000..f936e0680
--- /dev/null
+++ b/lib/service/global_search/provider.ex
@@ -0,0 +1,40 @@
+defmodule Mobilizon.Service.GlobalSearch.Provider do
+  @moduledoc """
+  Provider Behaviour for Global Search.
+
+  ## Supported backends
+
+    * `Mobilizon.Service.GlobalSearch.Mobilizon` [🔗](https://framagit.org/framasoft/joinmobilizon/search-index/)
+
+  ## Shared options
+
+    * `:lang` Lang in which to prefer results. Used as a request parameter or
+      through an `Accept-Language` HTTP header. Defaults to `"en"`.
+    * `:country_code` An ISO 3166 country code. String or `nil`
+    * `:limit` Maximum limit for the number of results returned by the backend.
+      Defaults to `10`
+    * `:api_key` Allows to override the API key (if the backend requires one) set
+      inside the configuration.
+    * `:endpoint` Allows to override the endpoint set inside the configuration.
+  """
+
+  alias Mobilizon.Service.GlobalSearch.{EventResult, GroupResult}
+
+  @doc """
+  Get global search results for a search string
+
+  ## Examples
+
+      iex> search_events(search: "London")
+      [%EventResult{id: "provider-534", origin_url: "https://somewhere.else", title: "MyEvent", begins_on: "2022-08-25T08:13:47+0200", ends_on: "2022-08-25T10:13:47+0200", category: "FILM_MEDIA", tags: ["something", "what"], participants: 5}]
+  """
+  @callback search_events(search_options :: keyword) ::
+              Page.t(EventResult.t())
+  @callback search_groups(search_options :: keyword) ::
+              Page.t(GroupResult.t())
+
+  @spec endpoint(atom()) :: String.t()
+  def endpoint(provider) do
+    Application.get_env(:mobilizon, provider) |> get_in([:endpoint])
+  end
+end
diff --git a/lib/service/global_search/search_mobilizon.ex b/lib/service/global_search/search_mobilizon.ex
new file mode 100644
index 000000000..435b5b83c
--- /dev/null
+++ b/lib/service/global_search/search_mobilizon.ex
@@ -0,0 +1,225 @@
+defmodule Mobilizon.Service.GlobalSearch.SearchMobilizon do
+  @moduledoc """
+  [Search Mobilizon](https://search.joinmobilizon.org) backend.
+  """
+
+  alias Mobilizon.Actors.Actor
+  alias Mobilizon.Addresses.Address
+  alias Mobilizon.Events.Tag
+  alias Mobilizon.Service.GlobalSearch.{EventResult, GroupResult, Provider}
+  alias Mobilizon.Service.HTTP.GenericJSONClient
+  alias Mobilizon.Storage.Page
+  require Logger
+  import Plug.Conn.Query, only: [encode: 1]
+
+  @search_events_api "/api/v1/search/events"
+  @search_groups_api "/api/v1/search/groups"
+
+  @behaviour Provider
+
+  @impl Provider
+  @doc """
+  Mobilizon Search implementation for `c:Mobilizon.Service.GlobalSearch.Provider.search_events/3`.
+  """
+  @spec search_events(keyword()) :: Page.t(EventResult.t())
+  def search_events(options \\ []) do
+    Logger.debug("Search events options, #{inspect(Keyword.keys(options))}")
+
+    options =
+      options
+      |> Keyword.merge(
+        term: options[:search],
+        startDateMin: to_date(options[:begins_on]),
+        startDateMax: to_date(options[:ends_on]),
+        categoryOneOf: options[:category_one_of],
+        languageOneOf: options[:language_one_of],
+        statusOneOf:
+          Enum.map(options[:status_one_of] || [], fn status ->
+            status |> Atom.to_string() |> String.upcase()
+          end),
+        distance: if(options[:radius], do: "#{options[:radius]}_km", else: nil),
+        count: options[:limit],
+        start: (options[:page] - 1) * options[:limit],
+        latlon: to_lat_lon(options[:location])
+      )
+      |> Keyword.take([
+        :search,
+        :startDateMin,
+        :startDateMax,
+        :boostLanguages,
+        :categoryOneOf,
+        :languageOneOf,
+        :latlon,
+        :distance,
+        :sort,
+        :statusOneOf,
+        :start,
+        :count
+      ])
+      |> Keyword.reject(fn {_key, val} -> is_nil(val) end)
+
+    events_url = "#{search_endpoint()}#{@search_events_api}?#{encode(options)}"
+    Logger.debug("Calling global search engine url #{events_url}")
+
+    client = GenericJSONClient.client()
+
+    case GenericJSONClient.get(client, events_url) do
+      {:ok, %{status: 200, body: body}} ->
+        %Page{total: body["total"], elements: Enum.map(body["data"], &build_event/1)}
+
+      _ ->
+        nil
+    end
+  end
+
+  @impl Provider
+  @doc """
+  Mobilizon Search implementation for `c:Mobilizon.Service.GlobalSearch.Provider.search_groups/3`.
+  """
+  @spec search_groups(keyword()) :: Page.t(GroupResult.t())
+  def search_groups(options \\ []) do
+    options =
+      options
+      |> Keyword.merge(
+        term: options[:search],
+        languageOneOf: options[:language_one_of],
+        distance: if(options[:radius], do: "#{options[:radius]}_km", else: nil),
+        count: options[:limit],
+        start: (options[:page] - 1) * options[:limit],
+        latlon: to_lat_lon(options[:location])
+      )
+      |> Keyword.take([
+        :search,
+        :boostLanguages,
+        :latlon,
+        :distance,
+        :sort,
+        :start,
+        :count
+      ])
+      |> Keyword.reject(fn {_key, val} -> is_nil(val) end)
+
+    groups_url = "#{search_endpoint()}#{@search_groups_api}?#{encode(options)}"
+    Logger.debug("Calling global search engine url #{groups_url}")
+
+    client = GenericJSONClient.client()
+
+    case GenericJSONClient.get(client, groups_url) do
+      {:ok, %{status: 200, body: body}} ->
+        %Page{total: body["total"], elements: Enum.map(body["data"], &build_group/1)}
+
+      _ ->
+        nil
+    end
+  end
+
+  defp build_event(data) do
+    picture =
+      if data["banner"] do
+        %{url: data["banner"], id: data["banner"]}
+      else
+        nil
+      end
+
+    organizer_actor_avatar =
+      if data["creator"]["avatar"] do
+        %{url: data["creator"]["avatar"], id: data["creator"]["avatar"]}
+      else
+        nil
+      end
+
+    %EventResult{
+      id: data["id"],
+      uuid: data["uuid"],
+      title: data["name"],
+      begins_on: parse_date(data["startTime"]),
+      ends_on: parse_date(data["endTime"]),
+      url: data["url"],
+      picture: picture,
+      category: String.to_existing_atom(String.downcase(data["category"])),
+      organizer_actor: %Actor{
+        id: data["creator"]["id"],
+        name: data["creator"]["displayName"],
+        preferred_username: data["creator"]["name"],
+        avatar: organizer_actor_avatar
+      },
+      tags:
+        Enum.map(data["tags"], fn tag ->
+          tag = String.trim_leading(tag, "#")
+          %Tag{id: tag, slug: tag, title: tag}
+        end)
+    }
+  end
+
+  defp build_group(data) do
+    avatar =
+      if data["avatar"] do
+        %{url: data["avatar"], id: data["avatar"]}
+      else
+        nil
+      end
+
+    address =
+      if data["location"] do
+        %Address{
+          id: data["location"]["id"],
+          country: data["location"]["address"]["addressCountry"],
+          locality: data["location"]["address"]["addressLocality"],
+          region: data["location"]["address"]["addressRegion"],
+          postal_code: data["location"]["address"]["postalCode"],
+          street: data["location"]["address"]["streetAddress"],
+          url: data["location"]["id"],
+          description: data["location"]["name"],
+          geom: %Geo.Point{
+            coordinates:
+              {data["location"]["location"]["lon"], data["location"]["location"]["lat"]},
+            srid: 4326
+          }
+        }
+      else
+        nil
+      end
+
+    %GroupResult{
+      id: data["id"],
+      name: data["displayName"],
+      preferred_username: data["name"],
+      domain: data["host"],
+      avatar: avatar,
+      summary: data["description"],
+      url: data["url"],
+      members_count: data["memberCount"],
+      type: :Group,
+      physical_address: address
+    }
+  end
+
+  defp search_endpoint do
+    Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint]) ||
+      "https://search.joinmobilizon.org"
+  end
+
+  defp parse_date(nil), do: nil
+
+  defp parse_date(date_string) do
+    case DateTime.from_iso8601(date_string) do
+      {:ok, date, _} -> date
+      {:error, _} -> nil
+    end
+  end
+
+  defp to_date(nil), do: nil
+  defp to_date(date), do: DateTime.to_iso8601(date)
+
+  defp to_lat_lon(nil), do: nil
+
+  defp to_lat_lon(location) do
+    case Geohax.decode(location) do
+      {lon, lat} ->
+        "#{lat}:#{lon}"
+
+      _ ->
+        nil
+    end
+  end
+end
diff --git a/lib/service/pictures/information.ex b/lib/service/pictures/information.ex
index a48c2a2f3..40047cf16 100644
--- a/lib/service/pictures/information.ex
+++ b/lib/service/pictures/information.ex
@@ -1,3 +1,6 @@
 defmodule Mobilizon.Service.Pictures.Information do
+  @moduledoc """
+  The structure holding information about a picture
+  """
   defstruct [:url, :author, :source]
 end
diff --git a/lib/service/pictures/unsplash.ex b/lib/service/pictures/unsplash.ex
index 98eb9d68a..2a6b2f1b4 100644
--- a/lib/service/pictures/unsplash.ex
+++ b/lib/service/pictures/unsplash.ex
@@ -3,8 +3,8 @@ defmodule Mobilizon.Service.Pictures.Unsplash do
   [Unsplash](https://unsplash.com) backend.
   """
 
-  alias Mobilizon.Service.Pictures.{Information, Provider}
   alias Mobilizon.Service.HTTP.GenericJSONClient
+  alias Mobilizon.Service.Pictures.{Information, Provider}
   require Logger
 
   @unsplash_api "/search/photos"
@@ -24,12 +24,12 @@ defmodule Mobilizon.Service.Pictures.Unsplash do
       GenericJSONClient.client(headers: [{:Authorization, "Client-ID #{unsplash_access_key()}"}])
 
     with {:ok, %{status: 200, body: body}} <- GenericJSONClient.get(client, url),
-         selectedPicture <- Enum.random(body["results"]) do
+         selected_picture <- Enum.random(body["results"]) do
       %Information{
-        url: selectedPicture["urls"]["small"],
+        url: selected_picture["urls"]["small"],
         author: %{
-          name: selectedPicture["user"]["name"],
-          url: "#{selectedPicture["user"]["links"]["html"]}#{unsplash_utm_source()}"
+          name: selected_picture["user"]["name"],
+          url: "#{selected_picture["user"]["links"]["html"]}#{unsplash_utm_source()}"
         },
         source: %{
           name: @unsplash_name,
diff --git a/lib/web/templates/page/index.html.heex b/lib/web/templates/page/index.html.heex
index 2e270b8de..1a66c9c90 100644
--- a/lib/web/templates/page/index.html.heex
+++ b/lib/web/templates/page/index.html.heex
@@ -4,9 +4,9 @@
     <meta charset="utf-8" />
     <meta http-equiv="X-UA-Compatible" content="IE=edge" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png" sizes="152x152">
-    <link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()}>
-    <meta name="theme-color" content={theme_color()}>
+    <link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png" sizes="152x152" />
+    <link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()} />
+    <meta name="theme-color" content={theme_color()} />
     <%= tags(assigns) || assigns.tags %>
     <%= Vite.inlined_phx_manifest() %>
     <%= Vite.vite_client() %>