Migrate to Vue 3 and Vite

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2022-07-12 10:55:28 +02:00
parent 8f4099ee33
commit ee20e03cc2
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
464 changed files with 31515 additions and 32758 deletions

View file

@ -1,2 +1,2 @@
elixir 1.13.4-otp-24 elixir 1.13.4-otp-25
erlang 24.3.3 erlang 25.0.3

View file

@ -54,7 +54,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
secret_key_base: "1yOazsoE0Wqu4kXk3uC5gu3jDbShOimTCzyFL3OjCdBmOXMyHX87Qmf3+Tu9s0iM", secret_key_base: "1yOazsoE0Wqu4kXk3uC5gu3jDbShOimTCzyFL3OjCdBmOXMyHX87Qmf3+Tu9s0iM",
render_errors: [view: Mobilizon.Web.ErrorView, accepts: ~w(html json)], render_errors: [view: Mobilizon.Web.ErrorView, accepts: ~w(html json)],
pubsub_server: Mobilizon.PubSub, pubsub_server: Mobilizon.PubSub,
cache_static_manifest: "priv/static/manifest.json", cache_static_manifest: "priv/static/cache_manifest.json",
has_reverse_proxy: true has_reverse_proxy: true
config :mime, :types, %{ config :mime, :types, %{
@ -123,6 +123,18 @@ config :mobilizon, Mobilizon.Web.Email.Mailer,
# can be `true` # can be `true`
no_mx_lookups: false no_mx_lookups: false
config :vite_phx,
release_app: :mobilizon,
# to tell prod and dev env appart
environment: config_env(),
# this manifest is different from the Phoenix "cache_manifest.json"!
# optional
vite_manifest: "priv/static/manifest.json",
# optional
phx_manifest: "priv/static/cache_manifest.json",
# optional
dev_server_address: "http://localhost:3000"
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,
backends: [:console], backends: [:console],
@ -347,6 +359,12 @@ config :mobilizon, :exports,
config :mobilizon, :analytics, providers: [] config :mobilizon, :analytics, providers: []
config :mobilizon, Mobilizon.Service.Pictures, service: Mobilizon.Service.Pictures.Unsplash
config :mobilizon, Mobilizon.Service.Pictures.Unsplash,
app_name: "Mobilizon",
access_key: nil
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View file

@ -15,13 +15,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
check_origin: false, check_origin: false,
watchers: [ watchers: [
node: [ node: [
"node_modules/webpack/bin/webpack.js", "node_modules/.bin/vite",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
"--config",
"node_modules/@vue/cli-service/webpack.config.js",
cd: Path.expand("../js", __DIR__) cd: Path.expand("../js", __DIR__)
] ]
] ]

View file

@ -1,3 +1,6 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = { module.exports = {
root: true, root: true,
@ -6,10 +9,11 @@ module.exports = {
}, },
extends: [ extends: [
"plugin:vue/essential",
"eslint:recommended", "eslint:recommended",
"@vue/typescript/recommended", "plugin:vue/vue3-essential",
"@vue/eslint-config-typescript",
"plugin:prettier/recommended", "plugin:prettier/recommended",
"@vue/eslint-config-prettier",
], ],
plugins: ["prettier"], plugins: ["prettier"],

1
js/.gitignore vendored
View file

@ -5,6 +5,7 @@ node_modules
/tests/e2e/videos/ /tests/e2e/videos/
/tests/e2e/screenshots/ /tests/e2e/screenshots/
/coverage /coverage
stats.html
# local env files # local env files
.env.local .env.local

View file

@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

1
js/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="histoire/vue" />

51
js/histoire.config.ts Normal file
View file

@ -0,0 +1,51 @@
/// <reference types="@histoire/plugin-vue/components" />
import { defineConfig } from "histoire";
import { HstVue } from "@histoire/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [HstVue()],
setupFile: path.resolve(__dirname, "./src/histoire.setup.ts"),
viteNodeInlineDeps: [/date-fns/],
tree: {
groups: [
{
title: "Actors",
include: (file) => /^src\/components\/Account/.test(file.path),
},
{
title: "Address",
include: (file) => /^src\/components\/Address/.test(file.path),
},
{
title: "Comments",
include: (file) => /^src\/components\/Comment/.test(file.path),
},
{
title: "Discussion",
include: (file) => /^src\/components\/Discussion/.test(file.path),
},
{
title: "Events",
include: (file) => /^src\/components\/Event/.test(file.path),
},
{
title: "Groups",
include: (file) => /^src\/components\/Group/.test(file.path),
},
{
title: "Home",
include: (file) => /^src\/components\/Home/.test(file.path),
},
{
title: "Posts",
include: (file) => /^src\/components\/Post/.test(file.path),
},
{
title: "Others",
include: () => true,
},
],
},
});

View file

@ -3,19 +3,25 @@
"version": "2.1.0", "version": "2.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "dev": "vite",
"preview": "vite preview",
"build": "yarn run build:assets && yarn run build:pictures", "build": "yarn run build:assets && yarn run build:pictures",
"test:unit": "LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 TZ=UTC vue-cli-service test:unit", "lint": "eslint --ext .ts,.vue --ignore-path .gitignore --fix src",
"test:e2e": "vue-cli-service test:e2e", "format": "prettier . --write",
"lint": "vue-cli-service lint", "build:assets": "vite build",
"build:assets": "vue-cli-service build --report", "build:pictures": "bash ./scripts/build/pictures.sh",
"build:pictures": "bash ./scripts/build/pictures.sh" "story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview",
"test": "vitest",
"coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@absinthe/socket": "^0.2.1", "@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1", "@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16", "@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95", "@headlessui/vue": "^1.6.7",
"@oruga-ui/oruga-next": "^0.5.5",
"@sentry/tracing": "^7.1", "@sentry/tracing": "^7.1",
"@sentry/vue": "^7.1", "@sentry/vue": "^7.1",
"@tailwindcss/line-clamp": "^0.4.0", "@tailwindcss/line-clamp": "^0.4.0",
@ -39,19 +45,24 @@
"@tiptap/extension-strike": "^2.0.0-beta.26", "@tiptap/extension-strike": "^2.0.0-beta.26",
"@tiptap/extension-text": "^2.0.0-beta.15", "@tiptap/extension-text": "^2.0.0-beta.15",
"@tiptap/extension-underline": "^2.0.0-beta.7", "@tiptap/extension-underline": "^2.0.0-beta.7",
"@tiptap/vue-2": "^2.0.0-beta.21", "@tiptap/vue-3": "^2.0.0-beta.96",
"@vue-a11y/announcer": "^2.1.0", "@vue-a11y/announcer": "^2.1.0",
"@vue-a11y/skip-to": "^2.1.2", "@vue-a11y/skip-to": "^2.1.2",
"@vue/apollo-option": "4.0.0-alpha.11", "@vue-leaflet/vue-leaflet": "^0.6.1",
"@vue/apollo-composable": "^4.0.0-alpha.17",
"@vue/compiler-sfc": "^3.2.37",
"@vueuse/head": "^0.7.9",
"@vueuse/router": "^9.0.2",
"@xiaoshuapp/draggable": "^4.1.0",
"apollo-absinthe-upload-link": "^1.5.0", "apollo-absinthe-upload-link": "^1.5.0",
"autoprefixer": "^10", "autoprefixer": "^10",
"blurhash": "^1.1.3", "blurhash": "^1.1.3",
"buefy": "^0.9.0", "bulma": "^0.9.4",
"bulma-divider": "^0.2.0", "bulma-divider": "^0.2.0",
"core-js": "^3.6.4",
"date-fns": "^2.16.0", "date-fns": "^2.16.0",
"date-fns-tz": "^1.1.6", "date-fns-tz": "^1.1.6",
"graphql": "^16.0.0", "floating-vue": "^2.0.0-beta.17",
"graphql": "^15.8.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"intersection-observer": "^0.12.0", "intersection-observer": "^0.12.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
@ -67,22 +78,24 @@
"tailwindcss": "^3", "tailwindcss": "^3",
"tippy.js": "^6.2.3", "tippy.js": "^6.2.3",
"unfetch": "^4.2.0", "unfetch": "^4.2.0",
"v-tooltip": "^2.1.3", "vue": "^3.2.37",
"vue": "^2.6.11", "vue-class-component": "8.0.0-rc.1",
"vue-class-component": "^7.2.3", "vue-i18n": "9",
"vue-i18n": "^8.14.0", "vue-material-design-icons": "^5.1.2",
"vue-matomo": "^4.1.0", "vue-matomo": "^4.1.0",
"vue-meta": "^2.3.1", "vue-meta": "^2.3.1",
"vue-plausible": "^1.3.1", "vue-plausible": "^1.3.1",
"vue-property-decorator": "^9.0.0", "vue-property-decorator": "10.0.0-rc.3",
"vue-router": "^3.1.6", "vue-router": "4",
"vue-scrollto": "^2.17.1", "vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3", "vue-use-route-query": "^1.1.0"
"vuedraggable": "^2.24.3"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.1.0", "@histoire/plugin-vue": "^0.9.0",
"@types/jest": "^28.0.0", "@intlify/vite-plugin-vue-i18n": "^6.0.0",
"@rushstack/eslint-patch": "^1.1.4",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.4",
"@types/leaflet": "^1.5.2", "@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.74", "@types/leaflet.locatecontrol": "^0.74",
"@types/lodash": "^4.14.141", "@types/lodash": "^4.14.141",
@ -93,37 +106,28 @@
"@types/prosemirror-state": "^1.2.4", "@types/prosemirror-state": "^1.2.4",
"@types/prosemirror-view": "^1.11.4", "@types/prosemirror-view": "^1.11.4",
"@types/sanitize-html": "^2.5.0", "@types/sanitize-html": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^5.3.0", "@vitejs/plugin-vue": "^2.3.2",
"@typescript-eslint/parser": "^5.3.0", "@vitest/ui": "^0.21.1",
"@vue/cli-plugin-babel": "~5.0.6", "@vue/eslint-config-prettier": "^7.0.0",
"@vue/cli-plugin-eslint": "~5.0.6",
"@vue/cli-plugin-pwa": "~5.0.6",
"@vue/cli-plugin-router": "~5.0.6",
"@vue/cli-plugin-typescript": "~5.0.6",
"@vue/cli-plugin-unit-jest": "~5.0.6",
"@vue/cli-service": "~5.0.6",
"@vue/eslint-config-typescript": "^11.0.0", "@vue/eslint-config-typescript": "^11.0.0",
"@vue/test-utils": "^1.1.0", "@vue/test-utils": "^2.0.2",
"@vue/vue2-jest": "^28.0.0", "eslint": "^8.21.0",
"babel-jest": "^28.1.1",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^9.1.1", "eslint-plugin-vue": "^9.3.0",
"flush-promises": "^1.0.2", "flush-promises": "^1.0.2",
"jest": "^28.1.1", "histoire": "^0.9.0",
"jest-junit": "^13.0.0", "jsdom": "^20.0.0",
"mock-apollo-client": "^1.1.0", "mock-apollo-client": "^1.1.0",
"prettier": "^2.2.1", "prettier": "^2.2.1",
"prettier-eslint": "^15.0.1", "prettier-eslint": "^15.0.1",
"rollup-plugin-visualizer": "^5.7.1",
"sass": "^1.34.1", "sass": "^1.34.1",
"sass-loader": "^13.0.0", "typescript": "~4.7.4",
"ts-jest": "28", "vite": "^2.9.0",
"typescript": "~4.5.5", "vite-plugin-pwa": "^0.12.3",
"vue-cli-plugin-tailwind": "~3.0.0", "vitest": "^0.21.0",
"vue-i18n-extract": "^2.0.4", "vue-i18n-extract": "^2.0.4"
"vue-template-compiler": "^2.6.11",
"webpack-cli": "^4.7.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

10
js/public/img/shape-1.svg Normal file
View file

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(255, 231.287, 78.545, 0.3)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(254.848, 165.324, 149.009, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M18.2,-13.5C23.5,-7.8,27.8,-0.2,27.7,8.7C27.5,17.6,22.9,27.8,14.1,33.9C5.3,39.9,-7.8,41.6,-17.7,36.8C-27.6,32,-34.2,20.7,-37.1,8.4C-39.9,-3.9,-39,-17.2,-32.2,-23.2C-25.4,-29.3,-12.7,-28.3,-3.1,-25.8C6.4,-23.3,12.8,-19.3,18.2,-13.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1,015 B

10
js/public/img/shape-2.svg Normal file
View file

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(181.058, 255, 167.816, 0.2)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(149.009, 254.848, 251.263, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M20.2,-14.3C28.2,-6,38.3,2.5,37.6,9.8C36.9,17.1,25.5,23.1,15.5,25.2C5.6,27.3,-2.9,25.4,-11.2,21.9C-19.6,18.4,-27.9,13.3,-30.8,5.6C-33.7,-2.1,-31.2,-12.5,-25.2,-20.4C-19.1,-28.3,-9.6,-33.7,-1.8,-32.3C6.1,-30.9,12.1,-22.7,20.2,-14.3Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1,016 B

10
js/public/img/shape-3.svg Normal file
View file

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(172.198, 167.816, 255, 0.2)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(236.8, 149.009, 254.848, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M25.3,-21.5C29.4,-15.2,26.8,-4.8,23.6,3.8C20.4,12.5,16.5,19.4,10.2,23.2C3.9,27,-4.8,27.6,-12.6,24.5C-20.3,21.4,-27,14.6,-30.1,5.6C-33.2,-3.4,-32.6,-14.4,-26.9,-21.1C-21.3,-27.8,-10.7,-30.1,0,-30.1C10.7,-30.1,21.3,-27.8,25.3,-21.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1,013 B

View file

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head>
<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="icon" href="<%= BASE_URL %>favicon.ico" />
<meta name="server-injected-data" />
</head>
<body>
<noscript>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -1,40 +1,37 @@
<template> <template>
<div id="mobilizon"> <div id="mobilizon">
<VueAnnouncer /> <!-- <VueAnnouncer />
<VueSkipTo to="#main" :label="$t('Skip to main content')" /> <VueSkipTo to="#main" :label="t('Skip to main content')" /> -->
<NavBar /> <NavBar />
<div v-if="config && config.demoMode"> <div v-if="isDemoMode">
<b-message <o-notification
class="container" class="container mx-auto"
type="is-danger" variant="danger"
:title="$t('Warning').toLocaleUpperCase()" :title="t('Warning').toLocaleUpperCase()"
closable closable
:aria-close-label="$t('Close')" :aria-close-label="t('Close')"
> >
<p> <p>
{{ $t("This is a demonstration site to test Mobilizon.") }} {{ t("This is a demonstration site to test Mobilizon.") }}
<b>{{ $t("Please do not use it in any real way.") }}</b> <b>{{ t("Please do not use it in any real way.") }}</b>
{{ {{
$t( t(
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)." "This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
) )
}} }}
</p> </p>
</b-message> </o-notification>
</div> </div>
<error v-if="error" :error="error" /> <ErrorComponent v-if="error" :error="error" />
<main id="main" v-else> <main id="main" class="pt-4" v-else>
<transition name="fade" mode="out-in"> <router-view></router-view>
<router-view ref="routerView" />
</transition>
</main> </main>
<mobilizon-footer /> <mobilizon-footer />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
import NavBar from "./components/NavBar.vue"; import NavBar from "./components/NavBar.vue";
import { import {
AUTH_ACCESS_TOKEN, AUTH_ACCESS_TOKEN,
@ -42,224 +39,237 @@ import {
AUTH_USER_ID, AUTH_USER_ID,
AUTH_USER_ROLE, AUTH_USER_ROLE,
} from "./constants"; } from "./constants";
import { import { UPDATE_CURRENT_USER_CLIENT } from "./graphql/user";
CURRENT_USER_CLIENT, import MobilizonFooter from "./components/Footer.vue";
UPDATE_CURRENT_USER_CLIENT,
} from "./graphql/user";
import Footer from "./components/Footer.vue";
import Logo from "./components/Logo.vue";
import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model";
import { ICurrentUser } from "./types/current-user.model";
import jwt_decode, { JwtPayload } from "jwt-decode"; import jwt_decode, { JwtPayload } from "jwt-decode";
import { refreshAccessToken } from "./apollo/utils"; import { refreshAccessToken } from "./apollo/utils";
import { Route } from "vue-router"; import {
reactive,
ref,
provide,
onUnmounted,
onMounted,
onBeforeMount,
inject,
defineAsyncComponent,
} from "vue";
import { LocationType } from "./types/user-location.model";
import { useMutation } 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";
@Component({ const ErrorComponent = defineAsyncComponent(
apollo: { () => import("./components/ErrorComponent.vue")
currentUser: CURRENT_USER_CLIENT, );
config: CONFIG,
}, const { t } = useI18n({ useScope: "global" });
components: {
Logo, const { location } = useServerProvidedLocation();
NavBar,
error: () => const userLocation = reactive<LocationType>({
import(/* webpackChunkName: "editor" */ "./components/Error.vue"), lon: undefined,
"mobilizon-footer": Footer, lat: undefined,
}, name: undefined,
metaInfo() { picture: undefined,
return { isIPLocation: true,
titleTemplate: "%s | Mobilizon", accuracy: 100,
});
const updateUserLocation = (newLocation: LocationType) => {
userLocation.lat = newLocation.lat;
userLocation.lon = newLocation.lon;
userLocation.name = newLocation.name;
userLocation.picture = newLocation.picture;
userLocation.isIPLocation = newLocation.isIPLocation;
userLocation.accuracy = newLocation.accuracy;
};
updateUserLocation({
lat: location.value?.latitude,
lon: location.value?.longitude,
name: "", // config.ipLocation.country.name,
isIPLocation: true,
accuracy: 150, // config.ipLocation.location.accuracy_radius * 1.5 || 150,
});
provide("userLocation", {
userLocation,
updateUserLocation,
});
// const routerView = ref("routerView");
const error = ref<Error | null>(null);
const online = ref(true);
const interval = ref<number>(0);
const notifier = inject<Notifier>("notifier");
interval.value = setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken();
}
}
}, 60000) as unknown as number;
onBeforeMount(async () => {
if (initializeCurrentUser()) {
await initializeCurrentActor();
}
});
const snackbar = inject<Snackbar>("snackbar");
onMounted(() => {
online.value = window.navigator.onLine;
window.addEventListener("offline", () => {
online.value = false;
showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
online.value = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
snackbar?.open({
queue: false,
indefinite: true,
variant: "dark",
actionText: t("Update app"),
cancelText: t("Ignore"),
message: t("A new version is available."),
onAction: async () => {
const registration = event.detail as ServiceWorkerRegistration;
try {
await refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
notifier?.error(t("An error has occured while refreshing the page."));
}
},
});
});
});
onUnmounted(() => {
clearInterval(interval.value);
interval.value = 0;
});
const { mutate: updateCurrentUser } = useMutation(UPDATE_CURRENT_USER_CLIENT);
const initializeCurrentUser = () => {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
console.log("Saving current user client from localstorage", role);
updateCurrentUser({
id: userId,
email: userEmail,
isLoggedIn: true,
role,
});
return true;
}
return false;
};
const refreshApp = async (
registration: ServiceWorkerRegistration
): Promise<any> => {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
console.debug("Doing worker.skipWaiting().");
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
console.debug("Done worker.skipWaiting().");
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
}; };
}, console.debug("calling skip waiting");
}) worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
export default class App extends Vue { });
config!: IConfig; };
currentUser!: ICurrentUser; const showOfflineNetworkWarning = (): void => {
notifier?.error(t("You are offline"));
};
// const extractPageTitleFromRoute = (routeWatched: RouteLocation): string => {
// if (routeWatched.meta?.announcer?.message) {
// return routeWatched.meta?.announcer?.message();
// }
// return document.title;
// };
error: Error | null = null; // watch(route, (routeWatched) => {
// const pageTitle = extractPageTitleFromRoute(routeWatched);
// if (pageTitle) {
// // this.$announcer.polite(
// // t("Navigated to {pageTitle}", {
// // pageTitle,
// // }) as string
// // );
// }
// // Set the focus to the router view
// // https://marcus.io/blog/accessible-routing-vuejs
// setTimeout(() => {
// const focusTarget = (
// routerView.value?.$refs?.componentFocusTarget !== undefined
// ? routerView.value?.$refs?.componentFocusTarget
// : routerView.value?.$el
// ) as HTMLElement;
// if (focusTarget && focusTarget instanceof Element) {
// // Make focustarget programmatically focussable
// focusTarget.setAttribute("tabindex", "-1");
online = true; // // Focus element
// focusTarget.focus();
interval: number | undefined = undefined; // // Remove tabindex from focustarget.
// // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
// focusTarget.removeAttribute("tabindex");
// }
// }, 0);
// });
@Ref("routerView") routerView!: Vue; // 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 });
// }
// });
async created(): Promise<void> { const { isDemoMode } = useIsDemoMode();
if (await this.initializeCurrentUser()) {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
}
}
errorCaptured(error: Error): void {
this.error = error;
}
private async initializeCurrentUser() {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role,
},
});
}
return false;
}
mounted(): void {
this.online = window.navigator.onLine;
window.addEventListener("offline", () => {
this.online = false;
this.showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
this.online = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
this.$buefy.snackbar.open({
queue: false,
indefinite: true,
type: "is-secondary",
actionText: this.$t("Update app") as string,
cancelText: this.$t("Ignore") as string,
message: this.$t("A new version is available.") as string,
onAction: async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const detail = event.detail;
const registration = detail as ServiceWorkerRegistration;
try {
await this.refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
this.$notifier.error(
this.$t(
"An error has occured while refreshing the page."
) as string
);
}
},
});
});
this.interval = setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken(this.$apollo.getClient());
}
}
}, 60000);
}
private async refreshApp(
registration: ServiceWorkerRegistration
): Promise<any> {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
console.debug("Doing worker.skipWaiting().");
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
console.debug("Done worker.skipWaiting().");
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
};
console.debug("calling skip waiting");
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
});
}
showOfflineNetworkWarning(): void {
this.$notifier.error(this.$t("You are offline") as string);
}
unmounted(): void {
clearInterval(this.interval);
this.interval = undefined;
}
@Watch("config")
async initializeStatistics(config: IConfig) {
if (config) {
const { statistics } = (await import("./services/statistics")) as {
statistics: (config: IConfig, environment: Record<string, any>) => void;
};
statistics(config, { router: this.$router, version: config.version });
}
}
@Watch("$route", { immediate: true })
updateAnnouncement(route: Route): void {
const pageTitle = this.extractPageTitleFromRoute(route);
if (pageTitle) {
this.$announcer.polite(
this.$t("Navigated to {pageTitle}", {
pageTitle,
}) as string
);
}
// Set the focus to the router view
// https://marcus.io/blog/accessible-routing-vuejs
setTimeout(() => {
const focusTarget = (
this.routerView?.$refs?.componentFocusTarget !== undefined
? this.routerView?.$refs?.componentFocusTarget
: this.routerView?.$el
) as HTMLElement;
if (focusTarget && focusTarget instanceof Element) {
// Make focustarget programmatically focussable
focusTarget.setAttribute("tabindex", "-1");
// Focus element
focusTarget.focus();
// Remove tabindex from focustarget.
// Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
focusTarget.removeAttribute("tabindex");
}
}, 0);
}
extractPageTitleFromRoute(route: Route): string {
if (route.meta?.announcer?.message) {
return route.meta?.announcer?.message();
}
return document.title;
}
}
</script> </script>
<style lang="scss"> <style lang="scss">
@import "variables"; @import "variables";
/* Icons */
$mdi-font-path: "~@mdi/font/fonts";
@import "~@mdi/font/scss/materialdesignicons";
@import "common"; @import "common";
#mobilizon { #mobilizon {

View file

@ -14,7 +14,8 @@ export const MOBILIZON_INSTANCE_HOST = window.location.hostname;
* *
* Example: https://framameet.org * Example: https://framameet.org
*/ */
export const GRAPHQL_API_ENDPOINT = window.location.origin; export const GRAPHQL_API_ENDPOINT =
import.meta.env.VITE_SERVER_URL ?? window.location.origin;
/** /**
* URL with path on which the API is. Replaces GRAPHQL_API_ENDPOINT if used * URL with path on which the API is. Replaces GRAPHQL_API_ENDPOINT if used
@ -23,4 +24,4 @@ export const GRAPHQL_API_ENDPOINT = window.location.origin;
* *
* Example: https://framameet.org/api * Example: https://framameet.org/api
*/ */
export const GRAPHQL_API_FULL_PATH = `${window.location.origin}/api`; export const GRAPHQL_API_FULL_PATH = `${GRAPHQL_API_ENDPOINT}/api`;

View file

@ -0,0 +1,25 @@
import { Socket as PhoenixSocket } from "phoenix";
import { create } from "@absinthe/socket";
import { createAbsintheSocketLink } from "@absinthe/socket-apollo-link";
import { AUTH_ACCESS_TOKEN } from "@/constants";
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
const httpServer = GRAPHQL_API_ENDPOINT || "http://localhost:4000";
const webSocketPrefix = import.meta.env.PROD ? "wss" : "ws";
const wsEndpoint = `${webSocketPrefix}${httpServer.substring(
httpServer.indexOf(":")
)}/graphql_socket`;
const phoenixSocket = new PhoenixSocket(wsEndpoint, {
params: () => {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (token) {
return { token };
}
return {};
},
});
const absintheSocket = create(phoenixSocket);
export default createAbsintheSocketLink(absintheSocket);

View file

@ -0,0 +1,20 @@
import fetch from "unfetch";
import { createLink } from "apollo-absinthe-upload-link";
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from "@/api/_entrypoint";
// Endpoints
const httpServer = GRAPHQL_API_ENDPOINT || "http://localhost:4000";
const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`;
const customFetch = async (uri: string, options: any) => {
const response = await fetch(uri, options);
if (response.status >= 400) {
return Promise.reject(response.status);
}
return response;
};
export const uploadLink = createLink({
uri: httpEndpoint,
fetch: customFetch,
});

23
js/src/apollo/auth.ts Normal file
View file

@ -0,0 +1,23 @@
import { AUTH_ACCESS_TOKEN } from "@/constants";
import { ApolloLink } from "@apollo/client/core";
export function generateTokenHeader() {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
return token ? `Bearer ${token}` : null;
}
const authMiddleware = new ApolloLink((operation, forward) => {
// add the authorization to the headers
operation.setContext({
headers: {
authorization: generateTokenHeader(),
},
});
if (forward) return forward(operation);
return null;
});
export { authMiddleware };

101
js/src/apollo/error-link.ts Normal file
View file

@ -0,0 +1,101 @@
import { logout } from "@/utils/auth";
import { onError } from "@apollo/client/link/error";
import { fromPromise } from "@apollo/client/core";
import { refreshAccessToken } from "./utils";
import { GraphQLError } from "graphql";
import { generateTokenHeader } from "./auth";
let isRefreshing = false;
let pendingRequests: any[] = [];
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback());
pendingRequests = [];
};
const isAuthError = (graphQLError: GraphQLError | undefined) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return graphQLError && [403, 401].includes(graphQLError.status_code);
};
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
console.debug("We have an apollo error", [graphQLErrors, networkError]);
if (
graphQLErrors?.some((graphQLError) => isAuthError(graphQLError)) ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
networkError === 401
) {
console.debug("It's a authorization error (statusCode 401)");
let forwardOperation;
if (!isRefreshing) {
console.debug("Setting isRefreshing to true");
isRefreshing = true;
forwardOperation = fromPromise(
refreshAccessToken()
.then((res) => {
if (res !== true) {
// failed to refresh the token
throw "Failed to refresh the token";
}
resolvePendingRequests();
const context = operation.getContext();
const oldHeaders = context.headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: generateTokenHeader(),
},
});
return true;
})
.catch((e) => {
console.debug("Something failed, let's logout", e);
pendingRequests = [];
// don't perform a logout since we don't have any working access/refresh tokens
logout(false);
return;
})
.finally(() => {
isRefreshing = false;
})
).filter((value) => Boolean(value));
} else {
forwardOperation = fromPromise(
new Promise((resolve) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
pendingRequests.push(() => resolve());
})
);
}
return forwardOperation.flatMap(() => forward(operation));
}
if (graphQLErrors) {
graphQLErrors.map(
(graphQLError: GraphQLError & { status_code?: number }) => {
if (graphQLError?.status_code !== 401) {
console.log(
`[GraphQL error]: Message: ${graphQLError.message}, Location: ${graphQLError.locations}, Path: ${graphQLError.path}`
);
}
}
);
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
console.debug(JSON.stringify(networkError));
}
}
);
export default errorLink;

27
js/src/apollo/link.ts Normal file
View file

@ -0,0 +1,27 @@
import { split } from "@apollo/client/core";
import { RetryLink } from "@apollo/client/link/retry";
import { getMainDefinition } from "@apollo/client/utilities";
import absintheSocketLink from "./absinthe-socket-link";
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
// );
const retryLink = new RetryLink();
export const fullLink = authMiddleware
.concat(retryLink)
.concat(errorLink)
.concat(uploadLink);

14
js/src/apollo/memory.ts Normal file
View file

@ -0,0 +1,14 @@
import { defaultDataIdFromObject, InMemoryCache } from "@apollo/client/core";
import { possibleTypes, typePolicies } from "./utils";
export const cache = new InMemoryCache({
addTypename: true,
typePolicies,
possibleTypes,
dataIdFromObject: (object: any) => {
if (object.__typename === "Address") {
return object.origin_id;
}
return defaultDataIdFromObject(object);
},
});

View file

@ -1,4 +1,5 @@
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CURRENT_USER_LOCATION_CLIENT } from "@/graphql/location";
import { CURRENT_USER_CLIENT } from "@/graphql/user"; import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUserRole } from "@/types/enums"; import { ICurrentUserRole } from "@/types/enums";
import { ApolloCache, NormalizedCacheObject } from "@apollo/client/cache"; import { ApolloCache, NormalizedCacheObject } from "@apollo/client/cache";
@ -33,6 +34,20 @@ export default function buildCurrentUserResolver(
}, },
}); });
cache.writeQuery({
query: CURRENT_USER_LOCATION_CLIENT,
data: {
currentUserLocation: {
lat: null,
lon: null,
accuracy: null,
isIPLocation: null,
name: null,
picture: null,
},
},
});
return { return {
Mutation: { Mutation: {
updateCurrentUser: ( updateCurrentUser: (
@ -55,6 +70,8 @@ export default function buildCurrentUserResolver(
}, },
}; };
console.debug("updating current user", data);
localCache.writeQuery({ data, query: CURRENT_USER_CLIENT }); localCache.writeQuery({ data, query: CURRENT_USER_CLIENT });
}, },
updateCurrentActor: ( updateCurrentActor: (
@ -82,8 +99,45 @@ export default function buildCurrentUserResolver(
}, },
}; };
console.debug("updating current actor", data);
localCache.writeQuery({ data, query: CURRENT_ACTOR_CLIENT }); localCache.writeQuery({ data, query: CURRENT_ACTOR_CLIENT });
}, },
updateCurrentUserLocation: (
_: any,
{
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
}: {
lat: number;
lon: number;
accuracy: number;
isIPLocation: boolean;
name: string;
picture: any;
},
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentUserLocation: {
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
__typename: "CurrentUserLocation",
},
};
console.debug("updating current user location", data);
localCache.writeQuery({ data, query: CURRENT_USER_LOCATION_CLIENT });
},
}, },
}; };
} }

View file

@ -4,19 +4,16 @@ import { IFollower } from "@/types/actor/follower.model";
import { IParticipant } from "@/types/participant.model"; import { IParticipant } from "@/types/participant.model";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { saveTokenData } from "@/utils/auth"; import { saveTokenData } from "@/utils/auth";
import { import { FieldPolicy, Reference, TypePolicies } from "@apollo/client/core";
ApolloClient,
FieldPolicy,
NormalizedCacheObject,
Reference,
TypePolicies,
} from "@apollo/client/core";
import introspectionQueryResultData from "../../fragmentTypes.json"; import introspectionQueryResultData from "../../fragmentTypes.json";
import { IMember } from "@/types/actor/member.model"; import { IMember } from "@/types/actor/member.model";
import { IComment } from "@/types/comment.model"; import { IComment } from "@/types/comment.model";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { IActivity } from "@/types/activity.model"; import { IActivity } from "@/types/activity.model";
import uniqBy from "lodash/uniqBy"; import uniqBy from "lodash/uniqBy";
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo";
import { IToken } from "@/types/login.model";
type possibleTypes = { name: string }; type possibleTypes = { name: string };
type schemaType = { type schemaType = {
@ -73,6 +70,9 @@ export const typePolicies: TypePolicies = {
Instance: { Instance: {
keyFields: ["domain"], keyFields: ["domain"],
}, },
Config: {
merge: true,
},
RootQueryType: { RootQueryType: {
fields: { fields: {
relayFollowers: paginatedLimitPagination<IFollower>(), relayFollowers: paginatedLimitPagination<IFollower>(),
@ -99,9 +99,7 @@ export const typePolicies: TypePolicies = {
}, },
}; };
export async function refreshAccessToken( export async function refreshAccessToken(): Promise<boolean> {
apolloClient: ApolloClient<NormalizedCacheObject>
): Promise<boolean> {
// Remove invalid access token, so the next request is not authenticated // Remove invalid access token, so the next request is not authenticated
localStorage.removeItem(AUTH_ACCESS_TOKEN); localStorage.removeItem(AUTH_ACCESS_TOKEN);
@ -114,21 +112,28 @@ export async function refreshAccessToken(
console.log("Refreshing access token."); console.log("Refreshing access token.");
try { return new Promise((resolve, reject) => {
const res = await apolloClient.mutate({ const { mutate, onDone, onError } = provideApolloClient(apolloClient)(() =>
mutation: REFRESH_TOKEN, useMutation<{ refreshToken: IToken }>(REFRESH_TOKEN)
variables: { );
refreshToken,
}, mutate({
refreshToken,
}); });
saveTokenData(res.data.refreshToken); onDone(({ data }) => {
if (data?.refreshToken) {
saveTokenData(data?.refreshToken);
resolve(true);
}
reject(false);
});
return true; onError((err) => {
} catch (err) { console.debug("Failed to refresh token");
console.debug("Failed to refresh token"); reject(false);
return false; });
} });
} }
type KeyArgs = FieldPolicy<any>["keyArgs"]; type KeyArgs = FieldPolicy<any>["keyArgs"];

View file

@ -0,0 +1,192 @@
body {
@apply bg-body-background-color dark:bg-gray-700 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;
}
.btn-rounded {
@apply rounded-full;
}
.btn-outlined-primary {
@apply bg-transparent text-blue-700 font-semibold py-2 px-4 border border-mbz-bluegreen dark:border-violet-3;
}
.btn-outlined-primary:hover {
@apply font-bold py-2 px-4 bg-mbz-bluegreen dark:bg-violet-3 text-white rounded;
}
.btn-disabled {
@apply opacity-50 cursor-not-allowed;
}
/* Field */
.field {
margin-top: 0.5rem;
}
.field-label {
@apply block text-gray-700 dark:text-gray-100 text-base font-bold mb-2;
}
.field-danger {
@apply text-red-500;
}
.o-field.o-field--addons .control:last-child:not(:only-child) .button {
@apply inline-flex text-gray-800 bg-gray-200 h-9 mt-[1px] rounded text-center px-2 py-1.5;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.field-message-info {
@apply text-mbz-info;
}
.field-message-danger {
@apply text-mbz-danger;
}
/* Input */
.input {
@apply appearance-none border w-full py-2 px-3 text-black leading-tight;
}
.input-danger {
@apply border-red-500;
}
.input-icon-right {
right: 0.5rem;
}
.icon-warning {
@apply text-amber-600;
}
.icon-danger {
@apply text-red-500;
}
.o-input__icon-left {
@apply dark:text-black h-10 w-10;
}
.o-input-iconspace-left {
@apply pl-10;
}
/* InputItems */
.inputitems-item {
@apply bg-primary mr-2;
}
.inputitems-item:first-child {
@apply ml-2;
}
/* Autocomplete */
.autocomplete-menu {
@apply max-h-[200px] drop-shadow-md text-black;
}
.autocomplete-item {
@apply py-1.5 px-4;
}
/* Dropdown */
.dropdown {
@apply inline-flex relative;
}
.dropdown-menu {
min-width: 12em;
@apply bg-white dark:bg-gray-700 shadow-lg rounded-sm;
}
.dropdown-item {
@apply relative inline-flex gap-1 no-underline p-2 cursor-pointer;
}
.dropdown-item-active {
/* @apply bg-violet-2; */
@apply bg-white;
}
/* Checkbox */
.checkbox {
@apply appearance-none bg-blue-500 border-blue-500;
}
.checkbox-checked {
@apply bg-blue-500;
}
.checkbox-label {
margin-left: 0.2rem;
}
/* Modal */
.modal-content {
@apply bg-white dark:bg-gray-700 rounded px-2 py-4 w-full;
}
/* Switch */
.switch {
@apply cursor-pointer inline-flex items-center relative mr-2;
}
.switch-label {
@apply pl-2;
}
/* Select */
.select {
@apply dark:bg-white dark:text-black rounded pl-2 pr-6 border-2 border-transparent h-10 shadow-none;
}
/* Radio */
.form-radio {
@apply bg-none;
}
.radio-label {
@apply pl-2;
}
/* Editor */
button.menubar__button {
@apply dark:text-white;
}
/* Notification */
.notification {
@apply p-7;
}
.notification-primary {
@apply bg-primary;
}
.notification-info {
@apply bg-mbz-info;
}
.notification-warning {
@apply bg-amber-600 text-black;
}
.notification-danger {
@apply bg-mbz-danger;
}
/* Table */
.table tr {
@apply odd:bg-white even:bg-gray-50 border-b;
}
.table-td {
@apply py-4 px-2 whitespace-nowrap;
}
/* Snackbar */
.notification-dark {
@apply text-white;
background: #363636;
}

View file

@ -3,3 +3,49 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
body {
@apply bg-white dark:bg-gray-900;
}
h1 {
@apply text-4xl lg:text-5xl leading-none font-extrabold tracking-tight mt-5 mb-4 sm:mt-7 sm:mb-5;
}
h2 {
@apply text-xl mt-2;
}
h3 {
@apply text-lg;
}
}
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;
}
}
@media (prefers-color-scheme: dark) {
:root {
--oruga-variant-primary: #1e7d97;
--oruga-field-label-color: white;
--oruga-table-background-color: #111827;
--oruga-table-th-color: white;
--oruga-modal-content-background-color: #111827;
--oruga-dropdown-item-color: white;
--oruga-dropdown-menu-background: #111827;
--oruga-dropdown-item-hover-color: white;
--oruga-dropdown-item-hover-background-color: #111827;
}
}

View file

@ -1,9 +1,9 @@
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;
@import "variables.scss"; @import "variables.scss";
@import "~bulma"; // @import "node_modules/bulma/bulma.sass";
@import "~bulma-divider"; // @import "node_modules/bulma-divider/src/sass/index.sass";
@import "~buefy/src/scss/buefy"; // @import "node_modules/buefy/src/scss/buefy";
@import "styles/vue-announcer.scss"; @import "styles/vue-announcer.scss";
@import "styles/vue-skip-to.scss"; @import "styles/vue-skip-to.scss";
@ -75,44 +75,44 @@ body {
color: $violet-1; color: $violet-1;
} }
$list-background-color: $scheme-main !default; // $list-background-color: $scheme-main !default;
$list-shadow: 0 2px 3px rgba($scheme-invert, 0.1), // $list-shadow: 0 2px 3px rgba($scheme-invert, 0.1),
0 0 0 1px rgba($scheme-invert, 0.1) !default; // 0 0 0 1px rgba($scheme-invert, 0.1) !default;
$list-radius: $radius !default; // $list-radius: $radius !default;
$list-item-border: 1px solid $border !default; // $list-item-border: 1px solid $border !default;
$list-item-color: $text !default; // $list-item-color: $text !default;
$list-item-active-background-color: $link !default; // $list-item-active-background-color: $link !default;
$list-item-active-color: $link-invert !default; // $list-item-active-color: $link-invert !default;
$list-item-hover-background-color: $background !default; // $list-item-hover-background-color: $background !default;
.list-item { // .list-item {
display: block; // display: block;
padding: 0.5em 1em; // padding: 0.5em 1em;
&:not(a) { // &:not(a) {
color: $list-item-color; // color: $list-item-color;
} // }
&:first-child { // &:first-child {
border-top-left-radius: $list-radius; // border-top-left-radius: $list-radius;
border-top-right-radius: $list-radius; // border-top-right-radius: $list-radius;
} // }
&:last-child { // &:last-child {
border-bottom-left-radius: $list-radius; // border-bottom-left-radius: $list-radius;
border-bottom-right-radius: $list-radius; // border-bottom-right-radius: $list-radius;
} // }
&:not(:last-child) { // &:not(:last-child) {
border-bottom: $list-item-border; // border-bottom: $list-item-border;
} // }
&.is-active { // &.is-active {
background-color: $list-item-active-background-color; // background-color: $list-item-active-background-color;
color: $list-item-active-color; // color: $list-item-active-color;
} // }
} // }
a.list-item { // a.list-item {
background-color: $list-item-hover-background-color; // background-color: $list-item-hover-background-color;
cursor: pointer; // cursor: pointer;
} // }
.setting-title { .setting-title {
margin-top: 2rem; margin-top: 2rem;

View file

@ -0,0 +1,20 @@
<template>
<Story>
<Variant title="empty">
<InstanceContactLink />
</Variant>
<Variant title="string">
<InstanceContactLink contact="someone" />
</Variant>
<Variant title="email">
<InstanceContactLink contact="someone@somewhere.tld" />
</Variant>
<Variant title="url">
<InstanceContactLink contact="https://somewhere.com" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import InstanceContactLink from "./InstanceContactLink.vue";
</script>

View file

@ -4,49 +4,49 @@
configLink.text configLink.text
}}</a> }}</a>
<span dir="auto" v-else-if="contact">{{ contact }}</span> <span dir="auto" v-else-if="contact">{{ contact }}</span>
<span v-else>{{ $t("contact uninformed") }}</span> <span v-else>{{ t("contact uninformed") }}</span>
</p> </p>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue } from "vue-property-decorator"; import { computed } from "vue";
import { useI18n } from "vue-i18n";
@Component const props = defineProps<{
export default class InstanceContactLink extends Vue { contact?: string;
@Prop({ required: true, type: String }) contact!: string; }>();
get configLink(): { uri: string; text: string } | null { const { t } = useI18n({ useScope: "global" });
if (!this.contact) return null;
if (this.isContactEmail) { const configLink = computed((): { uri: string; text: string } | null => {
return { if (!props.contact) return null;
uri: `mailto:${this.contact}`, if (isContactEmail.value) {
text: this.contact, return {
}; uri: `mailto:${props.contact}`,
} text: props.contact,
if (this.isContactURL) { };
return { }
uri: this.contact, if (isContactURL.value) {
text: return {
InstanceContactLink.urlToHostname(this.contact) || uri: props.contact,
(this.$t("Contact") as string), text: urlToHostname(props.contact) ?? "Contact",
}; };
} }
return null;
});
const isContactEmail = computed((): boolean => {
return (props.contact ?? "").includes("@");
});
const isContactURL = computed((): boolean => {
return (props.contact ?? "").match(/^https?:\/\//g) !== null;
});
const urlToHostname = (url: string): string | null => {
try {
return new URL(url).hostname;
} catch (e) {
return null; return null;
} }
};
get isContactEmail(): boolean {
return this.contact.includes("@");
}
get isContactURL(): boolean {
return this.contact.match(/^https?:\/\//g) !== null;
}
static urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
}
}
</script> </script>

View file

@ -0,0 +1,52 @@
<template>
<Story>
<Variant title="local">
<ActorCard :actor="stateLocal"></ActorCard>
<template #controls>
<HstText v-model="stateLocal.preferredUsername" title="username" />
<HstText v-model="stateLocal.name" title="Name" />
</template>
</Variant>
<Variant title="remote">
<ActorCard :actor="stateRemote"></ActorCard>
<template #controls>
<HstText v-model="stateRemote.preferredUsername" title="username" />
<HstText v-model="stateRemote.name" title="Name" />
<HstText v-model="stateRemote.domain" title="Domain" />
<HstText v-model="avatarUrl" title="Avatar" />
</template>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ActorCard from "./ActorCard.vue";
import { reactive, ref } from "vue";
import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
const avatarUrl = ref<string>(
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
);
const stateLocal = reactive<IActor>({
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
const stateRemote = reactive<IActor>({
name: "Framasoft",
preferredUsername: "framasoft",
avatar: { url: avatarUrl.value, id: "", name: "", alt: "", metadata: {} },
domain: "framapiaf.org",
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
</script>

View file

@ -1,9 +1,9 @@
<template> <template>
<div <div
class="bg-white rounded-lg flex space-x-4 items-center" class="bg-white dark:bg-mbz-purple rounded-lg flex space-x-4 items-center"
:class="{ 'flex-col p-4 shadow-md sm:p-8 pb-10 w-80': !inline }" :class="{ 'flex-col p-4 shadow-md sm:p-8 pb-10 w-80': !inline }"
> >
<div> <div class="flex pl-2">
<figure class="w-12 h-12" v-if="actor.avatar"> <figure class="w-12 h-12" v-if="actor.avatar">
<img <img
class="rounded-lg" class="rounded-lg"
@ -13,16 +13,15 @@
height="48" height="48"
/> />
</figure> </figure>
<b-icon <AccountCircle
v-else v-else
:size="inline ? 'is-medium' : 'is-large'" :size="inline ? 24 : 48"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5" class="ltr:-mr-0.5 rtl:-ml-0.5"
/> />
</div> </div>
<div :class="{ 'text-center': !inline }" class="overflow-hidden w-full"> <div :class="{ 'text-center': !inline }" class="overflow-hidden w-full">
<h5 <h5
class="text-xl font-medium violet-title tracking-tight text-gray-900 whitespace-pre-line line-clamp-2" class="text-xl font-medium violet-title tracking-tight text-gray-900 dark:text-gray-200 whitespace-pre-line line-clamp-2"
> >
{{ displayName(actor) }} {{ displayName(actor) }}
</h5> </h5>
@ -54,9 +53,9 @@
height="48" height="48"
/> />
</figure> </figure>
<b-icon <o-icon
v-else v-else
size="is-large" size="large"
icon="account-circle" icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5" class="ltr:-mr-0.5 rtl:-ml-0.5"
/> />
@ -78,29 +77,28 @@
</div> </div>
</div> --> </div> -->
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Vue, Prop } from "vue-property-decorator";
import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Component withDefaults(
export default class ActorCard extends Vue { defineProps<{
@Prop({ required: true, type: Object }) actor!: IActor; actor: IActor;
full?: boolean;
@Prop({ required: false, type: Boolean, default: false }) full!: boolean; inline?: boolean;
popover?: boolean;
@Prop({ required: false, type: Boolean, default: false }) inline!: boolean; limit?: boolean;
}>(),
@Prop({ required: false, type: Boolean, default: false }) popover!: boolean; {
full: false,
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean; inline: false,
popover: false,
usernameWithDomain = usernameWithDomain; limit: true,
}
displayName = displayName; );
}
</script> </script>
<style scoped> <style scoped>
.only-first-child ::v-deep :not(:first-child) { .only-first-child :deep(:not(:first-child)) {
display: none; display: none;
} }
</style> </style>

View file

@ -0,0 +1,52 @@
<template>
<Story>
<Variant title="local">
<ActorInline :actor="stateLocal" />
<template #controls>
<HstText v-model="stateLocal.preferredUsername" title="username" />
<HstText v-model="stateLocal.name" title="Name" />
</template>
</Variant>
<Variant title="remote">
<ActorInline :actor="stateRemote" />
<template #controls>
<HstText v-model="stateRemote.preferredUsername" title="username" />
<HstText v-model="stateRemote.name" title="Name" />
<HstText v-model="stateRemote.domain" title="Domain" />
<HstText v-model="avatarUrl" title="Avatar" />
</template>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ActorInline from "./ActorInline.vue";
import { reactive, ref } from "vue";
import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
const avatarUrl = ref<string>(
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
);
const stateLocal = reactive<IActor>({
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
const stateRemote = reactive<IActor>({
name: "Framasoft",
preferredUsername: "framasoft",
avatar: { url: avatarUrl.value, id: "", name: "", alt: "", metadata: {} },
domain: "framapiaf.org",
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
</script>

View file

@ -1,34 +1,37 @@
<template> <template>
<div class="inline-flex items-start"> <div
class="inline-flex items-start bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
>
<div class="flex-none mr-2"> <div class="flex-none mr-2">
<figure class="image is-48x48" v-if="actor.avatar"> <figure v-if="actor.avatar">
<img class="is-rounded" :src="actor.avatar.url" alt="" /> <img
class="rounded-xl"
:src="actor.avatar.url"
alt=""
width="36"
height="36"
/>
</figure> </figure>
<b-icon v-else size="is-large" icon="account-circle" /> <AccountCircle :size="36" v-else />
</div> </div>
<div class="flex-auto"> <div class="flex-auto">
<p class="text-base line-clamp-3 md:line-clamp-2 max-w-xl"> <p class="text-lg line-clamp-3 md:line-clamp-2 max-w-xl">
{{ displayName(actor) }} {{ displayName(actor) }}
</p> </p>
<p class="text-sm text-gray-500 truncate"> <p class="text-sm text-gray-500 dark:text-gray-300 truncate">
@{{ usernameWithDomain(actor) }} @{{ usernameWithDomain(actor) }}
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Vue, Prop } from "vue-property-decorator";
import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Component defineProps<{
export default class ActorInline extends Vue { actor: IActor;
@Prop({ required: true, type: Object }) actor!: IActor; }>();
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;
@ -42,7 +45,7 @@ div.actor-inline {
flex-basis: auto; flex-basis: auto;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
@include margin-right(0.5rem); // @include margin-right(0.5rem);
} }
div.actor-name { div.actor-name {
flex-basis: auto; flex-basis: auto;

View file

@ -1,90 +0,0 @@
<template>
<section>
<h1 class="title">
{{ $t("My identities") }}
</h1>
<ul class="identities">
<li v-for="identity in identities" :key="identity.id">
<router-link
:to="{
name: 'UpdateIdentity',
params: { identityName: identity.preferredUsername },
}"
class="media identity"
v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"
>
<div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url" />
</figure>
</div>
<div class="media-content">
{{ identity.displayName() }}
</div>
</router-link>
</li>
</ul>
<router-link
:to="{ name: 'CreateIdentity' }"
class="button create-identity is-primary"
>
{{ $t("Create a new identity") }}
</router-link>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IDENTITIES } from "../../graphql/actor";
import { IPerson, Person } from "../../types/actor";
@Component({
apollo: {
identities: {
query: IDENTITIES,
update(result) {
return result.identities.map((i: IPerson) => new Person(i));
},
},
},
})
export default class Identities extends Vue {
@Prop({ type: String }) currentIdentityName!: string;
identities: Person[] = [];
errors: string[] = [];
isCurrentIdentity(identity: IPerson): boolean {
return identity.preferredUsername === this.currentIdentityName;
}
}
</script>
<style lang="scss" scoped>
.identities {
border-right: 1px solid grey;
padding: 15px 0;
}
.media.identity {
align-items: center;
font-size: 1.3rem;
padding-bottom: 0;
margin-bottom: 15px;
color: #000;
&.is-current-identity {
background-color: rgba(0, 0, 0, 0.1);
}
}
.title {
margin-bottom: 30px;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<Story>
<Variant :setup-app="setupApp" title="Person">
<div class="p-5">
<PopoverActorCard :actor="baseActor">
<div><b> Popover me !</b></div></PopoverActorCard
>
</div>
</Variant>
<Variant :setup-app="setupApp" title="Group">
<div class="p-5">
<PopoverActorCard :actor="group">
<div><b> Popover me !</b></div></PopoverActorCard
>
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import PopoverActorCard from "./PopoverActorCard.vue";
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
import { ActorType } from "@/types/enums";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
const group = {
...baseActor,
name: "Framasoft",
preferredUsername: "framasoft",
domain: "mobilizon.fr",
avatar: {
...baseActorAvatar,
url: "https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg",
},
};
function setupApp({ app }) {
app.use(FloatingVue);
}
</script>

View file

@ -1,44 +1,38 @@
<template> <template>
<v-popover <VMenu
offset="16" :distance="16"
trigger="hover" :triggers="['hover']"
class="popover" class="popover"
:class="{ inline, clickable: actor && actor.type === ActorType.GROUP }" :class="{ inline, clickable: actor && actor.type === ActorType.GROUP }"
> >
<slot></slot> <slot></slot>
<template slot="popover"> <template #popper>
<actor-card :full="true" :actor="actor" :popover="true" /> <actor-card :full="true" :actor="actor" :popover="true" />
</template> </template>
</v-popover> </VMenu>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { ActorType } from "@/types/enums"; import { ActorType } from "@/types/enums";
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor } from "../../types/actor"; import { IActor } from "../../types/actor";
import ActorCard from "./ActorCard.vue"; import ActorCard from "./ActorCard.vue";
@Component({ withDefaults(
components: { defineProps<{
ActorCard, actor: IActor;
}, inline?: boolean;
}) }>(),
export default class PopoverActorCard extends Vue { {
@Prop({ required: true, type: Object }) actor!: IActor; inline: false,
}
@Prop({ required: false, type: Boolean, default: false }) inline!: boolean; );
ActorType = ActorType;
}
</script> </script>
<style lang="scss" scoped> <style lang="scss">
.inline { .v-popper__inner {
display: inline; padding: 0 !important;
background-color: transparent !important;
} }
.popover { .v-popper__arrow-outer {
cursor: default; border-color: $violet-1 !important;
}
.clickable {
cursor: pointer;
} }
</style> </style>

View file

@ -0,0 +1,29 @@
<template>
<Story>
<Variant>
<div class="p-5">
<ProfileOnboarding
:current-actor="baseActor"
instance-name="Instance name"
/>
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ProfileOnboarding from "./ProfileOnboarding.vue";
import { ActorType } from "@/types/enums";
import { IPerson } from "@/types/actor";
const baseActor: IPerson = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
</script>

View file

@ -1,71 +1,60 @@
<template> <template>
<div class="section container"> <div class="">
<div class="setting-title"> <h2 class="text-2xl">{{ t("Profiles and federation") }}</h2>
<h2>{{ $t("Profiles and federation") }}</h2> </div>
</div> <p class="my-2">
<div> {{
<p class="content"> t(
{{ "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
$t( )
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want." }}
) </p>
}} <hr role="presentation" />
</p> <p class="my-2">
<hr role="presentation" /> <span>
<p class="content"> {{
<span> t(
{{ "Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
$t( )
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere." }}
) </span>
}} <i18n-t
</span> keypath="This instance, {instanceName}, hosts your profile, so remember its name."
<span >
v-if="config" <template v-slot:instanceName>
v-html=" <b>{{
$t( t("{instanceName} ({domain})", {
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.', domain,
{ instanceName,
domain, })
instanceName: config.name, }}</b>
} </template>
) </i18n-t>
" </p>
/> <hr role="presentation" />
</p> <p class="my-2">
<hr role="presentation" /> {{
<p class="content"> t(
{{ "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
$t( )
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:" }}
) </p>
}} <div class="text-center">
</p> <code>{{ `${currentActor?.preferredUsername}@${domain}` }}</code>
<div class="has-text-centered">
<code>{{ `${currentActor.preferredUsername}@${domain}` }}</code>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CONFIG } from "@/graphql/config";
import { IPerson } from "@/types/actor"; import { IPerson } from "@/types/actor";
import { IConfig } from "@/types/config.model"; import { computed } from "vue";
import { Component, Vue } from "vue-property-decorator"; import { useI18n } from "vue-i18n";
@Component({ defineProps<{
apollo: { currentActor: IPerson;
config: CONFIG, instanceName: string;
currentActor: CURRENT_ACTOR_CLIENT, }>();
},
})
export default class ProfileOnboarding extends Vue {
config!: IConfig;
currentActor!: IPerson; const { t } = useI18n({ useScope: "global" });
domain = window.location.hostname; const domain = computed(() => window.location.hostname);
}
</script> </script>

View file

@ -1,118 +1,117 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="'chat'" :type="iconColor" /> <o-icon :icon="'chat'" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<router-link <template #discussion>
v-if="activity.object" <router-link
slot="discussion" v-if="activity.object"
:to="{ :to="{
name: RouteName.DISCUSSION, name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug }, params: { slug: subjectParams.discussion_slug },
}" }"
>{{ subjectParams.discussion_title }}</router-link >{{ subjectParams.discussion_title }}</router-link
> >
<b v-else slot="discussion">{{ subjectParams.discussion_title }}</b> <b v-else>{{ subjectParams.discussion_title }}</b>
<router-link </template>
v-if="activity.object && subjectParams.old_discussion_title" <template #old_discussion>
slot="old_discussion" <router-link
:to="{ v-if="activity.object && subjectParams.old_discussion_title"
name: RouteName.DISCUSSION, :to="{
params: { slug: subjectParams.discussion_slug }, name: RouteName.DISCUSSION,
}" params: { slug: subjectParams.discussion_slug },
>{{ subjectParams.old_discussion_title }}</router-link }"
> >{{ subjectParams.old_discussion_title }}</router-link
<b >
v-else-if="subjectParams.old_discussion_title" <b v-else-if="subjectParams.old_discussion_title">{{
slot="old_discussion" subjectParams.old_discussion_title
>{{ subjectParams.old_discussion_title }}</b }}</b>
> </template>
<popover-actor-card <template #profile>
:actor="activity.author" <popover-actor-card :actor="activity.author" :inline="true">
:inline="true" <b>
slot="profile" {{
> $t("{'@'}{username}", {
<b> username: usernameWithDomain(activity.author),
{{ })
$t("@{username}", { }}</b
username: usernameWithDomain(activity.author), ></popover-actor-card
}) ></template
}}</b ></i18n-t
></popover-actor-card
></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { ActivityDiscussionSubject } from "@/types/enums"; import { ActivityDiscussionSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity"; import { IActivity } from "@/types/activity.model";
import { mixins } from "vue-class-component"; import { computed } from "vue";
import { formatTimeString } from "@/filters/datetime";
import {
useActivitySubjectParams,
useIsActivityAuthorCurrentActor,
} from "@/composition/activity";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class DiscussionActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityDiscussionSubject = ActivityDiscussionSubject;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED: const subjectParams = useActivitySubjectParams()(props.activity);
if (this.isAuthorCurrentActor) {
return "You created the discussion {discussion}."; const translation = computed((): string | undefined => {
} switch (props.activity.subject) {
return "{profile} created the discussion {discussion}."; case ActivityDiscussionSubject.DISCUSSION_CREATED:
case ActivityDiscussionSubject.DISCUSSION_REPLIED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You created the discussion {discussion}.";
return "You replied to the discussion {discussion}."; }
} return "{profile} created the discussion {discussion}.";
return "{profile} replied to the discussion {discussion}."; case ActivityDiscussionSubject.DISCUSSION_REPLIED:
case ActivityDiscussionSubject.DISCUSSION_RENAMED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You replied to the discussion {discussion}.";
return "You renamed the discussion from {old_discussion} to {discussion}."; }
} return "{profile} replied to the discussion {discussion}.";
return "{profile} renamed the discussion from {old_discussion} to {discussion}."; case ActivityDiscussionSubject.DISCUSSION_RENAMED:
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You renamed the discussion from {old_discussion} to {discussion}.";
return "You archived the discussion {discussion}."; }
} return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
return "{profile} archived the discussion {discussion}."; case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
case ActivityDiscussionSubject.DISCUSSION_DELETED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You archived the discussion {discussion}.";
return "You deleted the discussion {discussion}."; }
} return "{profile} archived the discussion {discussion}.";
return "{profile} deleted the discussion {discussion}."; case ActivityDiscussionSubject.DISCUSSION_DELETED:
default: if (isAuthorCurrentActor) {
return undefined; return "You deleted the discussion {discussion}.";
} }
return "{profile} deleted the discussion {discussion}.";
default:
return undefined;
} }
});
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED: case ActivityDiscussionSubject.DISCUSSION_CREATED:
case ActivityDiscussionSubject.DISCUSSION_REPLIED: case ActivityDiscussionSubject.DISCUSSION_REPLIED:
return "is-success"; return "is-success";
case ActivityDiscussionSubject.DISCUSSION_RENAMED: case ActivityDiscussionSubject.DISCUSSION_RENAMED:
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED: case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
return "is-grey"; return "is-grey";
case ActivityDiscussionSubject.DISCUSSION_DELETED: case ActivityDiscussionSubject.DISCUSSION_DELETED:
return "is-danger"; return "is-danger";
default: default:
return undefined; return undefined;
}
} }
} });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,107 +1,107 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="'calendar'" :type="iconColor" /> <o-icon :icon="'calendar'" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<router-link <template #event>
slot="event" <router-link
v-if="activity.object" v-if="activity.object"
:to="{ :to="{
name: RouteName.EVENT, name: RouteName.EVENT,
params: { uuid: subjectParams.event_uuid }, params: { uuid: subjectParams.event_uuid },
}" }"
>{{ subjectParams.event_title }}</router-link >{{ subjectParams.event_title }}</router-link
> >
<b v-else slot="event">{{ subjectParams.event_title }}</b> <b v-else>{{ subjectParams.event_title }}</b>
<popover-actor-card </template>
:actor="activity.author" <template #profile>
:inline="true" <popover-actor-card :actor="activity.author" :inline="true">
slot="profile" <b>
> {{
<b> $t("{'@'}{username}", {
{{ username: usernameWithDomain(activity.author),
$t("@{username}", { })
username: usernameWithDomain(activity.author), }}</b
}) ></popover-actor-card
}}</b ></template
></popover-actor-card ></i18n-t
></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import {
useActivitySubjectParams,
useIsActivityAuthorCurrentActor,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { formatTimeString } from "@/filters/datetime";
import { import {
ActivityEventCommentSubject, ActivityEventCommentSubject,
ActivityEventSubject, ActivityEventSubject,
} from "@/types/enums"; } from "@/types/enums";
import { mixins } from "vue-class-component"; import { computed } from "vue";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class EventActivityItem extends mixins(ActivityMixin) {
ActivityEventSubject = ActivityEventSubject;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityEventSubject.EVENT_CREATED: const subjectParams = useActivitySubjectParams()(props.activity);
if (this.isAuthorCurrentActor) {
return "You created the event {event}."; const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
if (isAuthorCurrentActor) {
return "You created the event {event}.";
}
return "The event {event} was created by {profile}.";
case ActivityEventSubject.EVENT_UPDATED:
if (isAuthorCurrentActor) {
return "You updated the event {event}.";
}
return "The event {event} was updated by {profile}.";
case ActivityEventSubject.EVENT_DELETED:
if (isAuthorCurrentActor) {
return "You deleted the event {event}.";
}
return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED:
if (subjectParams.comment_reply_to) {
if (isAuthorCurrentActor) {
return "You replied to a comment on the event {event}.";
} }
return "The event {event} was created by {profile}."; return "{profile} replied to a comment on the event {event}.";
case ActivityEventSubject.EVENT_UPDATED: }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You updated the event {event}."; return "You posted a comment on the event {event}.";
} }
return "The event {event} was updated by {profile}."; return "{profile} posted a comment on the event {event}.";
case ActivityEventSubject.EVENT_DELETED: default:
if (this.isAuthorCurrentActor) { return undefined;
return "You deleted the event {event}.";
}
return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED:
if (this.subjectParams.comment_reply_to) {
if (this.isAuthorCurrentActor) {
return "You replied to a comment on the event {event}.";
}
return "{profile} replied to a comment on the event {event}.";
}
if (this.isAuthorCurrentActor) {
return "You posted a comment on the event {event}.";
}
return "{profile} posted a comment on the event {event}.";
default:
return undefined;
}
} }
});
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED: case ActivityEventSubject.EVENT_CREATED:
case ActivityEventCommentSubject.COMMENT_POSTED: case ActivityEventCommentSubject.COMMENT_POSTED:
return "is-success"; return "is-success";
case ActivityEventSubject.EVENT_UPDATED: case ActivityEventSubject.EVENT_UPDATED:
return "is-grey"; return "is-grey";
case ActivityEventSubject.EVENT_DELETED: case ActivityEventSubject.EVENT_DELETED:
return "is-danger"; return "is-danger";
default: default:
return undefined; return undefined;
}
} }
} });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,189 +1,176 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="'cog'" :type="iconColor" /> <o-icon :icon="'cog'" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<router-link <template #group>
v-if="activity.object" <router-link
slot="group" v-if="activity.object"
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { params: {
preferredUsername: subjectParams.group_federated_username, preferredUsername: subjectParams.group_federated_username,
}, },
}" }"
>{{ subjectParams.group_name }}</router-link >{{ subjectParams.group_name }}</router-link
> >
<b v-else slot="post">{{ subjectParams.group_name }}</b> <b v-else>{{ subjectParams.group_name }}</b>
<popover-actor-card </template>
:actor="activity.author" <template #profile>
:inline="true" <popover-actor-card :actor="activity.author" :inline="true">
slot="profile" <b>
> {{
<b> $t("{'@'}{username}", {
{{ username: usernameWithDomain(activity.author),
$t("@{username}", { })
username: usernameWithDomain(activity.author), }}</b
}) ></popover-actor-card
}}</b ></template
></popover-actor-card ></i18n-t
></i18n
> >
<i18n <i18n-t
:path="detail" :keypath="detail"
v-for="detail in details" v-for="detail in details"
:key="detail" :key="detail"
tag="p" tag="p"
class="has-text-grey-dark" class="has-text-grey-dark"
> >
<popover-actor-card <template #profile>
:actor="activity.author" <popover-actor-card :actor="activity.author" :inline="true">
:inline="true" <b>
slot="profile" {{
> $t("{'@'}{username}", {
<b> username: usernameWithDomain(activity.author),
{{ })
$t("@{username}", { }}</b
username: usernameWithDomain(activity.author), ></popover-actor-card
}) >
}}</b </template>
></popover-actor-card <template #group>
> <router-link
<router-link v-if="activity.object"
v-if="activity.object" :to="{
slot="group"
:to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(activity.object) }, params: { preferredUsername: usernameWithDomain(activity.object as IActor) },
}" }"
>{{ subjectParams.group_name }}</router-link >{{ subjectParams.group_name }}</router-link
> >
<b v-else slot="post">{{ subjectParams.group_name }}</b> <b v-else>{{ subjectParams.group_name }}</b>
<b v-if="subjectParams.old_group_name" slot="old_group_name">{{ </template>
subjectParams.old_group_name <template #old_group_name>
}}</b> <b v-if="subjectParams.old_group_name">{{
</i18n> subjectParams.old_group_name
}}</b>
</template>
</i18n-t>
<small class="has-text-grey-dark activity-date">{{ <small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor"; import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { IActor, IGroup, usernameWithDomain } from "@/types/actor";
import { ActivityGroupSubject, GroupVisibility, Openness } from "@/types/enums"; import { ActivityGroupSubject, GroupVisibility, Openness } from "@/types/enums";
import { Component } from "vue-property-decorator"; import { computed } from "vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity"; import { formatTimeString } from "@/filters/datetime";
import { mixins } from "vue-class-component";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class GroupActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityGroupSubject = ActivityGroupSubject;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
if (this.isAuthorCurrentActor) {
return "You created the group {group}.";
}
return "{profile} created the group {group}.";
case ActivityGroupSubject.GROUP_UPDATED:
if (this.isAuthorCurrentActor) {
return "You updated the group {group}.";
}
return "{profile} updated the group {group}.";
default:
return undefined;
}
}
get iconColor(): string | undefined { const subjectParams = useActivitySubjectParams()(props.activity);
switch (this.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
return "is-success";
case ActivityGroupSubject.GROUP_UPDATED:
return "is-grey";
default:
return undefined;
}
}
get details(): string[] { const translation = computed((): string | undefined => {
const details = []; switch (props.activity.subject) {
const changes = this.subjectParams.group_changes.split(","); case ActivityGroupSubject.GROUP_CREATED:
if (changes.includes("name") && this.subjectParams.old_group_name) { if (isAuthorCurrentActor) {
details.push("{old_group_name} was renamed to {group}."); return "You created the group {group}.";
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("visibility") && this.activity.object.visibility) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
switch (this.activity.object.visibility) {
case GroupVisibility.PRIVATE:
details.push("Visibility was set to private.");
break;
case GroupVisibility.PUBLIC:
details.push("Visibility was set to public.");
break;
default:
details.push("Visibility was set to an unknown value.");
break;
} }
} return "{profile} created the group {group}.";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment case ActivityGroupSubject.GROUP_UPDATED:
// @ts-ignore if (isAuthorCurrentActor) {
if (changes.includes("openness") && this.activity.object.openness) { return "You updated the group {group}.";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
switch (this.activity.object.openness) {
case Openness.INVITE_ONLY:
details.push("The group can now only be joined with an invite.");
break;
case Openness.MODERATED:
details.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator."
);
break;
case Openness.OPEN:
details.push("The group can now be joined by anyone.");
break;
default:
details.push("Unknown value for the openness setting.");
break;
} }
} return "{profile} updated the group {group}.";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment default:
// @ts-ignore return undefined;
if (changes.includes("address") && this.activity.object.physicalAddress) {
details.push("The group's physical address was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("avatar") && this.activity.object.avatar) {
details.push("The group's avatar was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("banner") && this.activity.object.banner) {
details.push("The group's banner was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("summary") && this.activity.object.summary) {
details.push("The group's short description was changed.");
}
return details;
} }
} });
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
return "is-success";
case ActivityGroupSubject.GROUP_UPDATED:
return "is-grey";
default:
return undefined;
}
});
const group = computed(() => props.activity.object as IGroup);
const details = computed((): string[] => {
const localDetails = [];
const changes = subjectParams.group_changes.split(",");
if (changes.includes("name") && subjectParams.old_group_name) {
localDetails.push("{old_group_name} was renamed to {group}.");
}
if (changes.includes("visibility") && group.value.visibility) {
switch (group.value.visibility) {
case GroupVisibility.PRIVATE:
localDetails.push("Visibility was set to private.");
break;
case GroupVisibility.PUBLIC:
localDetails.push("Visibility was set to public.");
break;
default:
localDetails.push("Visibility was set to an unknown value.");
break;
}
}
if (changes.includes("openness") && group.value.openness) {
switch (group.value.openness) {
case Openness.INVITE_ONLY:
localDetails.push("The group can now only be joined with an invite.");
break;
case Openness.MODERATED:
localDetails.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator."
);
break;
case Openness.OPEN:
localDetails.push("The group can now be joined by anyone.");
break;
default:
localDetails.push("Unknown value for the openness setting.");
break;
}
}
if (changes.includes("address") && group.value.physicalAddress) {
localDetails.push("The group's physical address was changed.");
}
if (changes.includes("avatar") && group.value.avatar) {
localDetails.push("The group's avatar was changed.");
}
if (changes.includes("banner") && group.value.banner) {
localDetails.push("The group's banner was changed.");
}
if (changes.includes("summary") && group.value.summary) {
localDetails.push("The group's short description was changed.");
}
return localDetails;
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,236 +1,232 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="icon" :type="iconColor" /> <o-icon :icon="icon" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<popover-actor-card <template #member>
v-if="activity.object" <popover-actor-card
:actor="activity.object.actor" v-if="member"
:inline="true" :actor="member.actor"
slot="member" :inline="true"
> >
<b> {{ displayName(activity.object.actor) }}</b></popover-actor-card <b> {{ displayName(member.actor) }}</b></popover-actor-card
> >
<b slot="member" v-else>{{ <b v-else>{{ subjectParams.member_actor_federated_username }}</b>
subjectParams.member_actor_federated_username </template>
}}</b> <template #profile>
<popover-actor-card <popover-actor-card :actor="activity.author" :inline="true">
:actor="activity.author" <b> {{ displayName(activity.author) }}</b></popover-actor-card
:inline="true" ></template
slot="profile" ></i18n-t
>
<b> {{ displayName(activity.author) }}</b></popover-actor-card
></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { displayName } from "@/types/actor";
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
export const MEMBER_ROLE_VALUE: Record<string, number> = { export const MEMBER_ROLE_VALUE: Record<string, number> = {
[MemberRole.MEMBER]: 20, [MemberRole.MEMBER]: 20,
[MemberRole.MODERATOR]: 50, [MemberRole.MODERATOR]: 50,
[MemberRole.ADMINISTRATOR]: 90, [MemberRole.ADMINISTRATOR]: 90,
[MemberRole.CREATOR]: 100, [MemberRole.CREATOR]: 100,
}; };
</script>
<script lang="ts" setup>
import { displayName } from "@/types/actor";
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import { formatTimeString } from "@/filters/datetime";
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
useIsActivityObjectCurrentActor,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { IMember } from "@/types/actor/member.model";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class MemberActivityItem extends mixins(ActivityMixin) {
displayName = displayName;
RouteName = RouteName;
ActivityMemberSubject = ActivityMemberSubject;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
if (this.isAuthorCurrentActor) {
return "You requested to join the group.";
}
return "{member} requested to join the group.";
case ActivityMemberSubject.MEMBER_INVITED:
if (this.isAuthorCurrentActor) {
return "You invited {member}.";
}
return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED:
if (this.isAuthorCurrentActor) {
return "You added the member {member}.";
}
return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (this.isAuthorCurrentActor) {
return "You approved {member}'s membership.";
}
if (this.isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}.";
}
return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED:
if (this.subjectParams.member_role && this.subjectParams.old_role) {
return this.roleUpdate;
}
if (this.isAuthorCurrentActor) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}.";
case ActivityMemberSubject.MEMBER_REMOVED:
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (this.isAuthorCurrentActor) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (this.isAuthorCurrentActor) {
return "You excluded member {member}.";
}
return "{profile} excluded member {member}.";
case ActivityMemberSubject.MEMBER_QUIT:
return "{profile} quit the group.";
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
return "{member} rejected the invitation to join the group.";
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
if (this.isAuthorCurrentActor) {
return "You accepted the invitation to join the group.";
}
return "{member} accepted the invitation to join the group.";
default:
return undefined;
}
}
get icon(): string { const subjectParams = useActivitySubjectParams()(props.activity);
switch (this.activity.subject) { const member = computed(() => props.activity.object as IMember);
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "account-multiple-plus";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "account-multiple-minus";
case ActivityMemberSubject.MEMBER_UPDATED:
default:
return "account-multiple";
}
}
get iconColor(): string | undefined { const isObjectMemberCurrentActor = useIsActivityObjectCurrentActor()(
switch (this.activity.subject) { props.activity
case ActivityMemberSubject.MEMBER_ADDED: );
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_JOINED:
case ActivityMemberSubject.MEMBER_APPROVED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "is-success";
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_UPDATED:
return "is-grey";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "is-danger";
default:
return undefined;
}
}
get roleUpdate(): string | undefined { const translation = computed((): string | undefined => {
if ( switch (props.activity.subject) {
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.member_role) && case ActivityMemberSubject.MEMBER_REQUEST:
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.old_role) if (isAuthorCurrentActor) {
) { return "You requested to join the group.";
if (
MEMBER_ROLE_VALUE[this.subjectParams.member_role] >
MEMBER_ROLE_VALUE[this.subjectParams.old_role]
) {
switch (this.subjectParams.member_role) {
case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) {
return "You promoted {member} to moderator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to moderator by {profile}.";
}
return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR:
if (this.isAuthorCurrentActor) {
return "You promoted {member} to administrator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to administrator by {profile}.";
}
return "{profile} promoted {member} to administrator.";
default:
if (this.isAuthorCurrentActor) {
return "You promoted the member {member} to an unknown role.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to an unknown role by {profile}.";
}
return "{profile} promoted {member} to an unknown role.";
}
} else {
switch (this.subjectParams.member_role) {
case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) {
return "You demoted {member} to moderator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to moderator by {profile}.";
}
return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER:
if (this.isAuthorCurrentActor) {
return "You demoted {member} to simple member.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to simple member by {profile}.";
}
return "{profile} demoted {member} to simple member.";
default:
if (this.isAuthorCurrentActor) {
return "You demoted the member {member} to an unknown role.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to an unknown role by {profile}.";
}
return "{profile} demoted {member} to an unknown role.";
}
} }
} else { return "{member} requested to join the group.";
if (this.isAuthorCurrentActor) { case ActivityMemberSubject.MEMBER_INVITED:
if (isAuthorCurrentActor) {
return "You invited {member}.";
}
return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED:
if (isAuthorCurrentActor) {
return "You added the member {member}.";
}
return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (isAuthorCurrentActor) {
return "You approved {member}'s membership.";
}
if (isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}.";
}
return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED:
if (subjectParams.member_role && subjectParams.old_role) {
return roleUpdate.value;
}
if (isAuthorCurrentActor) {
return "You updated the member {member}."; return "You updated the member {member}.";
} }
return "{profile} updated the member {member}"; return "{profile} updated the member {member}.";
} case ActivityMemberSubject.MEMBER_REMOVED:
if (subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (isAuthorCurrentActor) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (isAuthorCurrentActor) {
return "You excluded member {member}.";
}
return "{profile} excluded member {member}.";
case ActivityMemberSubject.MEMBER_QUIT:
return "{profile} quit the group.";
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
return "{member} rejected the invitation to join the group.";
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
if (isAuthorCurrentActor) {
return "You accepted the invitation to join the group.";
}
return "{member} accepted the invitation to join the group.";
default:
return undefined;
} }
});
get isObjectMemberCurrentActor(): boolean { const icon = computed((): string => {
return ( switch (props.activity.subject) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment case ActivityMemberSubject.MEMBER_REQUEST:
// @ts-ignore case ActivityMemberSubject.MEMBER_ADDED:
this.activity?.object?.actor?.id === this.currentActor?.id && case ActivityMemberSubject.MEMBER_INVITED:
this.currentActor?.id !== undefined case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
); return "account-multiple-plus";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "account-multiple-minus";
case ActivityMemberSubject.MEMBER_UPDATED:
default:
return "account-multiple";
} }
} });
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_JOINED:
case ActivityMemberSubject.MEMBER_APPROVED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "is-success";
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_UPDATED:
return "is-grey";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "is-danger";
default:
return undefined;
}
});
const roleUpdate = computed((): string | undefined => {
if (
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.member_role) &&
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.old_role)
) {
if (
MEMBER_ROLE_VALUE[subjectParams.member_role] >
MEMBER_ROLE_VALUE[subjectParams.old_role]
) {
switch (subjectParams.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor) {
return "You promoted {member} to moderator.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to moderator by {profile}.";
}
return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR:
if (isAuthorCurrentActor) {
return "You promoted {member} to administrator.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to administrator by {profile}.";
}
return "{profile} promoted {member} to administrator.";
default:
if (isAuthorCurrentActor) {
return "You promoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to an unknown role by {profile}.";
}
return "{profile} promoted {member} to an unknown role.";
}
} else {
switch (subjectParams.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor) {
return "You demoted {member} to moderator.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to moderator by {profile}.";
}
return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER:
if (isAuthorCurrentActor) {
return "You demoted {member} to simple member.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to simple member by {profile}.";
}
return "{profile} demoted {member} to simple member.";
default:
if (isAuthorCurrentActor) {
return "You demoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to an unknown role by {profile}.";
}
return "{profile} demoted {member} to an unknown role.";
}
}
} else {
if (isAuthorCurrentActor) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}";
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,92 +1,92 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="'bullhorn'" :type="iconColor" /> <o-icon :icon="'bullhorn'" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<router-link <template #post>
v-if="activity.object" <router-link
slot="post" v-if="activity.object"
:to="{ :to="{
name: RouteName.POST, name: RouteName.POST,
params: { slug: subjectParams.post_slug }, params: { slug: subjectParams.post_slug },
}" }"
>{{ subjectParams.post_title }}</router-link >{{ subjectParams.post_title }}</router-link
> >
<b v-else slot="post">{{ subjectParams.post_title }}</b> <b v-else>{{ subjectParams.post_title }}</b>
<popover-actor-card </template>
:actor="activity.author" <template #profile>
:inline="true" <popover-actor-card :actor="activity.author" :inline="true">
slot="profile" <b>
> {{
<b> $t("{'@'}{username}", {
{{ username: usernameWithDomain(activity.author),
$t("@{username}", { })
username: usernameWithDomain(activity.author), }}</b
}) ></popover-actor-card
}}</b ></template
></popover-actor-card ></i18n-t
></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { ActivityPostSubject } from "@/types/enums"; import { ActivityPostSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity"; import { formatTimeString } from "@/filters/datetime";
import { mixins } from "vue-class-component"; import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class PostActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityPostSubject = ActivityPostSubject;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityPostSubject.POST_CREATED: const subjectParams = useActivitySubjectParams()(props.activity);
if (this.isAuthorCurrentActor) {
return "You created the post {post}."; const translation = computed((): string | undefined => {
} switch (props.activity.subject) {
return "The post {post} was created by {profile}."; case ActivityPostSubject.POST_CREATED:
case ActivityPostSubject.POST_UPDATED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You created the post {post}.";
return "You updated the post {post}."; }
} return "The post {post} was created by {profile}.";
return "The post {post} was updated by {profile}."; case ActivityPostSubject.POST_UPDATED:
case ActivityPostSubject.POST_DELETED: if (isAuthorCurrentActor) {
if (this.isAuthorCurrentActor) { return "You updated the post {post}.";
return "You deleted the post {post}."; }
} return "The post {post} was updated by {profile}.";
return "The post {post} was deleted by {profile}."; case ActivityPostSubject.POST_DELETED:
default: if (isAuthorCurrentActor) {
return undefined; return "You deleted the post {post}.";
} }
return "The post {post} was deleted by {profile}.";
default:
return undefined;
} }
});
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED: case ActivityPostSubject.POST_CREATED:
return "is-success"; return "is-success";
case ActivityPostSubject.POST_UPDATED: case ActivityPostSubject.POST_UPDATED:
return "is-grey"; return "is-grey";
case ActivityPostSubject.POST_DELETED: case ActivityPostSubject.POST_DELETED:
return "is-danger"; return "is-danger";
default: default:
return undefined; return undefined;
}
} }
} });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,189 +1,193 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<b-icon :icon="'link'" :type="iconColor" /> <o-icon :icon="'link'" :type="iconColor" />
<div class="subject"> <div class="subject">
<i18n :path="translation" tag="p"> <i18n-t :keypath="translation" tag="p">
<router-link v-if="activity.object" slot="resource" :to="path">{{ <template #resource>
subjectParams.resource_title <router-link v-if="activity.object" :to="path">{{
}}</router-link> subjectParams.resource_title
<b v-else slot="resource">{{ subjectParams.resource_title }}</b> }}</router-link>
<router-link v-if="activity.object" slot="new_path" :to="path">{{ <b v-else>{{ subjectParams.resource_title }}</b>
parentDirectory </template>
}}</router-link> <template #new_path>
<b v-else slot="new_path">{{ parentDirectory }}</b> <router-link v-if="activity.object" :to="path">{{
<router-link parentDirectory
v-if="activity.object && subjectParams.old_resource_title" }}</router-link>
slot="old_resource_title" <b v-else>{{ parentDirectory }}</b>
:to="path" </template>
>{{ subjectParams.old_resource_title }}</router-link <template #old_resource_title>
> <router-link
<b v-if="activity.object && subjectParams.old_resource_title"
v-else-if="subjectParams.old_resource_title" :to="path"
slot="old_resource_title" >{{ subjectParams.old_resource_title }}</router-link
>{{ subjectParams.old_resource_title }}</b >
> <b v-else-if="subjectParams.old_resource_title">{{
subjectParams.old_resource_title
}}</b>
</template>
<popover-actor-card <template #profile>
:actor="activity.author" <popover-actor-card :actor="activity.author" :inline="true">
:inline="true" <b>
slot="profile" {{
> $t("{'@'}{username}", {
<b> username: usernameWithDomain(activity.author),
{{ })
$t("@{username}", { }}</b
username: usernameWithDomain(activity.author), ></popover-actor-card
}) ></template
}}</b ></i18n-t
></popover-actor-card
></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="activity-date">{{
activity.insertedAt | formatTimeString formatTimeString(activity.insertedAt)
}}</small> }}</small>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { ActivityResourceSubject } from "@/types/enums"; import { ActivityResourceSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity"; import { formatTimeString } from "@/filters/datetime";
import { mixins } from "vue-class-component"; import {
import { Location } from "vue-router"; useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { IResource } from "@/types/resource";
@Component({ const props = defineProps<{
components: { activity: IActivity;
PopoverActorCard, }>();
},
})
export default class ResourceActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
get translation(): string | undefined { const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
switch (this.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED: const subjectParams = useActivitySubjectParams()(props.activity);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore const resource = computed(() => props.activity.object as IResource);
if (this.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) { const translation = computed((): string | undefined => {
return "You created the folder {resource}."; switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You created the folder {resource}.";
}
return "{profile} created the folder {resource}.";
}
if (isAuthorCurrentActor) {
return "You created the resource {resource}.";
}
return "{profile} created the resource {resource}.";
case ActivityResourceSubject.RESOURCE_MOVED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (parentDirectory.value === null) {
if (isAuthorCurrentActor) {
return "You moved the folder {resource} to the root folder.";
} }
return "{profile} created the folder {resource}."; return "{profile} moved the folder {resource} to the root folder.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the resource {resource}."; return "You moved the folder {resource} into {new_path}.";
} }
return "{profile} created the resource {resource}."; return "{profile} moved the folder {resource} into {new_path}.";
case ActivityResourceSubject.RESOURCE_MOVED: }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment if (parentDirectory.value === null) {
// @ts-ignore if (isAuthorCurrentActor) {
if (this.activity?.object?.type === "folder") { return "You moved the resource {resource} to the root folder.";
if (this.parentDirectory === null) {
if (this.isAuthorCurrentActor) {
return "You moved the folder {resource} to the root folder.";
}
return "{profile} moved the folder {resource} to the root folder.";
}
if (this.isAuthorCurrentActor) {
return "You moved the folder {resource} into {new_path}.";
}
return "{profile} moved the folder {resource} into {new_path}.";
} }
if (this.parentDirectory === null) { return "{profile} moved the resource {resource} to the root folder.";
if (this.isAuthorCurrentActor) { }
return "You moved the resource {resource} to the root folder."; if (isAuthorCurrentActor) {
} return "You moved the resource {resource} into {new_path}.";
return "{profile} moved the resource {resource} to the root folder."; }
return "{profile} moved the resource {resource} into {new_path}.";
case ActivityResourceSubject.RESOURCE_UPDATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You renamed the folder from {old_resource_title} to {resource}.";
} }
if (this.isAuthorCurrentActor) { return "{profile} renamed the folder from {old_resource_title} to {resource}.";
return "You moved the resource {resource} into {new_path}."; }
if (isAuthorCurrentActor) {
return "You renamed the resource from {old_resource_title} to {resource}.";
}
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
case ActivityResourceSubject.RESOURCE_DELETED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You deleted the folder {resource}.";
} }
return "{profile} moved the resource {resource} into {new_path}."; return "{profile} deleted the folder {resource}.";
case ActivityResourceSubject.RESOURCE_UPDATED: }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment if (isAuthorCurrentActor) {
// @ts-ignore return "You deleted the resource {resource}.";
if (this.activity?.object?.type === "folder") { }
if (this.isAuthorCurrentActor) { return "{profile} deleted the resource {resource}.";
return "You renamed the folder from {old_resource_title} to {resource}."; default:
} return undefined;
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
}
if (this.isAuthorCurrentActor) {
return "You renamed the resource from {old_resource_title} to {resource}.";
}
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
case ActivityResourceSubject.RESOURCE_DELETED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (this.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) {
return "You deleted the folder {resource}.";
}
return "{profile} deleted the folder {resource}.";
}
if (this.isAuthorCurrentActor) {
return "You deleted the resource {resource}.";
}
return "{profile} deleted the resource {resource}.";
default:
return undefined;
}
} }
});
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED: case ActivityResourceSubject.RESOURCE_CREATED:
return "is-success"; return "is-success";
case ActivityResourceSubject.RESOURCE_MOVED: case ActivityResourceSubject.RESOURCE_MOVED:
case ActivityResourceSubject.RESOURCE_UPDATED: case ActivityResourceSubject.RESOURCE_UPDATED:
return "is-grey"; return "is-grey";
case ActivityResourceSubject.RESOURCE_DELETED: case ActivityResourceSubject.RESOURCE_DELETED:
return "is-danger"; return "is-danger";
default: default:
return undefined; return undefined;
}
} }
});
get path(): Location { const path = computed(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment const localPath = parentPath(resource.value?.path);
// @ts-ignore if (localPath === "") {
const path = this.parentPath(this.activity?.object?.path);
if (path === "") {
return {
name: RouteName.RESOURCE_FOLDER_ROOT,
params: {
preferredUsername: usernameWithDomain(this.activity.group),
},
};
}
return { return {
name: RouteName.RESOURCE_FOLDER, name: RouteName.RESOURCE_FOLDER_ROOT,
params: { params: {
path, preferredUsername: usernameWithDomain(props.activity.group),
preferredUsername: usernameWithDomain(this.activity.group),
}, },
}; };
} }
return {
name: RouteName.RESOURCE_FOLDER,
params: {
path: localPath,
preferredUsername: usernameWithDomain(props.activity.group),
},
};
});
get parentDirectory(): string | undefined | null { const parentPath = (parent: string | undefined): string | undefined => {
if (this.subjectParams.resource_path) { if (!parent) return undefined;
const parentPath = this.parentPath(this.subjectParams.resource_path); const localPath = parent.split("/");
const directory = parentPath.split("/"); localPath.pop();
const res = directory.pop(); return localPath.join("/").replace(/^\//, "");
res === "" ? null : res; };
}
return null;
}
parentPath(parent: string): string { const parentDirectory = computed((): string | undefined | null => {
const path = parent.split("/"); if (subjectParams.resource_path) {
path.pop(); const parentPathResult = parentPath(subjectParams.resource_path);
return path.join("/").replace(/^\//, ""); const directory = parentPathResult?.split("/");
const res = directory?.pop();
res === "" ? null : res;
} }
} return null;
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View file

@ -1,13 +1,13 @@
<template> <template>
<div class="activity-item"> <div class="activity-item">
<span> <span>
<b-skeleton circle width="32px" height="32px"></b-skeleton> <o-skeleton circle width="32px" height="32px"></o-skeleton>
</span> </span>
<div class="subject"> <div class="subject">
<div class="content"> <div class="prose dark:prose-invert">
<p> <p>
<b-skeleton active></b-skeleton> <o-skeleton active></o-skeleton>
<b-skeleton active class="datetime"></b-skeleton> <o-skeleton active class="datetime"></o-skeleton>
</p> </p>
</div> </div>
</div> </div>

View file

@ -5,7 +5,6 @@
height: 2em; height: 2em;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
background: #fff;
border: 2px solid; border: 2px solid;
z-index: 2; z-index: 2;
flex-shrink: 0; flex-shrink: 0;

View file

@ -0,0 +1,31 @@
<template>
<Story>
<Variant title="Basic">
<AddressInfo :address="address" />
</Variant>
<Variant title="Basic with timezone">
<AddressInfo
:address="address"
:show-timezone="true"
:user-timezone="'Europe/Berlin'"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { reactive } from "vue";
import AddressInfo from "./AddressInfo.vue";
const address = reactive<IAddress>({
description: "Locaux Motiv",
street: "10 Rue Jangot",
locality: "Lyon",
postalCode: "69007",
region: "Auvergne Rhône-Alpes",
country: "France",
type: "",
timezone: "Europe/Dublin",
});
</script>

View file

@ -1,22 +1,22 @@
<template> <template>
<address dir="auto"> <address dir="auto">
<b-icon <o-icon
v-if="showIcon" v-if="showIcon"
:icon="address.poiInfos.poiIcon.icon" :icon="poiInfos?.poiIcon.icon"
size="is-medium" size="is-medium"
class="icon" class="icon"
/> />
<p> <p>
<span <span
class="addressDescription" class="addressDescription"
:title="address.poiInfos.name" :title="poiInfos.name"
v-if="address.poiInfos.name" v-if="poiInfos?.name"
> >
{{ address.poiInfos.name }} {{ poiInfos.name }}
</span> </span>
<br v-if="address.poiInfos.name" /> <br v-if="poiInfos?.name" />
<span class="has-text-grey-dark"> <span>
{{ address.poiInfos.alternativeName }} {{ poiInfos?.alternativeName }}
</span> </span>
<br /> <br />
<small <small
@ -25,7 +25,6 @@
longShortTimezoneNamesDifferent && longShortTimezoneNamesDifferent &&
timezoneLongNameValid timezoneLongNameValid
" "
class="has-text-grey-dark"
> >
🌐 🌐
{{ {{
@ -35,72 +34,75 @@
}) })
}} }}
</small> </small>
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark"> <small v-else-if="userTimezoneDifferent" class="">
🌐 {{ timezoneShortName }} 🌐 {{ timezoneShortName }}
</small> </small>
</p> </p>
</address> </address>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { IAddress } from "@/types/address.model"; import { addressToPoiInfos, IAddress } from "@/types/address.model";
import { PropType } from "vue"; import { computed } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component const props = withDefaults(
export default class AddressInfo extends Vue { defineProps<{
@Prop({ required: true, type: Object as PropType<IAddress> }) address: IAddress;
address!: IAddress; showIcon?: boolean;
showTimezone?: boolean;
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean; userTimezone?: string;
@Prop({ required: false, default: false, type: Boolean }) }>(),
showTimezone!: boolean; {
@Prop({ required: false, type: String }) userTimezone!: string; showIcon: false,
showTimezone: false,
get userTimezoneDifferent(): boolean {
return (
this.userTimezone != undefined &&
this.address.timezone != undefined &&
this.userTimezone !== this.address.timezone
);
} }
);
get longShortTimezoneNamesDifferent(): boolean { const poiInfos = computed(() => addressToPoiInfos(props.address));
return (
this.timezoneLongName != undefined &&
this.timezoneShortName != undefined &&
this.timezoneLongName !== this.timezoneShortName
);
}
get timezoneLongName(): string | undefined { const userTimezoneDifferent = computed((): boolean => {
return this.timezoneName("long"); return (
} props.userTimezone != undefined &&
props.address.timezone != undefined &&
props.userTimezone !== props.address.timezone
);
});
get timezoneShortName(): string | undefined { const longShortTimezoneNamesDifferent = computed((): boolean => {
return this.timezoneName("short"); return (
} timezoneLongName.value != undefined &&
timezoneShortName.value != undefined &&
timezoneLongName.value !== timezoneShortName.value
);
});
get timezoneLongNameValid(): boolean { const timezoneLongName = computed((): string | undefined => {
return ( return timezoneName("long");
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/) });
);
}
private timezoneName(format: "long" | "short"): string | undefined { const timezoneShortName = computed((): string | undefined => {
return this.extractTimezone( return timezoneName("short");
new Intl.DateTimeFormat(undefined, { });
timeZoneName: format,
timeZone: this.address.timezone,
}).formatToParts()
);
}
private extractTimezone( const timezoneLongNameValid = computed((): boolean => {
parts: Intl.DateTimeFormatPart[] return (
): string | undefined { timezoneLongName.value != undefined && !timezoneLongName.value.match(/UTC/)
return parts.find((part) => part.type === "timeZoneName")?.value; );
} });
}
const timezoneName = (format: "long" | "short"): string | undefined => {
return extractTimezone(
new Intl.DateTimeFormat(undefined, {
timeZoneName: format,
timeZone: props.address.timezone,
}).formatToParts()
);
};
const extractTimezone = (
parts: Intl.DateTimeFormatPart[]
): string | undefined => {
return parts.find((part) => part.type === "timeZoneName")?.value;
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;

View file

@ -0,0 +1,27 @@
<template>
<Story>
<Variant title="with locality">
<InlineAddress :physicalAddress="address" />
</Variant>
<Variant title="without locality">
<InlineAddress :physicalAddress="{ ...address, locality: null }" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { reactive } from "vue";
import InlineAddress from "./InlineAddress.vue";
const address = reactive<IAddress>({
description: "Locaux Motiv",
street: "10 Rue Jangot",
locality: "Lyon",
postalCode: "69007",
region: "Auvergne Rhône-Alpes",
country: "France",
type: "",
timezone: "Europe/Dublin",
});
</script>

View file

@ -1,13 +1,14 @@
<template> <template>
<div <div
class="truncate" class="truncate flex gap-1"
dir="auto"
:title=" :title="
isDescriptionDifferentFromLocality isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}` ? `${physicalAddress.description}, ${physicalAddress.locality}`
: physicalAddress.description : physicalAddress.description
" "
> >
<b-icon icon="map-marker" /> <MapMarker />
<span v-if="physicalAddress.locality"> <span v-if="physicalAddress.locality">
{{ physicalAddress.locality }} {{ physicalAddress.locality }}
</span> </span>
@ -16,21 +17,19 @@
</span> </span>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { IAddress } from "@/types/address.model"; import { IAddress } from "@/types/address.model";
import { PropType } from "vue"; import MapMarker from "vue-material-design-icons/MapMarker.vue";
import { Prop, Vue, Component } from "vue-property-decorator"; import { computed } from "vue";
@Component const props = defineProps<{
export default class InlineAddress extends Vue { physicalAddress: IAddress;
@Prop({ required: true, type: Object as PropType<IAddress> }) }>();
physicalAddress!: IAddress;
get isDescriptionDifferentFromLocality(): boolean { const isDescriptionDifferentFromLocality = computed<boolean>(() => {
return ( return (
this.physicalAddress?.description !== this.physicalAddress?.locality && props.physicalAddress?.description !== props.physicalAddress?.locality &&
this.physicalAddress?.description !== undefined props.physicalAddress?.description !== undefined
); );
} });
}
</script> </script>

View file

@ -0,0 +1,29 @@
<template>
<Story>
<Variant title="Basic">
<section class="flex flex-wrap gap-3 md:gap-5">
<CategoryCard :category="category" />
</section>
</Variant>
<Variant title="Details">
<section class="flex flex-wrap gap-3 md:gap-5">
<CategoryCard
:category="{ ...category, key: 'OUTDOORS_ADVENTURE' }"
:with-details="true"
/>
</section>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { CategoryStatsModel } from "@/types/stats.model";
import { reactive } from "vue";
import CategoryCard from "./CategoryCard.vue";
const category = reactive<CategoryStatsModel>({
key: "PHOTOGRAPHY",
number: 5,
label: "Hello",
});
</script>

View file

@ -0,0 +1,78 @@
<template>
<router-link
:to="{
name: 'SEARCH',
query: {
eventCategory: category.key,
contentType: 'EVENTS',
radius: undefined,
},
}"
class="max-w-xs rounded-lg overflow-hidden bg-center bg-no-repeat bg-cover shadow-lg relative group"
>
<picture
v-if="categoriesWithPictures.includes(category.key)"
class="brightness-50"
>
<source
:srcset="`/img/categories/${category.key.toLowerCase()}.jpg 2x, /img/categories/${category.key.toLowerCase()}.jpg`"
media="(min-width: 1000px)"
/>
<source
:srcset="`/img/categories/${category.key.toLowerCase()}.jpg 2x, /img/categories/${category.key.toLowerCase()}-small.jpg`"
media="(min-width: 300px)"
/>
<img
class="w-full h-36 w-36 md:h-52 md:w-52 object-cover"
:src="`/img/categories/${category.key.toLowerCase()}.jpg`"
:srcset="`/img/categories/${category.key.toLowerCase()}-small.jpg `"
alt=""
/>
</picture>
<p
v-else
class="h-36 w-36 md:h-52 md:w-52 brightness-75"
:class="randomGradient()"
/>
<div class="px-3 py-1 absolute left-0 bottom-0">
<h2
class="group-hover:text-slate-200 font-semibold text-white tracking-tight text-xl mb-3"
>
{{ category.label }}
</h2>
</div>
<span
v-if="withDetails"
class="absolute z-10 inline-flex items-center px-3 py-1 text-xs font-semibold text-white bg-black rounded-full right-2 top-2"
>
{{
t(
"{count} events",
{
count: category.number.toString(),
},
category.number
)
}}
</span>
</router-link>
</template>
<script lang="ts" setup>
import { categoriesWithPictures } from "./constants";
import { randomGradient } from "../../utils/graphics";
import { CategoryStatsModel } from "../../types/stats.model";
import { useI18n } from "vue-i18n";
withDefaults(
defineProps<{
category: CategoryStatsModel;
withDetails?: boolean;
}>(),
{
withDetails: false,
}
);
const { t } = useI18n({ useScope: "global" });
</script>

View file

@ -0,0 +1,296 @@
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;
source: CategoryPictureLicencingElement;
license?: CategoryPictureLicencingElement;
};
export const categoriesPicturesLicences: Record<
string,
CategoryPictureLicencing
> = {
THEATRE: {
author: {
name: "David Joyce",
url: "https://www.flickr.com/photos/deapeajay/",
},
source: {
name: "Flickr",
url: "https://www.flickr.com/photos/30815420@N00/2213310171",
},
license: {
name: "CC BY-SA",
url: "https://creativecommons.org/licenses/by-sa/2.0/",
},
},
SPORTS: {
author: {
name: "Md Mahdi",
url: "https://unsplash.com/@mahdi17",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/lQpFRPrepQ8",
},
},
MUSIC: {
author: {
name: "Michael Starkie",
url: "https://unsplash.com/@starkie_pics",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/YjtevpXFHQY",
},
},
ARTS: {
author: {
name: "RhondaK Native Florida Folk Artist",
url: "https://unsplash.com/@rhondak",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_Yc7OtfFn-0",
},
},
SPIRITUALITY_RELIGION_BELIEFS: {
author: {
name: "The Dancing Rain",
url: "https://unsplash.com/@thedancingrain",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_KPuV9qSSlU",
},
},
MOVEMENTS_POLITICS: {
author: {
name: "Kyle Fiori",
url: "https://unsplash.com/@navy99",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/moytQ7vzhAM",
},
},
PARTY: {
author: {
name: "Amy Shamblen",
url: "https://unsplash.com/@amyshamblen",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/pJ_DCj9KswI",
},
},
BUSINESS: {
author: {
name: "Simone Hutsch",
url: "https://unsplash.com/@heysupersimi",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/6-c8GV2MBmg",
},
},
FILM_MEDIA: {
author: {
name: "Dan Senior",
url: "https://unsplash.com/@dansenior",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/ENtn4fH8C3g",
},
},
PHOTOGRAPHY: {
author: {
name: "Nathyn Masters",
url: "https://unsplash.com/@nathynmasters",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/k3oSs0hWOPo",
},
},
HEALTH: {
author: {
name: "Derek Finch",
url: "https://unsplash.com/@drugwatcher",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/Gi8Q8IfpxdY",
},
},
GAMES: {
author: {
name: "Randy Fath",
url: "https://unsplash.com/@randyfath",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_EoxKxrDL20",
},
},
OUTDOORS_ADVENTURE: {
author: {
name: "Davide Sacchet",
url: "https://unsplash.com/@davide_sak_",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/RYN-kov1lTY",
},
},
FOOD_DRINK: {
author: {
name: "sina piryae",
url: "https://unsplash.com/@sinapiryae",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/bBzjWthTqb8",
},
},
CRAFTS: {
author: {
name: "rocknwool",
url: "https://unsplash.com/@rocknwool",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/Jcb5O26G08A",
},
},
LGBTQ: {
author: {
name: "analuisa gamboa",
url: "https://unsplash.com/@anigmb",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/HsraPtCtRCM",
},
},
};
export const categoriesWithPictures = [
"SPORTS",
"THEATRE",
"MUSIC",
"ARTS",
"MOVEMENTS_POLITICS",
"SPIRITUALITY_RELIGION_BELIEFS",
"PARTY",
"BUSINESS",
"FILM_MEDIA",
"PHOTOGRAPHY",
"HEALTH",
"GAMES",
"OUTDOORS_ADVENTURE",
"FOOD_DRINK",
"CRAFTS",
"LGBTQ",
];

View file

@ -0,0 +1,177 @@
<template>
<Story :setup-app="setupApp">
<Variant title="Basic">
<Comment
:comment="comment"
:event="event"
:currentActor="baseActor"
@create-comment="hstEvent('Create comment', $event)"
@delete-comment="hstEvent('Delete comment', $event)"
@report-comment="hstEvent('Report comment', $event)"
/>
</Variant>
<Variant title="Announcement">
<Comment
:comment="{ ...comment, isAnnouncement: true }"
:event="event"
:currentActor="baseActor"
@create-comment="hstEvent('Create comment', $event)"
@delete-comment="hstEvent('Delete comment', $event)"
@report-comment="hstEvent('Report comment', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import { IComment } from "@/types/comment.model";
import {
ActorType,
CommentModeration,
EventJoinOptions,
EventStatus,
EventVisibility,
} from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { reactive } from "vue";
import Comment from "./Comment.vue";
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
import { hstEvent } from "histoire/client";
function setupApp({ app }) {
app.use(FloatingVue);
}
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
id: "598",
};
const baseEvent: IEvent = {
uuid: "",
title: "A very interesting event",
description: "Things happen",
beginsOn: new Date(),
endsOn: new Date(),
physicalAddress: {
description: "Somewhere",
street: "",
locality: "",
region: "",
country: "",
type: "",
postalCode: "",
},
picture: {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
},
url: "",
local: true,
slug: "",
publishAt: new Date(),
status: EventStatus.CONFIRMED,
visibility: EventVisibility.PUBLIC,
joinOptions: EventJoinOptions.FREE,
draft: false,
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 0,
moderator: 0,
administrator: 0,
going: 0,
},
participants: { total: 0, elements: [] },
relatedEvents: [],
tags: [{ slug: "something", title: "Something" }],
attributedTo: undefined,
organizerActor: {
...baseActor,
name: "Hello",
avatar: {
...baseActorAvatar,
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
},
},
comments: [],
options: {
maximumAttendeeCapacity: 0,
remainingAttendeeCapacity: 0,
showRemainingAttendeeCapacity: false,
anonymousParticipation: false,
hideOrganizerWhenGroupEvent: false,
offers: [],
participationConditions: [],
attendees: [],
program: "",
commentModeration: CommentModeration.ALLOW_ALL,
showParticipationPrice: false,
showStartTime: false,
showEndTime: false,
timezone: null,
isOnline: false,
},
metadata: [],
contacts: [],
language: "en",
category: "hello",
};
const event = reactive<IEvent>(baseEvent);
const comment = reactive<IComment>({
text: "hello",
local: true,
actor: baseActor,
totalReplies: 5,
replies: [
{
text: "a reply!",
id: "90",
actor: baseActor,
updatedAt: new Date(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
{
text: "a reply to another reply!",
id: "92",
actor: baseActor,
updatedAt: new Date(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
],
isAnnouncement: false,
updatedAt: new Date(),
url: "http://somewhere.tld",
});
</script>

View file

@ -2,347 +2,348 @@
<li <li
:class="{ :class="{
reply: comment.inReplyToComment, reply: comment.inReplyToComment,
announcement: comment.isAnnouncement, 'bg-purple-2': comment.isAnnouncement,
selected: commentSelected, 'bg-violet-1': commentSelected,
'shadow-none': !rootComment,
}" }"
class="comment-element" class="mbz-card p-2"
> >
<article class="media" :id="commentId" dir="auto"> <article :id="commentId" dir="auto">
<popover-actor-card <div>
:actor="comment.actor" <div class="flex items-center gap-2">
:inline="true" <div class="flex items-center gap-1" v-if="actorComment">
v-if="comment.actor" <popover-actor-card
> :actor="actorComment"
<figure :inline="true"
class="image is-32x32 media-left" v-if="!comment.deletedAt && actorComment.avatar"
v-if="!comment.deletedAt && comment.actor.avatar" >
> <figure>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> <img
</figure> class="rounded-xl"
<b-icon class="media-left" v-else icon="account-circle" /> :src="actorComment.avatar.url"
</popover-actor-card> alt=""
<div v-else class="media-left"> width="24"
<figure height="24"
class="image is-32x32" />
v-if="!comment.deletedAt && comment.actor.avatar" </figure>
> </popover-actor-card>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> <AccountCircle v-else />
</figure> <strong
<b-icon v-else icon="account-circle" /> v-if="!comment.deletedAt"
</div> dir="auto"
<div class="media-content"> :class="{ organizer: commentFromOrganizer }"
<div class="content"> >{{ actorComment?.name }}</strong
<span class="first-line" v-if="!comment.deletedAt" dir="auto"> >
<strong :class="{ organizer: commentFromOrganizer }">{{ </div>
comment.actor.name
}}</strong> <a v-else :href="commentURL">
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small> <span>{{ t("[deleted]") }}</span>
</span>
<a v-else class="comment-link" :href="commentURL">
<span>{{ $t("[deleted]") }}</span>
</a> </a>
<a class="comment-link" :href="commentURL"> <a :href="commentURL">
<small>{{ <small v-if="comment.updatedAt">{{
formatDistanceToNow(new Date(comment.updatedAt), { formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale, locale: dateFnsLocale,
addSuffix: true, addSuffix: true,
}) })
}}</small> }}</small>
</a> </a>
<span class="icons" v-if="!comment.deletedAt"> <div v-if="!comment.deletedAt" class="flex">
<button <button
v-if="comment.actor.id === currentActor.id" v-if="actorComment?.id === currentActor?.id"
@click="deleteComment" @click="deleteComment"
> >
<b-icon icon="delete" size="is-small" aria-hidden="true" /> <Delete :size="16" />
<span class="visually-hidden">{{ $t("Delete") }}</span> <span class="sr-only">{{ t("Delete") }}</span>
</button> </button>
<button @click="reportModal()"> <button @click="reportModal">
<b-icon icon="alert" size="is-small" /> <Alert :size="16" />
<span class="visually-hidden">{{ $t("Report") }}</span> <span class="sr-only">{{ t("Report") }}</span>
</button> </button>
</span>
<br />
<div
v-if="!comment.deletedAt"
v-html="comment.text"
dir="auto"
:lang="comment.language"
/>
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
<div class="load-replies" v-if="comment.totalReplies">
<p v-if="!showReplies" @click="fetchReplies">
<b-icon icon="chevron-down" class="reply-btn" />
<span class="reply-btn">{{
$tc("View a reply", comment.totalReplies, {
totalReplies: comment.totalReplies,
})
}}</span>
</p>
<p
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
>
<b-icon icon="chevron-up" class="reply-btn" />
<span class="reply-btn">{{ $t("Hide replies") }}</span>
</p>
</div> </div>
</div> </div>
<div
v-if="!comment.deletedAt"
v-html="comment.text"
dir="auto"
:lang="comment.language"
/>
<div v-else>{{ t("[This comment has been deleted]") }}</div>
<div class="" v-if="comment.totalReplies">
<p
v-if="!showReplies"
@click="showReplies = true"
class="flex cursor-pointer"
>
<ChevronDown />
<span>{{
t(
"View a reply",
{
totalReplies: comment.totalReplies,
},
comment.totalReplies
)
}}</span>
</p>
<p
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
class="flex cursor-pointer"
>
<ChevronUp />
<span>{{ t("Hide replies") }}</span>
</p>
</div>
<nav <nav
class="reply-action level is-mobile"
v-if=" v-if="
currentActor.id && currentActor?.id &&
event.options.commentModeration !== CommentModeration.CLOSED && event.options.commentModeration !== CommentModeration.CLOSED &&
!comment.deletedAt !comment.deletedAt
" "
@click="createReplyToComment()"
class="flex gap-1 cursor-pointer"
> >
<div class="level-left"> <Reply />
<span <span>{{ t("Reply") }}</span>
style="cursor: pointer"
class="level-item reply-btn"
@click="createReplyToComment()"
>
<span class="icon is-small">
<b-icon icon="reply" />
</span>
<span>{{ $t("Reply") }}</span>
</span>
</div>
</nav> </nav>
</div> </div>
</article> </article>
<form <form
class="reply"
@submit.prevent="replyToComment" @submit.prevent="replyToComment"
v-if="currentActor.id" v-if="currentActor?.id"
v-show="replyTo" v-show="replyTo"
> >
<article class="media reply"> <article class="flex gap-2">
<figure class="media-left" v-if="currentActor.avatar"> <figure v-if="currentActor?.avatar" class="mt-4">
<p class="image is-48x48"> <img
<img :src="currentActor.avatar.url" alt="" /> :src="currentActor?.avatar.url"
</p> alt=""
width="48"
height="48"
class="rounded-md"
/>
</figure> </figure>
<b-icon <AccountCircle v-else :size="48" />
class="media-left" <div class="flex-1">
v-else <div class="flex gap-1 items-center">
size="is-large" <strong>{{ currentActor?.name }}</strong>
icon="account-circle" <small dir="ltr">@{{ currentActor?.preferredUsername }}</small>
/> </div>
<div class="media-content"> <div class="flex flex-col gap-2">
<div class="content"> <editor
<span class="first-line"> ref="commentEditor"
<strong>{{ currentActor.name }}</strong> v-model="newComment.text"
<small dir="ltr">@{{ currentActor.preferredUsername }}</small> mode="comment"
</span> :current-actor="currentActor"
<br /> :aria-label="t('Comment body')"
<span class="editor-line"> class="flex-1"
<editor />
class="editor" <o-button
ref="commentEditor" :disabled="newComment.text.trim().length === 0"
v-model="newComment.text" native-type="submit"
mode="comment" variant="primary"
:aria-label="$t('Comment body')" class="self-end"
/> >{{ t("Post a reply") }}</o-button
<b-button >
:disabled="newComment.text.trim().length === 0"
native-type="submit"
type="is-primary"
>{{ $t("Post a reply") }}</b-button
>
</span>
</div> </div>
</div> </div>
</article> </article>
</form> </form>
<div class="replies"> <div>
<div class="left"> <div>
<div class="vertical-border" @click="showReplies = false" /> <div @click="showReplies = false" />
</div> </div>
<transition-group <transition-group
name="comment-replies" name="comment-replies"
v-if="showReplies" v-if="showReplies"
class="comment-replies"
tag="ul" tag="ul"
class="flex flex-col gap-2"
> >
<comment <Comment
class="reply"
v-for="reply in comment.replies" v-for="reply in comment.replies"
:key="reply.id" :key="reply.id"
:comment="reply" :comment="reply"
:event="event" :event="event"
@create-comment="$emit('create-comment', $event)" :currentActor="currentActor"
@delete-comment="$emit('delete-comment', $event)" :rootComment="false"
@create-comment="emit('create-comment', $event)"
@delete-comment="emit('delete-comment', $event)"
@report-comment="emit('report-comment', $event)"
/> />
</transition-group> </transition-group>
</div> </div>
</li> </li>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import EditorComponent from "@/components/Editor.vue"; import EditorComponent from "@/components/Editor.vue";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { CommentModeration } from "@/types/enums"; import { CommentModeration } from "@/types/enums";
import { CommentModel, IComment } from "../../types/comment.model"; import { CommentModel, IComment } from "../../types/comment.model";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { IPerson } from "../../types/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
import { IEvent } from "../../types/event.model"; import { IEvent } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
import PopoverActorCard from "../Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
import {
computed,
defineAsyncComponent,
inject,
onMounted,
ref,
nextTick,
} from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import Alert from "vue-material-design-icons/Alert.vue";
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
import Reply from "vue-material-design-icons/Reply.vue";
@Component({ const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
},
components: {
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
PopoverActorCard,
},
})
export default class Comment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
@Prop({ required: true, type: Object }) event!: IEvent; const props = withDefaults(
defineProps<{
comment: IComment;
event: IEvent;
currentActor: IPerson;
rootComment?: boolean;
}>(),
{ rootComment: true }
);
// Hack because Vue only exports it's own interface. const emit = defineEmits([
// See https://github.com/kaorun343/vue-property-decorator/issues/257 "create-comment",
@Ref() readonly commentEditor!: EditorComponent & { "delete-comment",
replyToComment: (comment: IComment) => void; "report-comment",
focus: () => void; ]);
};
currentActor!: IPerson; const commentEditor = ref<typeof EditorComponent | null>(null);
newComment: IComment = new CommentModel(); // Hack because Vue only exports it's own interface.
// See https://github.com/kaorun343/vue-property-decorator/issues/257
// @Ref() readonly commentEditor!: EditorComponent & {
// replyToComment: (comment: IComment) => void;
// focus: () => void;
// };
replyTo = false; const newComment = ref<IComment>(new CommentModel());
const replyTo = ref(false);
const showReplies = ref(false);
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
showReplies = false; onMounted(() => {
if (route?.hash.includes(`#comment-${props.comment.uuid}`)) {
CommentModeration = CommentModeration; showReplies.value = true;
usernameWithDomain = usernameWithDomain;
formatDistanceToNow = formatDistanceToNow;
async mounted(): Promise<void> {
const { hash } = this.$route;
if (hash.includes(`#comment-${this.comment.uuid}`)) {
this.fetchReplies();
}
} }
});
async createReplyToComment(): Promise<void> { const createReplyToComment = async (): Promise<void> => {
if (this.replyTo) { if (replyTo.value) {
this.replyTo = false; replyTo.value = false;
this.newComment = new CommentModel(); newComment.value = new CommentModel();
return; return;
}
this.replyTo = true;
if (this.comment.actor) {
this.commentEditor.replyToComment(this.comment.actor);
await this.$nextTick; // wait for the mention to be injected
this.commentEditor.focus();
}
} }
replyTo.value = true;
if (props.comment.actor) {
commentEditor.value?.replyToComment(props.comment.actor);
await nextTick(); // wait for the mention to be injected
commentEditor.value?.focus();
}
};
replyToComment(): void { const replyToComment = (): void => {
this.newComment.inReplyToComment = this.comment; newComment.value.inReplyToComment = props.comment;
this.newComment.originComment = this.comment.originComment || this.comment; newComment.value.originComment = props.comment.originComment ?? props.comment;
this.newComment.actor = this.currentActor; newComment.value.actor = props.currentActor;
this.$emit("create-comment", this.newComment); console.log(newComment.value);
this.newComment = new CommentModel(); emit("create-comment", newComment.value);
this.replyTo = false; newComment.value = new CommentModel();
this.showReplies = true; replyTo.value = false;
} showReplies.value = true;
};
deleteComment(): void { const deleteComment = (): void => {
this.$emit("delete-comment", this.comment); emit("delete-comment", props.comment);
this.showReplies = false; showReplies.value = false;
} };
fetchReplies(): void { const commentSelected = computed((): boolean => {
this.showReplies = true; return `#${commentId.value}` === route?.hash;
} });
get commentSelected(): boolean { const commentFromOrganizer = computed((): boolean => {
return `#${this.commentId}` === this.$route.hash; const organizerId =
} props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && props.comment?.actor?.id === organizerId;
});
get commentFromOrganizer(): boolean { const commentId = computed((): string => {
const organizerId = if (props.comment.originComment)
this.event?.organizerActor?.id || this.event?.attributedTo?.id; return `comment-${props.comment.originComment.uuid}-${props.comment.uuid}`;
return organizerId !== undefined && this.comment?.actor?.id === organizerId; return `comment-${props.comment.uuid}`;
} });
get commentId(): string { const commentURL = computed((): string => {
if (this.comment.originComment) if (!props.comment.local && props.comment.url) return props.comment.url;
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`; return `#${commentId.value}`;
return `comment-${this.comment.uuid}`; });
}
get commentURL(): string { const reportModal = (): void => {
if (!this.comment.local && this.comment.url) return this.comment.url; if (!props.comment.actor) return;
return `#${this.commentId}`; emit("report-comment", props.comment);
} // this.$buefy.modal.open({
// component: ReportModal,
// props: {
// title: t("Report this comment"),
// comment: props.comment,
// onConfirm: reportComment,
// outsideDomain: props.comment.actor?.domain,
// },
// // https://github.com/buefy/buefy/pull/3589
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// closeButtonAriaLabel: this.t("Close"),
// });
};
reportModal(): void { // const reportComment = async (
if (!this.comment.actor) return; // content: string,
this.$buefy.modal.open({ // forward: boolean
parent: this, // ): Promise<void> => {
component: ReportModal, // try {
props: { // if (!props.comment.actor) return;
title: this.$t("Report this comment"),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
},
// https://github.com/buefy/buefy/pull/3589
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
closeButtonAriaLabel: this.$t("Close"),
});
}
async reportComment(content: string, forward: boolean): Promise<void> { // const { onError, onDone } = useMutation(CREATE_REPORT, () => ({
try { // variables: {
if (!this.comment.actor) return; // eventId: props.event.id,
await this.$apollo.mutate<IReport>({ // reportedId: props.comment.actor?.id,
mutation: CREATE_REPORT, // commentsIds: [props.comment.id],
variables: { // content,
eventId: this.event.id, // forward,
reportedId: this.comment.actor.id, // },
commentsIds: [this.comment.id], // }));
content,
forward, // // this.$buefy.notification.open({
}, // // message: this.t("Comment from @{username} reported", {
}); // // username: this.comment.actor.preferredUsername,
this.$buefy.notification.open({ // // }) as string,
message: this.$t("Comment from @{username} reported", { // // type: "is-success",
username: this.comment.actor.preferredUsername, // // position: "is-bottom-right",
}) as string, // // duration: 5000,
type: "is-success", // // });
position: "is-bottom-right", // } catch (e: any) {
duration: 5000, // if (e.message) {
}); // // Snackbar.open({
} catch (e: any) { // // message: e.message,
if (e.message) { // // type: "is-danger",
Snackbar.open({ // // position: "is-bottom",
message: e.message, // // });
type: "is-danger", // }
position: "is-bottom", // }
}); // };
} const actorComment = computed(() => props.comment.actor);
} const dateFnsLocale = inject<Locale>("dateFnsLocale");
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;
@ -364,9 +365,9 @@ form.reply {
padding: 0 6px; padding: 0 6px;
} }
& > small { // & > small {
@include margin-left(0.3rem); // @include margin-left(0.3rem);
} // }
} }
.editor-line { .editor-line {
@ -375,15 +376,15 @@ form.reply {
.editor { .editor {
flex: 1; flex: 1;
@include padding-right(10px); // @include padding-right(10px);
margin-bottom: 0; margin-bottom: 0;
} }
} }
a.comment-link { a.comment-link {
text-decoration: none; text-decoration: none;
@include margin-left(5px); // @include margin-left(5px);
color: $text; color: text;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -425,9 +426,9 @@ a.comment-link {
} }
} }
.media-left { // .media-left {
@include margin-right(5px); // @include margin-right(5px);
} // }
} }
.root-comment .replies { .root-comment .replies {
@ -437,7 +438,7 @@ a.comment-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@include margin-right(10px); // @include margin-right(10px);
.vertical-border { .vertical-border {
width: 3px; width: 3px;
@ -528,9 +529,9 @@ article {
transform-origin: center top; transform-origin: center top;
} }
.reply-action .icon { // .reply-action .icon {
@include padding-right(0.4rem); // @include padding-right(0.4rem);
} // }
.visually-hidden { .visually-hidden {
display: none; display: none;

View file

@ -1,69 +1,62 @@
<template> <template>
<div> <div>
<form <form
class="new-comment" class=""
v-if="isAbleToComment" v-if="isAbleToComment"
@submit.prevent="createCommentForEvent(newComment)" @submit.prevent="createCommentForEvent(newComment)"
@keyup.ctrl.enter="createCommentForEvent(newComment)" @keyup.ctrl.enter="createCommentForEvent(newComment)"
> >
<b-notification <o-notification
v-if="isEventOrganiser && !areCommentsClosed" v-if="isEventOrganiser && !areCommentsClosed"
:closable="false" :closable="false"
>{{ $t("Comments are closed for everybody else.") }}</b-notification >{{ t("Comments are closed for everybody else.") }}</o-notification
> >
<article class="media"> <article class="flex flex-wrap items-start gap-2">
<figure class="media-left" v-if="newComment.actor"> <figure class="" v-if="newComment.actor">
<identity-picker-wrapper :inline="false" v-model="newComment.actor" /> <identity-picker-wrapper :inline="false" v-model="newComment.actor" />
</figure> </figure>
<div class="media-content"> <div class="flex-1">
<div class="field"> <div class="flex flex-col gap-2">
<div class="field"> <div class="editor-wrapper">
<p class="control"> <Editor
<editor ref="commenteditor"
ref="commenteditor" v-if="currentActor"
mode="comment" :currentActor="currentActor"
v-model="newComment.text" mode="comment"
:aria-label="$t('Comment body')" v-model="newComment.text"
/> :aria-label="t('Comment body')"
</p> />
<p class="help is-danger" v-if="emptyCommentError"> <p class="" v-if="emptyCommentError">
{{ $t("Comment text can't be empty") }} {{ t("Comment text can't be empty") }}
</p> </p>
</div> </div>
<div class="field notify-participants" v-if="isEventOrganiser"> <div class="" v-if="isEventOrganiser">
<b-switch <o-switch
aria-labelledby="notify-participants-toggle" aria-labelledby="notify-participants-toggle"
v-model="newComment.isAnnouncement" v-model="newComment.isAnnouncement"
>{{ $t("Notify participants") }}</b-switch >{{ t("Notify participants") }}</o-switch
> >
</div> </div>
</div> </div>
</div> </div>
<div class="send-comment"> <div class="">
<b-button <o-button native-type="submit" variant="primary" icon-left="send">{{
native-type="submit" t("Send")
type="is-primary" }}</o-button>
class="comment-button-submit"
icon-left="send"
>{{ $t("Send") }}</b-button
>
</div> </div>
</article> </article>
</form> </form>
<b-notification v-else-if="isConnected" :closable="false">{{ <o-notification v-else-if="isConnected" :closable="false">{{
$t("The organiser has chosen to close comments.") t("The organiser has chosen to close comments.")
}}</b-notification> }}</o-notification>
<p <p v-if="commentsLoading" class="text-center">
v-if="$apollo.queries.comments.loading" {{ t("Loading comments…") }}
class="loading has-text-centered"
>
{{ $t("Loading comments…") }}
</p> </p>
<transition-group tag="div" name="comment-empty-list" v-else> <transition-group tag="div" name="comment-empty-list" v-else>
<transition-group <transition-group
key="list" key="list"
name="comment-list" name="comment-list"
v-if="filteredOrderedComments.length" v-if="filteredOrderedComments.length && currentActor"
class="comment-list" class="comment-list"
tag="ul" tag="ul"
> >
@ -71,21 +64,26 @@
class="root-comment" class="root-comment"
:comment="comment" :comment="comment"
:event="event" :event="event"
:currentActor="currentActor"
v-for="comment in filteredOrderedComments" v-for="comment in filteredOrderedComments"
:key="comment.id" :key="comment.id"
@create-comment="createCommentForEvent" @create-comment="createCommentForEvent"
@delete-comment="deleteComment" @delete-comment="
deleteComment({
commentId: comment.id as string,
originCommentId: comment.originComment?.id,
})
"
/> />
</transition-group> </transition-group>
<empty-content v-else icon="comment" key="no-comments" :inline="true"> <empty-content v-else icon="comment" key="no-comments" :inline="true">
<span>{{ $t("No comments yet") }}</span> <span>{{ t("No comments yet") }}</span>
</empty-content> </empty-content>
</transition-group> </transition-group>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
import Comment from "@/components/Comment/Comment.vue"; import Comment from "@/components/Comment/Comment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue"; import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { CommentModeration } from "@/types/enums"; import { CommentModeration } from "@/types/enums";
@ -95,328 +93,338 @@ import {
DELETE_COMMENT, DELETE_COMMENT,
COMMENTS_THREADS_WITH_REPLIES, COMMENTS_THREADS_WITH_REPLIES,
} from "../../graphql/comment"; } from "../../graphql/comment";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { IEvent } from "../../types/event.model"; import { IEvent } from "../../types/event.model";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core"; import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import EmptyContent from "@/components/Utils/EmptyContent.vue"; import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
import { IPerson } from "@/types/actor";
import { AbsintheGraphQLError } from "@/types/errors.model";
import { useI18n } from "vue-i18n";
import { Notifier } from "@/plugins/notifier";
@Component({ const { currentActor } = useCurrentActorClient();
apollo: {
currentActor: CURRENT_ACTOR_CLIENT, const { result: commentsResult, loading: commentsLoading } = useQuery<{
comments: { event: Pick<IEvent, "id" | "uuid" | "comments">;
}>(
COMMENTS_THREADS_WITH_REPLIES,
() => ({ eventUUID: props.event?.uuid }),
() => ({ enabled: props.event?.uuid !== undefined })
);
const comments = computed(() => commentsResult.value?.event.comments ?? []);
const props = defineProps<{
event: IEvent;
newComment?: IComment;
}>();
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const newComment = ref<IComment>(props.newComment ?? new CommentModel());
const emptyCommentError = ref(false);
const { t } = useI18n({ useScope: "global" });
watch(currentActor, () => {
newComment.value.actor = currentActor.value as IPerson;
});
watch(newComment, (newCommentUpdated: IComment) => {
if (emptyCommentError.value) {
emptyCommentError.value = ["", "<p></p>"].includes(newCommentUpdated.text);
}
});
const {
mutate: createCommentForEventMutation,
onDone: createCommentForEventMutationDone,
onError: createCommentForEventMutationError,
} = useMutation<
{ createComment: IComment },
{
eventId: string;
text: string;
inReplyToCommentId?: string;
isAnnouncement?: boolean;
originCommentId?: string | undefined;
}
>(CREATE_COMMENT_FROM_EVENT, () => ({
update: (
store: ApolloCache<InMemoryCache>,
{ data }: FetchResult,
{ variables }
) => {
if (data == null) return;
// comments are attached to the event, so we can pass it to replies later
const newCommentLocal = { ...data.createComment, event: props.event };
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES, query: COMMENTS_THREADS_WITH_REPLIES,
variables() { variables: {
return { eventUUID: props.event?.uuid,
eventUUID: this.event.uuid,
};
}, },
update: (data) => data.event.comments, });
skip() { if (!commentThreadsData) return;
return !this.event.uuid; const { event } = commentThreadsData;
const oldComments = [...event.comments];
// if it's no a root comment, we first need to find
// existing replies and add the new reply to it
if (variables?.originCommentId !== undefined) {
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === variables.originCommentId
);
const parentComment = oldComments[parentCommentIndex];
// replace the root comment with has the updated list of replies in the thread list
oldComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: [...parentComment.replies, newCommentLocal],
});
} else {
// otherwise it's simply a new thread and we add it to the list
oldComments.push(newCommentLocal);
}
// finally we save the thread list
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
data: {
event: {
...event,
comments: oldComments,
},
}, },
variables: {
eventUUID: props.event?.uuid,
},
});
},
}));
createCommentForEventMutationDone(() => {
// and reset the new comment field
newComment.value = new CommentModel();
});
const notifier = inject<Notifier>("notifier");
createCommentForEventMutationError((errors) => {
console.error(errors);
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
const error = errors.graphQLErrors[0] as AbsintheGraphQLError;
if (error.field !== "text" && error.message[0] !== "can't be blank") {
notifier?.error(error.message);
}
}
});
const createCommentForEvent = (comment: IComment) => {
emptyCommentError.value = ["", "<p></p>"].includes(comment.text);
if (emptyCommentError.value) return;
if (!comment.actor) return;
if (!props.event?.id) return;
createCommentForEventMutation({
eventId: props.event?.id,
text: comment.text,
inReplyToCommentId: comment.inReplyToComment?.id,
isAnnouncement: comment.isAnnouncement,
originCommentId: comment.originComment?.id,
});
};
const { mutate: deleteComment, onError: deleteCommentMutationError } =
useMutation<
{ deleteComment: { id: string } },
{ commentId: string; originCommentId?: string }
>(DELETE_COMMENT, () => ({
update: (
store: ApolloCache<InMemoryCache>,
{ data }: FetchResult,
{ variables }
) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: props.event?.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
let updatedComments: IComment[] = [...event.comments];
if (variables?.originCommentId) {
// we have deleted a reply to a thread
const parentCommentIndex = updatedComments.findIndex(
(oldComment) => oldComment.id === variables.originCommentId
);
const parentComment = updatedComments[parentCommentIndex];
const updatedReplies = parentComment.replies.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
updatedComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: updatedReplies,
totalReplies: parentComment.totalReplies - 1,
});
console.log("updatedComments", updatedComments);
} else {
// we have deleted a thread itself
updatedComments = updatedComments.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
}
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: props.event?.uuid,
},
data: {
event: {
...event,
comments: updatedComments,
},
},
});
}, },
}, }));
components: {
Comment,
IdentityPickerWrapper,
EmptyContent,
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class CommentTree extends Vue {
@Prop({ required: false, type: Object }) event!: IEvent;
newComment: IComment = new CommentModel(); deleteCommentMutationError((error) => {
console.error(error);
currentActor!: IPerson; if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
comments: IComment[] = [];
CommentModeration = CommentModeration;
emptyCommentError = false;
@Watch("currentActor")
watchCurrentActor(currentActor: IPerson): void {
this.newComment.actor = currentActor;
} }
});
@Watch("newComment", { deep: true }) const orderedComments = computed((): IComment[] => {
resetEmptyCommentError(newComment: IComment): void { return comments.value
if (this.emptyCommentError) { .filter((comment: IComment) => comment.inReplyToComment == null)
this.emptyCommentError = ["", "<p></p>"].includes(newComment.text); .sort((a: IComment, b: IComment) => {
} if (a.isAnnouncement !== b.isAnnouncement) {
} return (
(b.isAnnouncement === true ? 1 : 0) -
async createCommentForEvent(comment: IComment): Promise<void> { (a.isAnnouncement === true ? 1 : 0)
this.emptyCommentError = ["", "<p></p>"].includes(comment.text); );
if (this.emptyCommentError) return;
try {
if (!comment.actor) return;
await this.$apollo.mutate({
mutation: CREATE_COMMENT_FROM_EVENT,
variables: {
eventId: this.event.id,
text: comment.text,
inReplyToCommentId: comment.inReplyToComment
? comment.inReplyToComment.id
: null,
isAnnouncement: comment.isAnnouncement,
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
// comments are attached to the event, so we can pass it to replies later
const newComment = { ...data.createComment, event: this.event };
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentThreadsData) return;
const { event } = commentThreadsData;
const oldComments = [...event.comments];
// if it's no a root comment, we first need to find
// existing replies and add the new reply to it
if (comment.originComment !== undefined) {
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
// replace the root comment with has the updated list of replies in the thread list
oldComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: [...parentComment.replies, newComment],
});
} else {
// otherwise it's simply a new thread and we add it to the list
oldComments.push(newComment);
}
// finally we save the thread list
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
data: {
event: {
...event,
comments: oldComments,
},
},
variables: {
eventUUID: this.event.uuid,
},
});
},
});
// and reset the new comment field
this.newComment = new CommentModel();
} catch (errors: any) {
console.error(errors);
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
const error = errors.graphQLErrors[0];
if (error.field !== "text" && error.message[0] !== "can't be blank") {
this.$notifier.error(error.message);
}
} }
} if (a.publishedAt && b.publishedAt) {
} return (
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
async deleteComment(comment: IComment): Promise<void> { );
try { } else if (a.updatedAt && b.updatedAt) {
await this.$apollo.mutate({ return (
mutation: DELETE_COMMENT, new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
variables: { );
commentId: comment.id,
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
let updatedComments: IComment[] = [...event.comments];
if (comment.originComment) {
// we have deleted a reply to a thread
const { originComment } = comment;
const parentCommentIndex = updatedComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = updatedComments[parentCommentIndex];
const updatedReplies = parentComment.replies.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
updatedComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: updatedReplies,
totalReplies: parentComment.totalReplies - 1,
});
console.log("updatedComments", updatedComments);
} else {
// we have deleted a thread itself
updatedComments = updatedComments.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
}
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
data: {
event: {
...event,
comments: updatedComments,
},
},
});
},
});
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
} }
} return 0;
} });
});
get orderedComments(): IComment[] { const filteredOrderedComments = computed((): IComment[] => {
return this.comments return orderedComments.value.filter(
.filter((comment) => comment.inReplyToComment == null) (comment) => !comment.deletedAt || comment.totalReplies > 0
.sort((a, b) => { );
if (a.isAnnouncement !== b.isAnnouncement) { });
return (
(b.isAnnouncement === true ? 1 : 0) -
(a.isAnnouncement === true ? 1 : 0)
);
}
if (a.publishedAt && b.publishedAt) {
return (
new Date(b.publishedAt).getTime() -
new Date(a.publishedAt).getTime()
);
} else if (a.updatedAt && b.updatedAt) {
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
return 0;
});
}
get filteredOrderedComments(): IComment[] { const isEventOrganiser = computed((): boolean => {
return this.orderedComments.filter( const organizerId =
(comment) => !comment.deletedAt || comment.totalReplies > 0 props.event?.organizerActor?.id || props.event?.attributedTo?.id;
); return organizerId !== undefined && currentActor.value?.id === organizerId;
} });
get isEventOrganiser(): boolean { const areCommentsClosed = computed((): boolean => {
const organizerId = return (
this.event?.organizerActor?.id || this.event?.attributedTo?.id; currentActor.value?.id !== undefined &&
return organizerId !== undefined && this.currentActor?.id === organizerId; props.event?.options.commentModeration !== CommentModeration.CLOSED
} );
});
get areCommentsClosed(): boolean { const isAbleToComment = computed((): boolean => {
return ( if (isConnected.value) {
this.currentActor.id !== undefined && return areCommentsClosed.value || isEventOrganiser.value;
this.event.options.commentModeration !== CommentModeration.CLOSED
);
} }
return false;
});
get isAbleToComment(): boolean { const isConnected = computed((): boolean => {
if (this.isConnected) { return currentActor.value?.id != undefined;
return this.areCommentsClosed || this.isEventOrganiser; });
}
return false;
}
get isConnected(): boolean {
return this.currentActor?.id != undefined;
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/_mixins" as *; // @use "@/styles/_mixins" as *;
@import "~bulma/sass/utilities/mixins.sass"; // // @import "node_modules/bulma/sass/utilities/mixins.sass";
form.new-comment { // form.new-comment {
padding-bottom: 1rem; // padding-bottom: 1rem;
.media { // .media {
flex-wrap: wrap; // flex-wrap: wrap;
justify-content: center; // justify-content: center;
.media-left { // // .media-left {
@include mobile { // // @include >mobile {
@include margin-right(0.5rem); // // @include margin-right(0.5rem);
@include margin-left(0.5rem); // // @include margin-left(0.5rem);
} // // }
} // // }
.media-content { // .media-content {
display: flex; // display: flex;
align-items: center; // align-items: center;
align-content: center; // align-content: center;
width: min-content; // width: min-content;
.field { // .field {
flex: 1; // flex: 1;
@include padding-right(10px); // // @include padding-right(10px);
margin-bottom: 0; // margin-bottom: 0;
&.notify-participants { // &.notify-participants {
margin-top: 0.5rem; // margin-top: 0.5rem;
} // }
} // }
} // }
} // }
} // }
.no-comments { // .no-comments {
display: flex; // display: flex;
flex-direction: column; // flex-direction: column;
span { // span {
text-align: center; // text-align: center;
margin-bottom: 10px; // margin-bottom: 10px;
} // }
img { // img {
max-width: 250px; // max-width: 250px;
align-self: center; // align-self: center;
} // }
} // }
ul.comment-list li { // ul.comment-list li {
margin-bottom: 16px; // margin-bottom: 16px;
} // }
.comment-list-enter-active, .comment-list-enter-active,
.comment-list-leave-active, .comment-list-leave-active,
@ -447,11 +455,11 @@ ul.comment-list li {
transform-origin: center top; transform-origin: center top;
} }
/*.comment-empty-list-enter-active {*/ // .comment-empty-list-enter-active {
/* transition: opacity .5s;*/ // transition: opacity .5s;
/*}*/ // }
/*.comment-empty-list-enter {*/ // .comment-empty-list-enter {
/* opacity: 0;*/ // opacity: 0;
/*}*/ // }
</style> </style>

View file

@ -0,0 +1,49 @@
<template>
<Story>
<Variant title="Basic">
<DiscussionComment v-model="comment" :currentActor="baseActor" />
</Variant>
<Variant title="Deleted comment">
<DiscussionComment v-model="deletedComment" :currentActor="baseActor" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IPerson } from "@/types/actor";
import { IComment } from "@/types/comment.model";
import { ActorType } from "@/types/enums";
import { reactive } from "vue";
import DiscussionComment from "./DiscussionComment.vue";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IPerson = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
id: "598",
};
const comment = reactive<IComment>({
text: "Hello there",
publishedAt: new Date().toString(),
actor: baseActor,
});
const deletedComment = reactive<IComment>({
...comment,
deletedAt: new Date().toString(),
});
</script>

View file

@ -1,23 +1,26 @@
<template> <template>
<article class="comment"> <article class="flex gap-1">
<div class="avatar"> <div class="">
<figure <figure class="" v-if="comment.actor && comment.actor.avatar">
class="image is-48x48" <img
v-if="comment.actor && comment.actor.avatar" class="rounded-xl"
> :src="comment.actor.avatar.url"
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> alt=""
:width="48"
:height="48"
/>
</figure> </figure>
<b-icon v-else size="is-large" icon="account-circle" /> <AccountCircle :size="48" v-else />
</div> </div>
<div class="body"> <div class="mb-2 pt-1 flex-1">
<div class="meta" dir="auto"> <div class="flex items-center gap-1" dir="auto">
<span <div
class="first-line name" class="flex flex-1 flex-col"
v-if="comment.actor && !comment.deletedAt" v-if="comment.actor && !comment.deletedAt"
> >
<strong>{{ comment.actor.name }}</strong> <strong v-if="comment.actor.name">{{ comment.actor.name }}</strong>
<small>@{{ usernameWithDomain(comment.actor) }}</small> <small>@{{ usernameWithDomain(comment.actor) }}</small>
</span> </div>
<span v-else class="name comment-link has-text-grey"> <span v-else class="name comment-link has-text-grey">
{{ $t("[deleted]") }} {{ $t("[deleted]") }}
</span> </span>
@ -26,39 +29,44 @@
v-if=" v-if="
comment.actor && comment.actor &&
!comment.deletedAt && !comment.deletedAt &&
comment.actor.id === currentActor.id comment.actor.id === currentActor?.id
" "
> >
<b-dropdown aria-role="list"> <o-dropdown aria-role="list">
<b-icon slot="trigger" role="button" icon="dots-horizontal" /> <template #trigger>
<o-icon role="button" icon="dots-horizontal" />
</template>
<b-dropdown-item <o-dropdown-item
v-if="comment.actor.id === currentActor.id" v-if="comment.actor?.id === currentActor?.id"
@click="toggleEditMode" @click="toggleEditMode"
aria-role="menuitem" aria-role="menuitem"
> >
<b-icon icon="pencil"></b-icon> <o-icon icon="pencil"></o-icon>
{{ $t("Edit") }} {{ $t("Edit") }}
</b-dropdown-item> </o-dropdown-item>
<b-dropdown-item <o-dropdown-item
v-if="comment.actor.id === currentActor.id" v-if="comment.actor?.id === currentActor?.id"
@click="$emit('delete-comment', comment)" @click="emit('deleteComment', comment)"
aria-role="menuitem" aria-role="menuitem"
> >
<b-icon icon="delete"></b-icon> <o-icon icon="delete"></o-icon>
{{ $t("Delete") }} {{ $t("Delete") }}
</b-dropdown-item> </o-dropdown-item>
<!-- <b-dropdown-item aria-role="listitem" @click="isReportModalActive = true"> <!-- <o-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
<b-icon icon="flag" /> <o-icon icon="flag" />
{{ $t("Report") }} {{ $t("Report") }}
</b-dropdown-item> --> </o-dropdown-item> -->
</b-dropdown> </o-dropdown>
</span> </span>
<div class="post-infos"> <div class="self-center">
<span :title="comment.insertedAt | formatDateTimeString"> <span
:title="formatDateTimeString(comment.updatedAt?.toString())"
v-if="comment.updatedAt"
>
{{ {{
formatDistanceToNow(new Date(comment.updatedAt), { formatDistanceToNow(new Date(comment.updatedAt?.toString()), {
locale: $dateFnsLocale, locale: dateFnsLocale,
}) || $t("Right now") }) || $t("Right now")
}}</span }}</span
> >
@ -69,20 +77,24 @@
class="text-wrapper" class="text-wrapper"
dir="auto" dir="auto"
> >
<div class="description-content" v-html="comment.text"></div> <div
class="prose md:prose-lg lg:prose-xl dark:prose-invert"
v-html="comment.text"
></div>
<p <p
class="text-sm"
v-if=" v-if="
comment.insertedAt && comment.insertedAt &&
comment.updatedAt && comment.updatedAt &&
new Date(comment.insertedAt).getTime() !== new Date(comment.insertedAt).getTime() !==
new Date(comment.updatedAt).getTime() new Date(comment.updatedAt).getTime()
" "
:title="comment.updatedAt | formatDateTimeString" :title="formatDateTimeString(comment.updatedAt.toString())"
> >
{{ {{
$t("Edited {ago}", { $t("Edited {ago}", {
ago: formatDistanceToNow(new Date(comment.updatedAt), { ago: formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale, locale: dateFnsLocale,
}), }),
}) })
}} }}
@ -92,66 +104,66 @@
{{ $t("[This comment has been deleted by it's author]") }} {{ $t("[This comment has been deleted by it's author]") }}
</div> </div>
<form v-else class="edition" @submit.prevent="updateComment"> <form v-else class="edition" @submit.prevent="updateComment">
<editor v-model="updatedComment" :aria-label="$t('Comment body')" /> <Editor
<div class="buttons"> v-model="updatedComment"
<b-button :aria-label="$t('Comment body')"
:current-actor="currentActor"
/>
<div class="flex gap-2 mt-2">
<o-button
native-type="submit" native-type="submit"
:disabled="['<p></p>', '', comment.text].includes(updatedComment)" :disabled="['<p></p>', '', comment.text].includes(updatedComment)"
type="is-primary" variant="primary"
>{{ $t("Update") }}</b-button >{{ $t("Update") }}</o-button
> >
<b-button native-type="button" @click="toggleEditMode">{{ <o-button native-type="button" @click="toggleEditMode">{{
$t("Cancel") $t("Cancel")
}}</b-button> }}</o-button>
</div> </div>
</form> </form>
</div> </div>
</article> </article>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue } from "vue-property-decorator";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { IComment } from "../../types/comment.model"; import { IComment } from "../../types/comment.model";
import { usernameWithDomain, IPerson } from "../../types/actor"; import { IPerson, usernameWithDomain } from "../../types/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { computed, defineAsyncComponent, inject, ref } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import type { Locale } from "date-fns";
@Component({ const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
},
components: {
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class DiscussionComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
editMode = false; const props = defineProps<{
modelValue: IComment;
currentActor: IPerson;
}>();
updatedComment = ""; const emit = defineEmits(["update:modelValue", "deleteComment"]);
currentActor!: IPerson; const comment = computed(() => props.modelValue);
usernameWithDomain = usernameWithDomain; const editMode = ref(false);
formatDistanceToNow = formatDistanceToNow; const updatedComment = ref("");
// isReportModalActive: boolean = false; const dateFnsLocale = inject<Locale>("dateFnsLocale");
toggleEditMode(): void { // isReportModalActive: boolean = false;
this.updatedComment = this.comment.text;
this.editMode = !this.editMode;
}
updateComment(): void { const toggleEditMode = (): void => {
this.$emit("update-comment", { updatedComment.value = comment.value.text;
...this.comment, editMode.value = !editMode.value;
text: this.updatedComment, };
});
this.toggleEditMode(); const updateComment = (): void => {
} emit("update:modelValue", {
} ...comment.value,
text: updatedComment.value,
});
toggleEditMode();
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;
@ -170,7 +182,7 @@ article.comment {
padding: 0 1rem 0.3em; padding: 0 1rem 0.3em;
.name { .name {
@include margin-right(auto); // @include margin-right(auto);
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
@ -200,33 +212,33 @@ article.comment {
div.description-content { div.description-content {
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
::v-deep h1 { :deep(h1) {
font-size: 2rem; font-size: 2rem;
} }
::v-deep h2 { :deep(h2) {
font-size: 1.5rem; font-size: 1.5rem;
} }
::v-deep h3 { :deep(h3) {
font-size: 1.25rem; font-size: 1.25rem;
} }
::v-deep ul { :deep(ul) {
list-style-type: disc; list-style-type: disc;
} }
::v-deep li { :deep(li) {
margin: 10px auto 10px 2rem; margin: 10px auto 10px 2rem;
} }
::v-deep blockquote { :deep(blockquote) {
border-left: 0.2em solid #333; border-left: 0.2em solid #333;
display: block; display: block;
@include padding-left(1em); // @include padding-left(1em);
} }
::v-deep p { :deep(p) {
margin: 10px auto; margin: 10px auto;
a { a {

View file

@ -0,0 +1,33 @@
<template>
<Story>
<Variant title="Basic">
<DiscussionListItem :discussion="discussion" />
</Variant>
<Variant title="Deleted comment">
<DiscussionListItem :discussion="discussionWithDeletedComment" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IDiscussion } from "@/types/discussions";
import { reactive } from "vue";
import DiscussionListItem from "./DiscussionListItem.vue";
const discussion = reactive<IDiscussion>({
title: "A discussion",
comments: { total: 5, elements: [] },
insertedAt: new Date().toString(),
updatedAt: new Date().toString(),
deletedAt: null,
lastComment: { text: "Hello there", publishedAt: new Date().toString() },
});
const discussionWithDeletedComment = reactive<IDiscussion>({
...discussion,
lastComment: {
...discussion.lastComment,
deletedAt: new Date().toString(),
},
});
</script>

View file

@ -1,126 +1,93 @@
<template> <template>
<router-link <router-link
class="discussion-minimalist-card-wrapper" class="flex gap-1 w-full items-center p-2 border-b-stone-200 border-b"
dir="auto" dir="auto"
:to="{ :to="{
name: RouteName.DISCUSSION, name: RouteName.DISCUSSION,
params: { slug: discussion.slug, id: discussion.id }, params: { slug: discussion.slug, id: discussion.id },
}" }"
> >
<div class="media-left"> <div class="">
<figure <figure
class="image is-32x32" class=""
v-if=" v-if="
discussion.lastComment.actor && discussion.lastComment.actor.avatar discussion.lastComment?.actor && discussion.lastComment.actor.avatar
" "
> >
<img <img
class="is-rounded" class="rounded-xl"
:src="discussion.lastComment.actor.avatar.url" :src="discussion.lastComment.actor.avatar.url"
alt alt=""
width="32"
height="32"
/> />
</figure> </figure>
<b-icon v-else size="is-medium" icon="account-circle" /> <account-circle :size="32" v-else />
</div> </div>
<div class="title-info-wrapper"> <div class="flex-1">
<div class="title-and-date"> <div class="flex items-center">
<p class="discussion-minimalist-title">{{ discussion.title }}</p> <p class="text-violet-3 dark:text-white text-lg font-semibold flex-1">
<span {{ discussion.title }}
class="has-text-grey-dark" </p>
:title="actualDate | formatDateTimeString" <span class="" :title="formatDateTimeString(actualDate)">
> {{ distanceToNow }}</span
{{
formatDistanceToNowStrict(new Date(actualDate), {
locale: $dateFnsLocale,
}) || $t("Right now")
}}</span
> >
</div> </div>
<div <div
class="ellipsis has-text-grey-dark" class="line-clamp-2"
dir="auto" dir="auto"
v-if="!discussion.lastComment.deletedAt" v-if="!discussion.lastComment?.deletedAt"
> >
{{ htmlTextEllipsis }} {{ htmlTextEllipsis }}
</div> </div>
<div v-else class="has-text-grey-dark"> <div v-else class="">
{{ $t("[This comment has been deleted]") }} {{ t("[This comment has been deleted]") }}
</div> </div>
</div> </div>
</router-link> </router-link>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue } from "vue-property-decorator";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { IDiscussion } from "../../types/discussions"; import { IDiscussion } from "@/types/discussions";
import RouteName from "../../router/name"; import RouteName from "@/router/name";
import { computed, inject } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import type { Locale } from "date-fns";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { useI18n } from "vue-i18n";
@Component const props = defineProps<{
export default class DiscussionListItem extends Vue { discussion: IDiscussion;
@Prop({ required: true, type: Object }) discussion!: IDiscussion; }>();
RouteName = RouteName; const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
formatDistanceToNowStrict = formatDistanceToNowStrict; const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
get htmlTextEllipsis(): string { const htmlTextEllipsis = computed((): string => {
const element = document.createElement("div"); const element = document.createElement("div");
if (this.discussion.lastComment && this.discussion.lastComment.text) { if (props.discussion.lastComment && props.discussion.lastComment.text) {
element.innerHTML = this.discussion.lastComment.text element.innerHTML = props.discussion.lastComment.text
.replace(/<br\s*\/?>/gi, " ") .replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " "); .replace(/<p>/gi, " ");
}
return element.innerText;
} }
return element.innerText;
});
get actualDate(): string | Date | undefined { const actualDate = computed((): string => {
if ( if (
this.discussion.updatedAt === this.discussion.insertedAt && props.discussion.updatedAt === props.discussion.insertedAt &&
this.discussion.lastComment props.discussion.lastComment?.publishedAt
) { ) {
return this.discussion.lastComment.publishedAt; return props.discussion.lastComment.publishedAt;
}
return this.discussion.updatedAt;
} }
} return props.discussion.updatedAt;
});
</script> </script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.discussion-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.calendar-icon {
@include margin-right(1rem);
}
.title-info-wrapper {
flex: 2;
.title-and-date {
display: flex;
align-items: center;
.discussion-minimalist-title {
color: #3c376e;
font-family: Roboto, Helvetica, Arial, serif;
font-size: 19px;
font-weight: 600;
flex: 1;
}
}
div.ellipsis {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 15px;
}
}
}
</style>

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-if="editor"> <div v-if="editor !== null">
<div <div
class="editor" class="editor"
:class="{ short_mode: isShortMode, comment_mode: isCommentMode }" :class="{ short_mode: isShortMode, comment_mode: isCommentMode }"
@ -14,64 +14,64 @@
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('bold') }" :class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()" @click="editor?.chain().focus().toggleBold().run()"
type="button" type="button"
:title="$t('Bold')" :title="$t('Bold')"
> >
<b-icon icon="format-bold" /> <o-icon icon="format-bold" />
</button> </button>
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('italic') }" :class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()" @click="editor?.chain().focus().toggleItalic().run()"
type="button" type="button"
:title="$t('Italic')" :title="$t('Italic')"
> >
<b-icon icon="format-italic" /> <o-icon icon="format-italic" />
</button> </button>
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('underline') }" :class="{ 'is-active': editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()" @click="editor?.chain().focus().toggleUnderline().run()"
type="button" type="button"
:title="$t('Underline')" :title="$t('Underline')"
> >
<b-icon icon="format-underline" /> <o-icon icon="format-underline" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
type="button" type="button"
:title="$t('Heading Level 1')" :title="$t('Heading Level 1')"
> >
<b-icon icon="format-header-1" /> <o-icon icon="format-header-1" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
type="button" type="button"
:title="$t('Heading Level 2')" :title="$t('Heading Level 2')"
> >
<b-icon icon="format-header-2" /> <o-icon icon="format-header-2" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
type="button" type="button"
:title="$t('Heading Level 3')" :title="$t('Heading Level 3')"
> >
<b-icon icon="format-header-3" /> <o-icon icon="format-header-3" />
</button> </button>
<button <button
@ -81,17 +81,17 @@
type="button" type="button"
:title="$t('Add link')" :title="$t('Add link')"
> >
<b-icon icon="link" /> <o-icon icon="link" />
</button> </button>
<button <button
v-if="editor.isActive('link')" v-if="editor.isActive('link')"
class="menubar__button" class="menubar__button"
@click="editor.chain().focus().unsetLink().run()" @click="editor?.chain().focus().unsetLink().run()"
type="button" type="button"
:title="$t('Remove link')" :title="$t('Remove link')"
> >
<b-icon icon="link-off" /> <o-icon icon="link-off" />
</button> </button>
<button <button
@ -101,60 +101,60 @@
type="button" type="button"
:title="$t('Add picture')" :title="$t('Add picture')"
> >
<b-icon icon="image" /> <o-icon icon="image" />
</button> </button>
<button <button
class="menubar__button" class="menubar__button"
v-if="!isBasicMode" v-if="!isBasicMode"
:class="{ 'is-active': editor.isActive('bulletList') }" :class="{ 'is-active': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()" @click="editor?.chain().focus().toggleBulletList().run()"
type="button" type="button"
:title="$t('Bullet list')" :title="$t('Bullet list')"
> >
<b-icon icon="format-list-bulleted" /> <o-icon icon="format-list-bulleted" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('orderedList') }" :class="{ 'is-active': editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()" @click="editor?.chain().focus().toggleOrderedList().run()"
type="button" type="button"
:title="$t('Ordered list')" :title="$t('Ordered list')"
> >
<b-icon icon="format-list-numbered" /> <o-icon icon="format-list-numbered" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('blockquote') }" :class="{ 'is-active': editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()" @click="editor?.chain().focus().toggleBlockquote().run()"
type="button" type="button"
:title="$t('Quote')" :title="$t('Quote')"
> >
<b-icon icon="format-quote-close" /> <o-icon icon="format-quote-close" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
@click="editor.chain().focus().undo().run()" @click="editor?.chain().focus().undo().run()"
type="button" type="button"
:title="$t('Undo')" :title="$t('Undo')"
> >
<b-icon icon="undo" /> <o-icon icon="undo" />
</button> </button>
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
@click="editor.chain().focus().redo().run()" @click="editor?.chain().focus().redo().run()"
type="button" type="button"
:title="$t('Redo')" :title="$t('Redo')"
> >
<b-icon icon="redo" /> <o-icon icon="redo" />
</button> </button>
</div> </div>
@ -167,34 +167,33 @@
<button <button
class="menububble__button" class="menububble__button"
:class="{ 'is-active': editor.isActive('bold') }" :class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()" @click="editor?.chain().focus().toggleBold().run()"
type="button" type="button"
:title="$t('Bold')" :title="$t('Bold')"
> >
<b-icon icon="format-bold" /> <o-icon icon="format-bold" />
<span class="visually-hidden">{{ $t("Bold") }}</span> <span class="visually-hidden">{{ $t("Bold") }}</span>
</button> </button>
<button <button
class="menububble__button" class="menububble__button"
:class="{ 'is-active': editor.isActive('italic') }" :class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()" @click="editor?.chain().focus().toggleItalic().run()"
type="button" type="button"
:title="$t('Italic')" :title="$t('Italic')"
> >
<b-icon icon="format-italic" /> <o-icon icon="format-italic" />
<span class="visually-hidden">{{ $t("Italic") }}</span> <span class="visually-hidden">{{ $t("Italic") }}</span>
</button> </button>
</bubble-menu> </bubble-menu>
<editor-content class="editor__content" :editor="editor" /> <editor-content class="editor__content" :editor="editor" v-if="editor" />
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-2";
import Blockquote from "@tiptap/extension-blockquote"; import Blockquote from "@tiptap/extension-blockquote";
import BulletList from "@tiptap/extension-bullet-list"; import BulletList from "@tiptap/extension-bullet-list";
import Heading from "@tiptap/extension-heading"; import Heading from "@tiptap/extension-heading";
@ -211,7 +210,6 @@ import { IActor, IPerson, usernameWithDomain } from "../types/actor";
import CustomImage from "./Editor/Image"; import CustomImage from "./Editor/Image";
import { UPLOAD_MEDIA } from "../graphql/upload"; import { UPLOAD_MEDIA } from "../graphql/upload";
import { listenFileUpload } from "../utils/upload"; import { listenFileUpload } from "../utils/upload";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import Mention from "@tiptap/extension-mention"; import Mention from "@tiptap/extension-mention";
import MentionOptions from "./Editor/Mention"; import MentionOptions from "./Editor/Mention";
import OrderedList from "@tiptap/extension-ordered-list"; import OrderedList from "@tiptap/extension-ordered-list";
@ -219,190 +217,204 @@ import ListItem from "@tiptap/extension-list-item";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import { AutoDir } from "./Editor/Autodir"; import { AutoDir } from "./Editor/Autodir";
import sanitizeHtml from "sanitize-html"; // import sanitizeHtml from "sanitize-html";
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
import { Notifier } from "@/plugins/notifier";
@Component({ const props = withDefaults(
components: { EditorContent, BubbleMenu }, defineProps<{
apollo: { modelValue: string;
currentActor: { mode?: string;
query: CURRENT_ACTOR_CLIENT, maxSize?: number;
ariaLabel?: string;
currentActor: IPerson;
}>(),
{
mode: "description",
maxSize: 100_000_000,
}
);
const emit = defineEmits(["update:modelValue"]);
const editor = ref<Editor | null>(null);
const isDescriptionMode = computed((): boolean => {
return props.mode === "description" || isBasicMode.value;
});
const isCommentMode = computed((): boolean => {
return props.mode === "comment";
});
const isShortMode = computed((): boolean => {
return isBasicMode.value;
});
const isBasicMode = computed((): boolean => {
return props.mode === "basic";
});
const insertMention = (obj: { range: any; attrs: any }) => {
console.log("initialize Mention");
};
const observer = ref<MutationObserver | null>(null);
onMounted(() => {
editor.value = new Editor({
editorProps: {
attributes: {
"aria-multiline": isShortMode.value.toString(),
"aria-label": props.ariaLabel ?? "",
role: "textbox",
},
transformPastedHTML: transformPastedHTML,
}, },
}, extensions: [
}) Blockquote,
export default class EditorComponent extends Vue { BulletList,
@Prop({ required: true }) value!: string; Heading,
Document,
Paragraph,
Text,
OrderedList,
ListItem,
Mention.configure(MentionOptions),
CustomImage,
AutoDir,
Underline,
Bold,
Italic,
Strike,
Dropcursor,
Gapcursor,
History,
Link.configure({
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
}),
],
injectCSS: false,
content: props.modelValue,
onUpdate: () => {
emit("update:modelValue", editor.value?.getHTML());
},
});
});
@Prop({ required: false, default: "description" }) mode!: string; const transformPastedHTML = (html: string): string => {
// When using comment mode, limit to acceptable tags
@Prop({ required: false, default: 100_000_000 }) maxSize!: number; if (isCommentMode.value) {
// return sanitizeHtml(html, {
@Prop({ required: false }) ariaLabel!: string; // allowedTags: ["b", "i", "em", "strong", "a"],
// allowedAttributes: {
currentActor!: IPerson; // a: ["href", "rel", "target"],
// },
editor: Editor | null = null; // });
get isDescriptionMode(): boolean {
return this.mode === "description" || this.isBasicMode;
}
get isCommentMode(): boolean {
return this.mode === "comment";
}
get isShortMode(): boolean {
return this.isBasicMode;
}
get isBasicMode(): boolean {
return this.mode === "basic";
}
// eslint-disable-next-line
insertMention(obj: { range: any; attrs: any }) {
console.log("initialize Mention");
}
observer!: MutationObserver | null;
mounted(): void {
this.editor = new Editor({
editorProps: {
attributes: {
"aria-multiline": this.isShortMode.toString(),
"aria-label": this.ariaLabel,
role: "textbox",
},
transformPastedHTML: this.transformPastedHTML,
},
extensions: [
Blockquote,
BulletList,
Heading,
Document,
Paragraph,
Text,
OrderedList,
ListItem,
Mention.configure(MentionOptions),
CustomImage,
AutoDir,
Underline,
Bold,
Italic,
Strike,
Dropcursor,
Gapcursor,
History,
Link.configure({
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
}),
],
injectCSS: false,
content: this.value,
onUpdate: () => {
this.$emit("input", this.editor?.getHTML());
},
});
}
transformPastedHTML(html: string): string {
// When using comment mode, limit to acceptable tags
if (this.isCommentMode) {
return sanitizeHtml(html, {
allowedTags: ["b", "i", "em", "strong", "a"],
allowedAttributes: {
a: ["href", "rel", "target"],
},
});
}
return html; return html;
} }
return html;
};
@Watch("value") const value = computed(() => props.modelValue);
onValueChanged(val: string): void {
if (!this.editor) return; watch(value, (val: string) => {
if (val !== this.editor.getHTML()) { if (!editor.value) return;
this.editor.commands.setContent(val, false); if (val !== editor.value.getHTML()) {
} editor.value.commands.setContent(val, false);
} }
});
/** const dialog = inject<Dialog>("dialog");
* Show a popup to get the link from the URL const { t } = useI18n({ useScope: "global" });
*/
showLinkMenu(): void {
this.$buefy.dialog.prompt({
message: this.$t("Enter the link URL") as string,
inputAttrs: {
type: "url",
},
trapFocus: true,
onConfirm: (value) => {
if (!this.editor) return undefined;
this.editor.chain().focus().setLink({ href: value }).run();
},
});
}
/** /**
* Show a file prompt, upload picture and insert it into editor * Show a popup to get the link from the URL
*/ */
async showImagePrompt(): Promise<void> { const showLinkMenu = (): void => {
const image = await listenFileUpload(); dialog?.prompt({
try { message: t("Enter the link URL"),
const { data } = await this.$apollo.mutate({ inputAttrs: {
mutation: UPLOAD_MEDIA, type: "url",
variables: { },
file: image, onConfirm: (prompt: string) => {
name: image.name, if (!editor.value) return;
}, editor.value.chain().focus().setLink({ href: prompt }).run();
}); },
if (data.uploadMedia && data.uploadMedia.url && this.editor) { });
this.editor };
.chain()
.focus()
.setImage({
src: data.uploadMedia.url,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
"data-media-id": data.uploadMedia.id,
})
.run();
}
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
/** const {
* We use this to programatically insert an actor mention when creating a reply to comment mutate: uploadMediaMutation,
*/ onDone: uploadMediaDone,
replyToComment(actor: IActor): void { onError: uploadMediaError,
if (!this.editor) return; } = useMutation(UPLOAD_MEDIA);
this.editor
/**
* Show a file prompt, upload picture and insert it into editor
*/
const showImagePrompt = async (): Promise<void> => {
const image = await listenFileUpload();
uploadMediaMutation({
file: image,
name: image.name,
});
};
uploadMediaDone(({ data }) => {
if (data.uploadMedia && data.uploadMedia.url && editor.value) {
editor.value
.chain() .chain()
.focus() .focus()
.insertContent({ .setImage({
type: "mention", src: data.uploadMedia.url,
attrs: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment
id: usernameWithDomain(actor), // @ts-ignore
}, "data-media-id": data.uploadMedia.id,
}) })
.insertContent(" ")
.run(); .run();
} }
});
focus(): void { const notifier = inject<Notifier>("notifier");
this.editor?.chain().focus("end");
}
beforeDestroy(): void { uploadMediaError((error) => {
this.editor?.destroy(); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
} }
} });
/**
* We use this to programatically insert an actor mention when creating a reply to comment
*/
const replyToComment = (actor: IActor): void => {
if (!editor.value) return;
editor.value
.chain()
.focus()
.insertContent({
type: "mention",
attrs: {
id: usernameWithDomain(actor),
},
})
.insertContent(" ")
.run();
};
const focus = (): void => {
editor.value?.chain().focus("end");
};
defineExpose({ replyToComment, focus });
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script> </script>
<style lang="scss"> <style lang="scss">
@use "@/styles/_mixins" as *; @use "@/styles/_mixins" as *;
@ -422,7 +434,7 @@ $color-white: #eee;
border: 0; border: 0;
color: $color-black; color: $color-black;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
@include margin-right(0.2rem); // @include margin-right(0.2rem);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
@ -492,10 +504,10 @@ $color-white: #eee;
font-size: 1.25em; font-size: 1.25em;
} }
ul, // ul,
ol { // ol {
@include padding-left(1rem); // @include padding-left(1rem);
} // }
ul { ul {
list-style-type: disc; list-style-type: disc;
@ -510,7 +522,7 @@ $color-white: #eee;
blockquote { blockquote {
border-left: 3px solid rgba($color-black, 0.1); border-left: 3px solid rgba($color-black, 0.1);
color: rgba($color-black, 0.8); color: rgba($color-black, 0.8);
@include padding-left(0.8rem); // @include padding-left(0.8rem);
font-style: italic; font-style: italic;
p { p {

View file

@ -1,10 +1,9 @@
import { UPLOAD_MEDIA } from "@/graphql/upload"; import { UPLOAD_MEDIA } from "@/graphql/upload";
import apolloProvider from "@/vue-apollo"; import { apolloClient } from "@/vue-apollo";
import { ApolloClient } from "@apollo/client/core/ApolloClient";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import { NormalizedCacheObject } from "@apollo/client/cache"; import { provideApolloClient, useMutation } from "@vue/apollo-composable";
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
@ -60,21 +59,25 @@ const CustomImage = Image.extend({
top: realEvent.clientY, top: realEvent.clientY,
}); });
if (!coordinates) return false; if (!coordinates) return false;
const client =
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
try { images.forEach((image) => {
images.forEach(async (image) => { const { onDone, onError } = provideApolloClient(apolloClient)(
const { data } = await client.mutate({ () =>
mutation: UPLOAD_MEDIA, useMutation<{ uploadMedia: { url: string; id: string } }>(
variables: { UPLOAD_MEDIA,
file: image, () => ({
name: image.name, variables: {
}, file: image,
}); name: image.name,
},
})
)
);
onDone(({ data }) => {
const node = schema.nodes.image.create({ const node = schema.nodes.image.create({
src: data.uploadMedia.url, src: data?.uploadMedia.url,
"data-media-id": data.uploadMedia.id, "data-media-id": data?.uploadMedia.id,
}); });
const transaction = view.state.tr.insert( const transaction = view.state.tr.insert(
coordinates.pos, coordinates.pos,
@ -82,11 +85,13 @@ const CustomImage = Image.extend({
); );
view.dispatch(transaction); view.dispatch(transaction);
}); });
return true;
} catch (error) { onError((error) => {
console.error(error); console.error(error);
return false; return false;
} });
});
return true;
}, },
}, },
}, },

View file

@ -1,27 +1,38 @@
import { SEARCH_PERSONS } from "@/graphql/search"; import { SEARCH_PERSONS } from "@/graphql/search";
import { VueRenderer } from "@tiptap/vue-2"; import { VueRenderer } from "@tiptap/vue-3";
import tippy from "tippy.js"; import tippy from "tippy.js";
import MentionList from "./MentionList.vue"; import MentionList from "./MentionList.vue";
import { ApolloClient } from "@apollo/client/core/ApolloClient"; import { apolloClient } from "@/vue-apollo";
import apolloProvider from "@/vue-apollo";
import { IPerson } from "@/types/actor"; import { IPerson } from "@/types/actor";
import pDebounce from "p-debounce"; import pDebounce from "p-debounce";
import { NormalizedCacheObject } from "@apollo/client/cache/inmemory/types";
import { MentionOptions } from "@tiptap/extension-mention"; import { MentionOptions } from "@tiptap/extension-mention";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
import { onError } from "@apollo/client/link/error";
const client = const fetchItems = (query: string): Promise<IPerson[]> => {
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>; return new Promise((resolve, reject) => {
const { onResult } = provideApolloClient(apolloClient)(() => {
return useQuery<{ searchPersons: Paginate<IPerson> }>(
SEARCH_PERSONS,
() => ({
variables: {
searchText: query,
},
})
);
});
const fetchItems = async (query: string): Promise<IPerson[]> => { onResult(({ data }) => {
const result = await client.query({ resolve(data.searchPersons.elements);
query: SEARCH_PERSONS, });
variables: {
searchText: query, onError(reject);
},
}); });
// TipTap doesn't handle async for onFilter, hence the following line.
return result.data.searchPersons.elements; // // TipTap doesn't handle async for onFilter, hence the following line.
// return result.data.searchPersons.elements;
}; };
const debouncedFetchItems = pDebounce(fetchItems, 200); const debouncedFetchItems = pDebounce(fetchItems, 200);
@ -53,7 +64,6 @@ const mentionOptions: MentionOptions = {
return { return {
onStart: (props: any) => { onStart: (props: any) => {
component = new VueRenderer(MentionList, { component = new VueRenderer(MentionList, {
parent: this,
propsData: props, propsData: props,
}); });

View file

@ -12,70 +12,64 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Vue, Component, Prop, Watch } from "vue-property-decorator"; import { usernameWithDomain } from "@/types/actor/actor.model";
import { displayName, usernameWithDomain } from "@/types/actor/actor.model";
import { IPerson } from "@/types/actor"; import { IPerson } from "@/types/actor";
import ActorInline from "../../components/Account/ActorInline.vue"; import ActorInline from "../../components/Account/ActorInline.vue";
import { ref, watch } from "vue";
@Component({ const props = defineProps<{
components: { items: IPerson[];
ActorInline, command: ({ id }: { id: string }) => {};
}, }>();
})
export default class MentionList extends Vue {
@Prop({ type: Array, required: true }) items!: Array<IPerson>;
@Prop({ type: Function, required: true }) command!: any;
selectedIndex = 0; // @Prop({ type: Function, required: true }) command!: any;
displayName = displayName; const selectedIndex = ref(0);
@Watch("items") watch(props.items, () => {
watchItems(): void { selectedIndex.value = 0;
this.selectedIndex = 0; });
const onKeyDown = ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") {
upHandler();
return true;
} }
onKeyDown({ event }: { event: KeyboardEvent }): boolean { if (event.key === "ArrowDown") {
if (event.key === "ArrowUp") { downHandler();
this.upHandler(); return true;
return true;
}
if (event.key === "ArrowDown") {
this.downHandler();
return true;
}
if (event.key === "Enter") {
this.enterHandler();
return true;
}
return false;
} }
upHandler(): void { if (event.key === "Enter") {
this.selectedIndex = enterHandler();
(this.selectedIndex + this.items.length - 1) % this.items.length; return true;
} }
downHandler(): void { return false;
this.selectedIndex = (this.selectedIndex + 1) % this.items.length; };
}
enterHandler(): void { const upHandler = (): void => {
this.selectItem(this.selectedIndex); selectedIndex.value =
} (selectedIndex.value + props.items.length - 1) % props.items.length;
};
selectItem(index: number): void { const downHandler = (): void => {
const item = this.items[index]; selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
};
if (item) { const enterHandler = (): void => {
this.command({ id: usernameWithDomain(item) }); selectItem(selectedIndex.value);
} };
const selectItem = (index: number): void => {
const item = props.items[index];
if (item) {
props.command({ id: usernameWithDomain(item) });
} }
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,345 +0,0 @@
<template>
<div class="container section" id="error-wrapper">
<div class="column">
<section>
<div class="picture-wrapper">
<picture>
<source
srcset="
/img/pics/error-480w.webp 1x,
/img/pics/error-1024w.webp 2x
"
type="image/webp"
/>
<source
srcset="/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x"
type="image/jpeg"
/>
<img
:src="`/img/pics/error-480w.jpg`"
alt=""
width="480"
height="312"
loading="lazy"
/>
</picture>
</div>
<b-message type="is-danger" class="is-size-5">
<h1>
{{
$t(
"An error has occured. Sorry about that. You may try to reload the page."
)
}}
</h1>
</b-message>
</section>
<b-loading v-if="$apollo.loading" :active.sync="$apollo.loading" />
<section v-else>
<h2 class="is-size-5">{{ $t("What can I do to help?") }}</h2>
<p class="content">
<i18n
tag="span"
path="{instanceName} is an instance of {mobilizon_link}, a free software built with the community."
>
<b slot="instanceName">{{ config.name }}</b>
<a slot="mobilizon_link" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
</i18n>
<span v-if="sentryEnabled && sentryReady">
{{
$t(
"We collect your feedback and the error information in order to improve this service."
)
}}</span
>
<span v-else>
{{
$t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
</span>
</p>
<form
v-if="sentryEnabled && sentryReady && !submittedFeedback"
@submit.prevent="sendErrorToSentry"
>
<b-field :label="$t('What happened?')" label-for="what-happened">
<b-input
v-model="feedback"
type="textarea"
id="what-happened"
:placeholder="$t(`I've clicked on X, then on Y`)"
/>
</b-field>
<b-button icon-left="send" native-type="submit" type="is-primary">{{
$t("Send feedback")
}}</b-button>
<p class="content">
{{
$t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
</form>
<b-message type="is-danger" v-else-if="feedbackError">
<p>
{{
$t(
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway."
)
}}
</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<b-message type="is-success" v-else-if="submittedFeedback">
<p>{{ $t("Thanks a lot, your feedback was submitted!") }}</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<div
class="content"
v-if="!(sentryEnabled && sentryReady) || submittedFeedback"
>
<p v-if="submittedFeedback">{{ $t("You may also:") }}</p>
<ul>
<li>
<a
href="https://framacolibri.org/c/mobilizon/39"
target="_blank"
>{{ $t("Open a topic on our forum") }}</a
>
</li>
<li>
<a
href="https://framagit.org/framasoft/mobilizon/-/issues/"
target="_blank"
>{{
$t("Open an issue on our bug tracker (advanced users)")
}}</a
>
</li>
</ul>
</div>
<p class="content" v-if="!sentryEnabled">
{{
$t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
<details>
<summary class="is-size-5">{{ $t("Technical details") }}</summary>
<p>{{ $t("Error message") }}</p>
<pre>{{ error }}</pre>
<p>{{ $t("Error stacktrace") }}</p>
<pre>{{ error.stack }}</pre>
</details>
<p v-if="!sentryEnabled">
{{
$t(
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
)
}}
</p>
<div class="buttons" v-if="!sentryEnabled">
<b-tooltip
:label="tooltipConfig.label"
:type="tooltipConfig.type"
:active="copied !== false"
always
>
<b-button
@click="copyErrorToClipboard"
@keyup.enter="copyErrorToClipboard"
>{{ $t("Copy details to clipboard") }}</b-button
>
</b-tooltip>
</div>
</section>
</div>
</div>
</template>
<script lang="ts">
import { CONFIG } from "@/graphql/config";
import { checkProviderConfig, convertConfig } from "@/services/statistics";
import { IAnalyticsConfig, IConfig } from "@/types/config.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import { LOGGED_USER } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { ISentryConfiguration } from "@/types/analytics/sentry.model";
import { submitFeedback } from "@/services/statistics/sentry";
import RouteName from "@/router/name";
@Component({
apollo: {
config: CONFIG,
loggedUser: LOGGED_USER,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.$t("Error") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class ErrorComponent extends Vue {
@Prop({ required: true, type: Error }) error!: Error;
copied: "success" | "error" | false = false;
config!: IConfig;
feedback = "";
submittedFeedback = false;
feedbackError = false;
loggedUser!: IUser;
RouteName = RouteName;
async copyErrorToClipboard(): Promise<void> {
try {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(this.fullErrorString);
} else {
this.fallbackCopyTextToClipboard(this.fullErrorString);
}
this.copied = "success";
setTimeout(() => {
this.copied = false;
}, 2000);
} catch (e) {
this.copied = "error";
console.error("Unable to copy to clipboard");
console.error(e);
}
}
get fullErrorString(): string {
return `${this.error.name}: ${this.error.message}\n\n${this.error.stack}`;
}
get tooltipConfig(): { label: string | null; type: string | null } {
if (this.copied === "success")
return {
label: this.$t("Error details copied!") as string,
type: "is-success",
};
if (this.copied === "error")
return {
label: this.$t("Unable to copy to clipboard") as string,
type: "is-danger",
};
return { label: null, type: "is-primary" };
}
private fallbackCopyTextToClipboard(text: string): void {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
}
get sentryEnabled(): boolean {
return this.sentryProvider?.enabled === true;
}
get sentryProvider(): IAnalyticsConfig | undefined {
return this.config && checkProviderConfig(this.config, "sentry");
}
get sentryConfig(): ISentryConfiguration | undefined {
if (this.sentryProvider?.configuration) {
return convertConfig(
this.sentryProvider?.configuration
) as ISentryConfiguration;
}
return undefined;
}
get sentryReady() {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
return eventId && dsn && organization && project && host;
}
async sendErrorToSentry() {
try {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
const endpoint = `https://${host}/api/0/projects/${organization}/${project}/user-feedback/`;
if (eventId && dsn && this.sentryReady) {
await submitFeedback(endpoint, dsn, {
event_id: eventId,
name:
this.loggedUser?.defaultActor?.preferredUsername || "Unknown user",
email: this.loggedUser?.email || "unknown@email.org",
comments: this.feedback,
});
this.submittedFeedback = true;
}
} catch (error) {
console.error(error);
this.feedbackError = true;
}
}
}
</script>
<style lang="scss" scoped>
#error-wrapper {
width: 100%;
background: $white;
section {
margin-bottom: 2rem;
}
.picture-wrapper {
text-align: center;
}
details {
summary:hover {
cursor: pointer;
}
}
}
</style>

View file

@ -0,0 +1,217 @@
<template>
<div class="container mx-auto" id="error-wrapper">
<div class="">
<section>
<div class="text-center">
<picture>
<source
:srcset="`/img/pics/error-480w.webp 1x, /img/pics/error-1024w.webp 2x`"
type="image/webp"
/>
<source
:srcset="`/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x`"
type="image/jpeg"
/>
<img
:src="`/img/pics/error-480w.jpg`"
alt=""
width="480"
height="312"
loading="lazy"
/>
</picture>
</div>
<o-notification variant="danger" class="">
<h1>
{{
t(
"An error has occured. Sorry about that. You may try to reload the page."
)
}}
</h1>
</o-notification>
</section>
<o-loading v-if="loading" v-model:active="loading" />
<section v-else>
<h2 class="">{{ t("What can I do to help?") }}</h2>
<p class="prose dark:prose-invert">
<i18n-t
tag="span"
keypath="{instanceName} is an instance of {mobilizon_link}, a free software built with the community."
>
<template v-slot:instanceName>
<b>{{ config?.name }}</b>
</template>
<template v-slot:mobilizon_link>
<a href="https://joinmobilizon.org">{{ t("Mobilizon") }}</a>
</template>
</i18n-t>
<span v-if="sentryEnabled">
{{
t(
"We collect your feedback and the error information in order to improve this service."
)
}}</span
>
<span v-else>
{{
t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
</span>
</p>
<SentryFeedback />
<p class="prose dark:prose-invert" v-if="!sentryEnabled">
{{
t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
<details>
<summary class="is-size-5">{{ t("Technical details") }}</summary>
<p>{{ t("Error message") }}</p>
<pre>{{ error }}</pre>
<p>{{ t("Error stacktrace") }}</p>
<pre>{{ error.stack }}</pre>
</details>
<p v-if="!sentryEnabled">
{{
t(
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
)
}}
</p>
<div class="buttons" v-if="!sentryEnabled">
<o-tooltip
:label="tooltipConfig.label"
:type="tooltipConfig.type"
:active="copied !== false"
always
>
<o-button
@click="copyErrorToClipboard"
@keyup.enter="copyErrorToClipboard"
>{{ t("Copy details to clipboard") }}</o-button
>
</o-tooltip>
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { checkProviderConfig } from "@/services/statistics";
import { IAnalyticsConfig } from "@/types/config.model";
import { computed, defineAsyncComponent, ref } from "vue";
import { useQueryLoading } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { useAnalytics } from "@/composition/apollo/config";
const SentryFeedback = defineAsyncComponent(
() => import("./Feedback/SentryFeedback.vue")
);
const { analytics } = useAnalytics();
const loading = useQueryLoading();
const props = defineProps<{
error: Error;
}>();
const copied = ref<"success" | "error" | false>(false);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Error")),
});
const copyErrorToClipboard = async (): Promise<void> => {
try {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(fullErrorString.value);
} else {
fallbackCopyTextToClipboard(fullErrorString.value);
}
copied.value = "success";
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (e) {
copied.value = "error";
console.error("Unable to copy to clipboard");
console.error(e);
}
};
const fullErrorString = computed((): string => {
return `${props.error.name}: ${props.error.message}\n\n${props.error.stack}`;
});
const tooltipConfig = computed(
(): { label: string | null; variant: string | null } => {
if (copied.value === "success")
return {
label: t("Error details copied!") as string,
variant: "success",
};
if (copied.value === "error")
return {
label: t("Unable to copy to clipboard") as string,
variant: "danger",
};
return { label: null, variant: "primary" };
}
);
const fallbackCopyTextToClipboard = (text: string): void => {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
};
const sentryEnabled = computed((): boolean => {
return sentryProvider.value?.enabled === true;
});
const sentryProvider = computed((): IAnalyticsConfig | undefined => {
return checkProviderConfig(analytics.value ?? [], "sentry");
});
</script>
<style lang="scss" scoped>
#error-wrapper {
width: 100%;
background: $white;
section {
margin-bottom: 2rem;
}
.picture-wrapper {
text-align: center;
}
details {
summary:hover {
cursor: pointer;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="address-autocomplete"> <div class="address-autocomplete">
<b-field expanded> <!-- <o-field expanded>
<b-autocomplete <o-autocomplete
:data="addressData" :data="addressData"
v-model="queryText" v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')" :placeholder="$t('e.g. 10 Rue Jangot')"
@ -15,31 +15,31 @@
dir="auto" dir="auto"
> >
<template #default="{ option }"> <template #default="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" /> <o-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b <b>{{ option.poiInfos.name }}</b
><br /> ><br />
<small>{{ option.poiInfos.alternativeName }}</small> <small>{{ option.poiInfos.alternativeName }}</small>
</template> </template>
</b-autocomplete> </o-autocomplete>
</b-field> </o-field>
<b-field <o-field
v-if="canDoGeoLocation" v-if="canDoGeoLocation"
:message="fieldErrors" :message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }" :type="{ 'is-danger': fieldErrors.length }"
> >
<b-button <o-button
type="is-text" type="is-text"
v-if="!gettingLocation" v-if="!gettingLocation"
icon-right="target" icon-right="target"
@click="locateMe" @click="locateMe"
@keyup.enter="locateMe" @keyup.enter="locateMe"
>{{ $t("Use my location") }}</b-button >{{ $t("Use my location") }}</o-button
> >
<span v-else>{{ $t("Getting location") }}</span> <span v-else>{{ $t("Getting location") }}</span>
</b-field> </o-field> -->
<!-- <!--
<div v-if="selected && selected.geom" class="control"> <div v-if="selected && selected.geom" class="control">
<b-checkbox @input="togglemap" /> <o-checkbox @input="togglemap" />
<label class="label">{{ $t("Show map") }}</label> <label class="label">{{ $t("Show map") }}</label>
</div> </div>
@ -59,16 +59,14 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Prop, Watch } from "vue-property-decorator"; import { Prop, Watch, Vue } from "vue-property-decorator";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin"; // import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
@Component({ // @Component({
inheritAttrs: false, // inheritAttrs: false,
}) // })
export default class AddressAutoComplete extends Mixins( export default class AddressAutoComplete extends Vue {
AddressAutoCompleteMixin
) {
@Prop({ required: false, default: false }) type!: string | false; @Prop({ required: false, default: false }) type!: string | false;
@Prop({ required: false, default: true, type: Boolean }) @Prop({ required: false, default: true, type: Boolean })
doGeoLocation!: boolean; doGeoLocation!: boolean;
@ -103,7 +101,7 @@ export default class AddressAutoComplete extends Mixins(
updateSelected(option: IAddress): void { updateSelected(option: IAddress): void {
if (option == null) return; if (option == null) return;
this.selected = option; this.selected = option;
this.$emit("input", this.selected); // this.$emit("input", this.selected);
} }
resetPopup(): void { resetPopup(): void {

View file

@ -0,0 +1,14 @@
<template>
<Story>
<Variant title="new">
<DateCalendarIcon :date="new Date().toString()" />
</Variant>
<Variant title="small">
<DateCalendarIcon :date="new Date().toString()" :small="true" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import DateCalendarIcon from "./DateCalendarIcon.vue";
</script>

View file

@ -1,71 +1,51 @@
<docs>
### Example
```vue
<DateCalendarIcon date="2019-10-05T18:41:11.720Z" />
```
```vue
<DateCalendarIcon
:date="new Date()"
/>
```
</docs>
<template> <template>
<div <div
class="datetime-container" class="datetime-container flex flex-col rounded-lg text-center justify-center overflow-hidden items-stretch bg-white dark:bg-gray-700 text-violet-3 dark:text-white"
:class="{ small }" :class="{ small }"
:style="`--small: ${smallStyle}`" :style="`--small: ${smallStyle}`"
> >
<div class="datetime-container-header" /> <div class="datetime-container-header" />
<div class="datetime-container-content"> <div class="datetime-container-content">
<time :datetime="dateObj.toISOString()" class="day">{{ day }}</time> <time :datetime="dateObj.toISOString()" class="day block font-semibold">{{
<time :datetime="dateObj.toISOString()" class="month">{{ month }}</time> day
}}</time>
<time
:datetime="dateObj.toISOString()"
class="month font-semibold block uppercase py-1 px-0"
>{{ month }}</time
>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Component, Prop, Vue } from "vue-property-decorator"; import { computed } from "vue";
@Component const props = withDefaults(
export default class DateCalendarIcon extends Vue { defineProps<{
/** date: string;
* `date` can be a string or an actual date object. small?: boolean;
*/ }>(),
@Prop({ required: true }) date!: string; { small: false }
@Prop({ required: false, default: false }) small!: boolean; );
get dateObj(): Date { const dateObj = computed<Date>(() => new Date(props.date));
return new Date(this.$props.date);
}
get month(): string { const month = computed<string>(() =>
return this.dateObj.toLocaleString(undefined, { month: "short" }); dateObj.value.toLocaleString(undefined, { month: "short" })
} );
get day(): string { const day = computed<string>(() =>
return this.dateObj.toLocaleString(undefined, { day: "numeric" }); dateObj.value.toLocaleString(undefined, { day: "numeric" })
} );
get smallStyle(): string {
return this.small ? "1.2" : "2"; const smallStyle = computed<string>(() => (props.small ? "1.2" : "2"));
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
div.datetime-container { div.datetime-container {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
overflow-y: hidden;
overflow-x: hidden;
align-items: stretch;
width: calc(40px * var(--small)); width: calc(40px * var(--small));
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2); box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
height: calc(40px * var(--small)); height: calc(40px * var(--small));
background: #fff;
.datetime-container-header { .datetime-container-header {
height: calc(10px * var(--small)); height: calc(10px * var(--small));
@ -76,15 +56,9 @@ div.datetime-container {
} }
time { time {
display: block;
font-weight: 600;
color: $violet-3;
&.month { &.month {
padding: 2px 0;
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
text-transform: uppercase;
} }
&.day { &.day {

Some files were not shown because too many files have changed in this diff Show more