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.0 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: 1015 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: 1016 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: 1013 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,121 +39,78 @@ 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,
},
components: {
Logo,
NavBar,
error: () =>
import(/* webpackChunkName: "editor" */ "./components/Error.vue"),
"mobilizon-footer": Footer,
},
metaInfo() {
return {
titleTemplate: "%s | Mobilizon",
};
},
})
export default class App extends Vue {
config!: IConfig;
currentUser!: ICurrentUser; const { t } = useI18n({ useScope: "global" });
error: Error | null = null; const { location } = useServerProvidedLocation();
online = true; const userLocation = reactive<LocationType>({
lon: undefined,
lat: undefined,
name: undefined,
picture: undefined,
isIPLocation: true,
accuracy: 100,
});
interval: number | undefined = undefined; 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;
};
@Ref("routerView") routerView!: Vue; 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,
});
async created(): Promise<void> { provide("userLocation", {
if (await this.initializeCurrentUser()) { userLocation,
await initializeCurrentActor(this.$apollo.provider.defaultClient); updateUserLocation,
} });
}
errorCaptured(error: Error): void { // const routerView = ref("routerView");
this.error = error; const error = ref<Error | null>(null);
} const online = ref(true);
const interval = ref<number>(0);
private async initializeCurrentUser() { const notifier = inject<Notifier>("notifier");
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) { interval.value = setInterval(async () => {
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); const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken); const token = jwt_decode<JwtPayload>(accessToken);
@ -164,15 +118,81 @@ export default class App extends Vue {
token?.exp !== undefined && token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date() new Date(token.exp * 1000 - 60000) < new Date()
) { ) {
refreshAccessToken(this.$apollo.getClient()); refreshAccessToken();
} }
} }
}, 60000); }, 60000) as unknown as number;
}
private async refreshApp( 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 registration: ServiceWorkerRegistration
): Promise<any> { ): Promise<any> => {
const worker = registration.waiting; const worker = registration.waiting;
if (!worker) { if (!worker) {
return Promise.resolve(); return Promise.resolve();
@ -192,74 +212,64 @@ export default class App extends Vue {
console.debug("calling skip waiting"); console.debug("calling skip waiting");
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]); worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
}); });
} };
showOfflineNetworkWarning(): void { const showOfflineNetworkWarning = (): void => {
this.$notifier.error(this.$t("You are offline") as string); notifier?.error(t("You are offline"));
} };
// const extractPageTitleFromRoute = (routeWatched: RouteLocation): string => {
// if (routeWatched.meta?.announcer?.message) {
// return routeWatched.meta?.announcer?.message();
// }
// return document.title;
// };
unmounted(): void { // watch(route, (routeWatched) => {
clearInterval(this.interval); // const pageTitle = extractPageTitleFromRoute(routeWatched);
this.interval = undefined; // 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");
@Watch("config") // // Focus element
async initializeStatistics(config: IConfig) { // focusTarget.focus();
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 }) // // Remove tabindex from focustarget.
updateAnnouncement(route: Route): void { // // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
const pageTitle = this.extractPageTitleFromRoute(route); // focusTarget.removeAttribute("tabindex");
if (pageTitle) { // }
this.$announcer.polite( // }, 0);
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 // watch(config, async (configWatched: IConfig) => {
focusTarget.focus(); // if (configWatched) {
// const { statistics } = (await import("./services/statistics")) as {
// statistics: (config: IConfig, environment: Record<string, any>) => void;
// };
// statistics(configWatched, { router, version: configWatched.version });
// }
// });
// Remove tabindex from focustarget. const { isDemoMode } = useIsDemoMode();
// 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: { );
mutate({
refreshToken, refreshToken,
},
}); });
saveTokenData(res.data.refreshToken); onDone(({ data }) => {
if (data?.refreshToken) {
return true; saveTokenData(data?.refreshToken);
} catch (err) { resolve(true);
console.debug("Failed to refresh token");
return false;
} }
reject(false);
});
onError((err) => {
console.debug("Failed to refresh token");
reject(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 => {
if (!props.contact) return null;
if (isContactEmail.value) {
return { return {
uri: `mailto:${this.contact}`, uri: `mailto:${props.contact}`,
text: this.contact, text: props.contact,
}; };
} }
if (this.isContactURL) { if (isContactURL.value) {
return { return {
uri: this.contact, uri: props.contact,
text: text: urlToHostname(props.contact) ?? "Contact",
InstanceContactLink.urlToHostname(this.contact) ||
(this.$t("Contact") as string),
}; };
} }
return null; return null;
} });
get isContactEmail(): boolean { const isContactEmail = computed((): boolean => {
return this.contact.includes("@"); return (props.contact ?? "").includes("@");
} });
get isContactURL(): boolean { const isContactURL = computed((): boolean => {
return this.contact.match(/^https?:\/\//g) !== null; return (props.contact ?? "").match(/^https?:\/\//g) !== null;
} });
static urlToHostname(url: string): string | null { const urlToHostname = (url: string): string | null => {
try { try {
return new URL(url).hostname; return new URL(url).hostname;
} catch (e) { } catch (e) {
return null; 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>
<div> <p class="my-2">
<p class="content">
{{ {{
$t( t(
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want." "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
) )
}} }}
</p> </p>
<hr role="presentation" /> <hr role="presentation" />
<p class="content"> <p class="my-2">
<span> <span>
{{ {{
$t( 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." "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> </span>
<span <i18n-t
v-if="config" keypath="This instance, {instanceName}, hosts your profile, so remember its name."
v-html=" >
$t( <template v-slot:instanceName>
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.', <b>{{
{ t("{instanceName} ({domain})", {
domain, domain,
instanceName: config.name, instanceName,
} })
) }}</b>
" </template>
/> </i18n-t>
</p> </p>
<hr role="presentation" /> <hr role="presentation" />
<p class="content"> <p class="my-2">
{{ {{
$t( 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:" "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> </p>
<div class="has-text-centered"> <div class="text-center">
<code>{{ `${currentActor.preferredUsername}@${domain}` }}</code> <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,105 +1,105 @@
<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">
<template #discussion>
<router-link <router-link
v-if="activity.object" v-if="activity.object"
slot="discussion"
: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>
</template>
<template #old_discussion>
<router-link <router-link
v-if="activity.object && subjectParams.old_discussion_title" v-if="activity.object && subjectParams.old_discussion_title"
slot="old_discussion"
:to="{ :to="{
name: RouteName.DISCUSSION, name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug }, params: { slug: subjectParams.discussion_slug },
}" }"
>{{ subjectParams.old_discussion_title }}</router-link >{{ subjectParams.old_discussion_title }}</router-link
> >
<b <b v-else-if="subjectParams.old_discussion_title">{{
v-else-if="subjectParams.old_discussion_title" subjectParams.old_discussion_title
slot="old_discussion" }}</b>
>{{ subjectParams.old_discussion_title }}</b </template>
> <template #profile>
<popover-actor-card <popover-actor-card :actor="activity.author" :inline="true">
:actor="activity.author"
:inline="true"
slot="profile"
>
<b> <b>
{{ {{
$t("@{username}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
></i18n ></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 { 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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED: case ActivityDiscussionSubject.DISCUSSION_CREATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the discussion {discussion}."; return "You created the discussion {discussion}.";
} }
return "{profile} created the discussion {discussion}."; return "{profile} created the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_REPLIED: case ActivityDiscussionSubject.DISCUSSION_REPLIED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You replied to the discussion {discussion}."; return "You replied to the discussion {discussion}.";
} }
return "{profile} replied to the discussion {discussion}."; return "{profile} replied to the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_RENAMED: case ActivityDiscussionSubject.DISCUSSION_RENAMED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You renamed the discussion from {old_discussion} to {discussion}."; return "You renamed the discussion from {old_discussion} to {discussion}.";
} }
return "{profile} renamed the discussion from {old_discussion} to {discussion}."; return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED: case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You archived the discussion {discussion}."; return "You archived the discussion {discussion}.";
} }
return "{profile} archived the discussion {discussion}."; return "{profile} archived the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_DELETED: case ActivityDiscussionSubject.DISCUSSION_DELETED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You deleted the discussion {discussion}."; return "You deleted the discussion {discussion}.";
} }
return "{profile} deleted the discussion {discussion}."; return "{profile} deleted the discussion {discussion}.";
default: default:
return undefined; 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";
@ -111,8 +111,7 @@ export default class DiscussionActivityItem extends mixins(ActivityMixin) {
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,10 +1,10 @@
<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">
<template #event>
<router-link <router-link
slot="event"
v-if="activity.object" v-if="activity.object"
:to="{ :to="{
name: RouteName.EVENT, name: RouteName.EVENT,
@ -12,84 +12,85 @@
}" }"
>{{ 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}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
></i18n ></template
></i18n-t
> >
<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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED: case ActivityEventSubject.EVENT_CREATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the event {event}."; return "You created the event {event}.";
} }
return "The event {event} was created by {profile}."; return "The event {event} was created by {profile}.";
case ActivityEventSubject.EVENT_UPDATED: case ActivityEventSubject.EVENT_UPDATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You updated the event {event}."; return "You updated the event {event}.";
} }
return "The event {event} was updated by {profile}."; return "The event {event} was updated by {profile}.";
case ActivityEventSubject.EVENT_DELETED: case ActivityEventSubject.EVENT_DELETED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You deleted the event {event}."; return "You deleted the event {event}.";
} }
return "The event {event} was deleted by {profile}."; return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED: case ActivityEventCommentSubject.COMMENT_POSTED:
if (this.subjectParams.comment_reply_to) { if (subjectParams.comment_reply_to) {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You replied to a comment on the event {event}."; return "You replied to a comment on the event {event}.";
} }
return "{profile} replied to a comment on the event {event}."; return "{profile} replied to a comment on the event {event}.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You posted a comment on the event {event}."; return "You posted a comment on the event {event}.";
} }
return "{profile} posted a comment on the event {event}."; return "{profile} posted a comment on the event {event}.";
default: default:
return undefined; 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";
@ -100,8 +101,7 @@ export default class EventActivityItem extends mixins(ActivityMixin) {
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,11 +1,11 @@
<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">
<template #group>
<router-link <router-link
v-if="activity.object" v-if="activity.object"
slot="group"
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { params: {
@ -14,99 +14,101 @@
}" }"
>{{ 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}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
></i18n ></template
></i18n-t
> >
<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"
slot="profile"
>
<b> <b>
{{ {{
$t("@{username}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
> >
</template>
<template #group>
<router-link <router-link
v-if="activity.object" v-if="activity.object"
slot="group"
:to="{ :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>
<template #old_group_name>
<b v-if="subjectParams.old_group_name">{{
subjectParams.old_group_name subjectParams.old_group_name
}}</b> }}</b>
</i18n> </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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED: case ActivityGroupSubject.GROUP_CREATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the group {group}."; return "You created the group {group}.";
} }
return "{profile} created the group {group}."; return "{profile} created the group {group}.";
case ActivityGroupSubject.GROUP_UPDATED: case ActivityGroupSubject.GROUP_UPDATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You updated the group {group}."; return "You updated the group {group}.";
} }
return "{profile} updated the group {group}."; return "{profile} updated the group {group}.";
default: default:
return undefined; return undefined;
} }
} });
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED: case ActivityGroupSubject.GROUP_CREATED:
return "is-success"; return "is-success";
case ActivityGroupSubject.GROUP_UPDATED: case ActivityGroupSubject.GROUP_UPDATED:
@ -114,76 +116,61 @@ export default class GroupActivityItem extends mixins(ActivityMixin) {
default: default:
return undefined; return undefined;
} }
} });
get details(): string[] { const group = computed(() => props.activity.object as IGroup);
const details = [];
const changes = this.subjectParams.group_changes.split(","); const details = computed((): string[] => {
if (changes.includes("name") && this.subjectParams.old_group_name) { const localDetails = [];
details.push("{old_group_name} was renamed to {group}."); const changes = subjectParams.group_changes.split(",");
if (changes.includes("name") && subjectParams.old_group_name) {
localDetails.push("{old_group_name} was renamed to {group}.");
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment if (changes.includes("visibility") && group.value.visibility) {
// @ts-ignore switch (group.value.visibility) {
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: case GroupVisibility.PRIVATE:
details.push("Visibility was set to private."); localDetails.push("Visibility was set to private.");
break; break;
case GroupVisibility.PUBLIC: case GroupVisibility.PUBLIC:
details.push("Visibility was set to public."); localDetails.push("Visibility was set to public.");
break; break;
default: default:
details.push("Visibility was set to an unknown value."); localDetails.push("Visibility was set to an unknown value.");
break; break;
} }
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment if (changes.includes("openness") && group.value.openness) {
// @ts-ignore switch (group.value.openness) {
if (changes.includes("openness") && this.activity.object.openness) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
switch (this.activity.object.openness) {
case Openness.INVITE_ONLY: case Openness.INVITE_ONLY:
details.push("The group can now only be joined with an invite."); localDetails.push("The group can now only be joined with an invite.");
break; break;
case Openness.MODERATED: case Openness.MODERATED:
details.push( localDetails.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator." "The group can now be joined by anyone, but new members need to be approved by an administrator."
); );
break; break;
case Openness.OPEN: case Openness.OPEN:
details.push("The group can now be joined by anyone."); localDetails.push("The group can now be joined by anyone.");
break; break;
default: default:
details.push("Unknown value for the openness setting."); localDetails.push("Unknown value for the openness setting.");
break; break;
} }
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment if (changes.includes("address") && group.value.physicalAddress) {
// @ts-ignore localDetails.push("The group's physical address was changed.");
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 if (changes.includes("avatar") && group.value.avatar) {
// @ts-ignore localDetails.push("The group's avatar was changed.");
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 if (changes.includes("banner") && group.value.banner) {
// @ts-ignore localDetails.push("The group's banner was changed.");
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 if (changes.includes("summary") && group.value.summary) {
// @ts-ignore localDetails.push("The group's short description was changed.");
if (changes.includes("summary") && this.activity.object.summary) {
details.push("The group's short description was changed.");
} }
return details; return localDetails;
} });
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View File

@ -1,102 +1,108 @@
<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">
<template #member>
<popover-actor-card <popover-actor-card
v-if="activity.object" v-if="member"
:actor="activity.object.actor" :actor="member.actor"
:inline="true" :inline="true"
slot="member"
> >
<b> {{ displayName(activity.object.actor) }}</b></popover-actor-card <b> {{ displayName(member.actor) }}</b></popover-actor-card
>
<b slot="member" v-else>{{
subjectParams.member_actor_federated_username
}}</b>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
> >
<b v-else>{{ subjectParams.member_actor_federated_username }}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b> {{ displayName(activity.author) }}</b></popover-actor-card <b> {{ displayName(activity.author) }}</b></popover-actor-card
></i18n ></template
></i18n-t
> >
<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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const member = computed(() => props.activity.object as IMember);
const isObjectMemberCurrentActor = useIsActivityObjectCurrentActor()(
props.activity
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST: case ActivityMemberSubject.MEMBER_REQUEST:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You requested to join the group."; return "You requested to join the group.";
} }
return "{member} requested to join the group."; return "{member} requested to join the group.";
case ActivityMemberSubject.MEMBER_INVITED: case ActivityMemberSubject.MEMBER_INVITED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You invited {member}."; return "You invited {member}.";
} }
return "{member} was invited by {profile}."; return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED: case ActivityMemberSubject.MEMBER_ADDED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You added the member {member}."; return "You added the member {member}.";
} }
return "{profile} added the member {member}."; return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED: case ActivityMemberSubject.MEMBER_APPROVED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You approved {member}'s membership."; return "You approved {member}'s membership.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}."; return "Your membership was approved by {profile}.";
} }
return "{profile} approved {member}'s membership."; return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED: case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group."; return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED: case ActivityMemberSubject.MEMBER_UPDATED:
if (this.subjectParams.member_role && this.subjectParams.old_role) { if (subjectParams.member_role && subjectParams.old_role) {
return this.roleUpdate; return roleUpdate.value;
} }
if (this.isAuthorCurrentActor) { 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: case ActivityMemberSubject.MEMBER_REMOVED:
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) { if (subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You rejected {member}'s membership request."; return "You rejected {member}'s membership request.";
} }
return "{profile} rejected {member}'s membership request."; return "{profile} rejected {member}'s membership request.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You excluded member {member}."; return "You excluded member {member}.";
} }
return "{profile} excluded member {member}."; return "{profile} excluded member {member}.";
@ -105,17 +111,17 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION: case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
return "{member} rejected the invitation to join the group."; return "{member} rejected the invitation to join the group.";
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION: case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You accepted the invitation to join the group."; return "You accepted the invitation to join the group.";
} }
return "{member} accepted the invitation to join the group."; return "{member} accepted the invitation to join the group.";
default: default:
return undefined; return undefined;
} }
} });
get icon(): string { const icon = computed((): string => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST: case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_ADDED: case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED: case ActivityMemberSubject.MEMBER_INVITED:
@ -129,10 +135,10 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
default: default:
return "account-multiple"; return "account-multiple";
} }
} });
get iconColor(): string | undefined { const iconColor = computed((): string | undefined => {
switch (this.activity.subject) { switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_ADDED: case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED: case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_JOINED: case ActivityMemberSubject.MEMBER_JOINED:
@ -149,88 +155,78 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
default: default:
return undefined; return undefined;
} }
} });
get roleUpdate(): string | undefined { const roleUpdate = computed((): string | undefined => {
if ( if (
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.member_role) && Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.member_role) &&
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.old_role) Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.old_role)
) { ) {
if ( if (
MEMBER_ROLE_VALUE[this.subjectParams.member_role] > MEMBER_ROLE_VALUE[subjectParams.member_role] >
MEMBER_ROLE_VALUE[this.subjectParams.old_role] MEMBER_ROLE_VALUE[subjectParams.old_role]
) { ) {
switch (this.subjectParams.member_role) { switch (subjectParams.member_role) {
case MemberRole.MODERATOR: case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You promoted {member} to moderator."; return "You promoted {member} to moderator.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were promoted to moderator by {profile}."; return "You were promoted to moderator by {profile}.";
} }
return "{profile} promoted {member} to moderator."; return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR: case MemberRole.ADMINISTRATOR:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You promoted {member} to administrator."; return "You promoted {member} to administrator.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were promoted to administrator by {profile}."; return "You were promoted to administrator by {profile}.";
} }
return "{profile} promoted {member} to administrator."; return "{profile} promoted {member} to administrator.";
default: default:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You promoted the member {member} to an unknown role."; return "You promoted the member {member} to an unknown role.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were promoted to an unknown role by {profile}."; return "You were promoted to an unknown role by {profile}.";
} }
return "{profile} promoted {member} to an unknown role."; return "{profile} promoted {member} to an unknown role.";
} }
} else { } else {
switch (this.subjectParams.member_role) { switch (subjectParams.member_role) {
case MemberRole.MODERATOR: case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You demoted {member} to moderator."; return "You demoted {member} to moderator.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were demoted to moderator by {profile}."; return "You were demoted to moderator by {profile}.";
} }
return "{profile} demoted {member} to moderator."; return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER: case MemberRole.MEMBER:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You demoted {member} to simple member."; return "You demoted {member} to simple member.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were demoted to simple member by {profile}."; return "You were demoted to simple member by {profile}.";
} }
return "{profile} demoted {member} to simple member."; return "{profile} demoted {member} to simple member.";
default: default:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You demoted the member {member} to an unknown role."; return "You demoted the member {member} to an unknown role.";
} }
if (this.isObjectMemberCurrentActor) { if (isObjectMemberCurrentActor) {
return "You were demoted to an unknown role by {profile}."; return "You were demoted to an unknown role by {profile}.";
} }
return "{profile} demoted {member} to an unknown role."; return "{profile} demoted {member} to an unknown role.";
} }
} }
} else { } else {
if (this.isAuthorCurrentActor) { 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}";
} }
} });
get isObjectMemberCurrentActor(): boolean {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.activity?.object?.actor?.id === this.currentActor?.id &&
this.currentActor?.id !== undefined
);
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./activity.scss"; @import "./activity.scss";

View File

@ -1,81 +1,82 @@
<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">
<template #post>
<router-link <router-link
v-if="activity.object" v-if="activity.object"
slot="post"
: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}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
></i18n ></template
></i18n-t
> >
<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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED: case ActivityPostSubject.POST_CREATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the post {post}."; return "You created the post {post}.";
} }
return "The post {post} was created by {profile}."; return "The post {post} was created by {profile}.";
case ActivityPostSubject.POST_UPDATED: case ActivityPostSubject.POST_UPDATED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You updated the post {post}."; return "You updated the post {post}.";
} }
return "The post {post} was updated by {profile}."; return "The post {post} was updated by {profile}.";
case ActivityPostSubject.POST_DELETED: case ActivityPostSubject.POST_DELETED:
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You deleted the post {post}."; return "You deleted the post {post}.";
} }
return "The post {post} was deleted by {profile}."; return "The post {post} was deleted by {profile}.";
default: default:
return undefined; 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:
@ -85,8 +86,7 @@ export default class PostActivityItem extends mixins(ActivityMixin) {
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,140 +1,146 @@
<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>
<router-link v-if="activity.object" :to="path">{{
subjectParams.resource_title subjectParams.resource_title
}}</router-link> }}</router-link>
<b v-else slot="resource">{{ subjectParams.resource_title }}</b> <b v-else>{{ subjectParams.resource_title }}</b>
<router-link v-if="activity.object" slot="new_path" :to="path">{{ </template>
<template #new_path>
<router-link v-if="activity.object" :to="path">{{
parentDirectory parentDirectory
}}</router-link> }}</router-link>
<b v-else slot="new_path">{{ parentDirectory }}</b> <b v-else>{{ parentDirectory }}</b>
</template>
<template #old_resource_title>
<router-link <router-link
v-if="activity.object && subjectParams.old_resource_title" v-if="activity.object && subjectParams.old_resource_title"
slot="old_resource_title"
:to="path" :to="path"
>{{ subjectParams.old_resource_title }}</router-link >{{ subjectParams.old_resource_title }}</router-link
> >
<b <b v-else-if="subjectParams.old_resource_title">{{
v-else-if="subjectParams.old_resource_title" subjectParams.old_resource_title
slot="old_resource_title" }}</b>
>{{ subjectParams.old_resource_title }}</b </template>
>
<popover-actor-card <template #profile>
:actor="activity.author" <popover-actor-card :actor="activity.author" :inline="true">
:inline="true"
slot="profile"
>
<b> <b>
{{ {{
$t("@{username}", { $t("{'@'}{username}", {
username: usernameWithDomain(activity.author), username: usernameWithDomain(activity.author),
}) })
}}</b }}</b
></popover-actor-card ></popover-actor-card
></i18n ></template
></i18n-t
> >
<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) {
const subjectParams = useActivitySubjectParams()(props.activity);
const resource = computed(() => props.activity.object as IResource);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED: case ActivityResourceSubject.RESOURCE_CREATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
if (this.activity?.object?.type === "folder") { if (props.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the folder {resource}."; return "You created the folder {resource}.";
} }
return "{profile} created the folder {resource}."; return "{profile} created the folder {resource}.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You created the resource {resource}."; return "You created the resource {resource}.";
} }
return "{profile} created the resource {resource}."; return "{profile} created the resource {resource}.";
case ActivityResourceSubject.RESOURCE_MOVED: case ActivityResourceSubject.RESOURCE_MOVED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
if (this.activity?.object?.type === "folder") { if (props.activity?.object?.type === "folder") {
if (this.parentDirectory === null) { if (parentDirectory.value === null) {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You moved the folder {resource} to the root folder."; return "You moved the folder {resource} to the root folder.";
} }
return "{profile} moved the folder {resource} to the root folder."; return "{profile} moved the folder {resource} to the root folder.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You moved the folder {resource} into {new_path}."; return "You moved the folder {resource} into {new_path}.";
} }
return "{profile} moved the folder {resource} into {new_path}."; return "{profile} moved the folder {resource} into {new_path}.";
} }
if (this.parentDirectory === null) { if (parentDirectory.value === null) {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You moved the resource {resource} to the root folder."; return "You moved the resource {resource} to the root folder.";
} }
return "{profile} moved the resource {resource} to the root folder."; return "{profile} moved the resource {resource} to the root folder.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You moved the resource {resource} into {new_path}."; return "You moved the resource {resource} into {new_path}.";
} }
return "{profile} moved the resource {resource} into {new_path}."; return "{profile} moved the resource {resource} into {new_path}.";
case ActivityResourceSubject.RESOURCE_UPDATED: case ActivityResourceSubject.RESOURCE_UPDATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
if (this.activity?.object?.type === "folder") { if (props.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You renamed the folder from {old_resource_title} to {resource}."; return "You renamed the folder from {old_resource_title} to {resource}.";
} }
return "{profile} renamed the folder from {old_resource_title} to {resource}."; return "{profile} renamed the folder from {old_resource_title} to {resource}.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You renamed the resource from {old_resource_title} to {resource}."; return "You renamed the resource from {old_resource_title} to {resource}.";
} }
return "{profile} renamed the resource from {old_resource_title} to {resource}."; return "{profile} renamed the resource from {old_resource_title} to {resource}.";
case ActivityResourceSubject.RESOURCE_DELETED: case ActivityResourceSubject.RESOURCE_DELETED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
if (this.activity?.object?.type === "folder") { if (props.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You deleted the folder {resource}."; return "You deleted the folder {resource}.";
} }
return "{profile} deleted the folder {resource}."; return "{profile} deleted the folder {resource}.";
} }
if (this.isAuthorCurrentActor) { if (isAuthorCurrentActor) {
return "You deleted the resource {resource}."; return "You deleted the resource {resource}.";
} }
return "{profile} deleted the resource {resource}."; return "{profile} deleted the resource {resource}.";
default: default:
return undefined; 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:
@ -145,45 +151,43 @@ export default class ResourceActivityItem extends mixins(ActivityMixin) {
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 { return {
name: RouteName.RESOURCE_FOLDER_ROOT, name: RouteName.RESOURCE_FOLDER_ROOT,
params: { params: {
preferredUsername: usernameWithDomain(this.activity.group), preferredUsername: usernameWithDomain(props.activity.group),
}, },
}; };
} }
return { return {
name: RouteName.RESOURCE_FOLDER, name: RouteName.RESOURCE_FOLDER,
params: { params: {
path, path: localPath,
preferredUsername: usernameWithDomain(this.activity.group), 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(/^\//, "");
};
const parentDirectory = computed((): string | undefined | null => {
if (subjectParams.resource_path) {
const parentPathResult = parentPath(subjectParams.resource_path);
const directory = parentPathResult?.split("/");
const res = directory?.pop();
res === "" ? null : res; res === "" ? null : res;
} }
return null; return null;
} });
parentPath(parent: string): string {
const path = parent.split("/");
path.pop();
return path.join("/").replace(/^\//, "");
}
}
</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;
userTimezone?: string;
}>(),
{
showIcon: false,
showTimezone: false,
}
);
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean; const poiInfos = computed(() => addressToPoiInfos(props.address));
@Prop({ required: false, default: false, type: Boolean })
showTimezone!: boolean;
@Prop({ required: false, type: String }) userTimezone!: string;
get userTimezoneDifferent(): boolean { const userTimezoneDifferent = computed((): boolean => {
return ( return (
this.userTimezone != undefined && props.userTimezone != undefined &&
this.address.timezone != undefined && props.address.timezone != undefined &&
this.userTimezone !== this.address.timezone props.userTimezone !== props.address.timezone
); );
} });
get longShortTimezoneNamesDifferent(): boolean { const longShortTimezoneNamesDifferent = computed((): boolean => {
return ( return (
this.timezoneLongName != undefined && timezoneLongName.value != undefined &&
this.timezoneShortName != undefined && timezoneShortName.value != undefined &&
this.timezoneLongName !== this.timezoneShortName timezoneLongName.value !== timezoneShortName.value
); );
} });
get timezoneLongName(): string | undefined { const timezoneLongName = computed((): string | undefined => {
return this.timezoneName("long"); return timezoneName("long");
} });
get timezoneShortName(): string | undefined { const timezoneShortName = computed((): string | undefined => {
return this.timezoneName("short"); return timezoneName("short");
} });
get timezoneLongNameValid(): boolean { const timezoneLongNameValid = computed((): boolean => {
return ( return (
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/) timezoneLongName.value != undefined && !timezoneLongName.value.match(/UTC/)
); );
} });
private timezoneName(format: "long" | "short"): string | undefined { const timezoneName = (format: "long" | "short"): string | undefined => {
return this.extractTimezone( return extractTimezone(
new Intl.DateTimeFormat(undefined, { new Intl.DateTimeFormat(undefined, {
timeZoneName: format, timeZoneName: format,
timeZone: this.address.timezone, timeZone: props.address.timezone,
}).formatToParts() }).formatToParts()
); );
} };
private extractTimezone( const extractTimezone = (
parts: Intl.DateTimeFormatPart[] parts: Intl.DateTimeFormatPart[]
): string | undefined { ): string | undefined => {
return parts.find((part) => part.type === "timeZoneName")?.value; 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">
<div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1" v-if="actorComment">
<popover-actor-card <popover-actor-card
:actor="comment.actor" :actor="actorComment"
:inline="true" :inline="true"
v-if="comment.actor" v-if="!comment.deletedAt && actorComment.avatar"
> >
<figure <figure>
class="image is-32x32 media-left" <img
v-if="!comment.deletedAt && comment.actor.avatar" class="rounded-xl"
> :src="actorComment.avatar.url"
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> alt=""
width="24"
height="24"
/>
</figure> </figure>
<b-icon class="media-left" v-else icon="account-circle" />
</popover-actor-card> </popover-actor-card>
<div v-else class="media-left"> <AccountCircle v-else />
<figure <strong
class="image is-32x32" v-if="!comment.deletedAt"
v-if="!comment.deletedAt && comment.actor.avatar" dir="auto"
:class="{ organizer: commentFromOrganizer }"
>{{ actorComment?.name }}</strong
> >
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
</div> </div>
<div class="media-content">
<div class="content"> <a v-else :href="commentURL">
<span class="first-line" v-if="!comment.deletedAt" dir="auto"> <span>{{ t("[deleted]") }}</span>
<strong :class="{ organizer: commentFromOrganizer }">{{
comment.actor.name
}}</strong>
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small>
</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> </div>
<br /> </div>
<div <div
v-if="!comment.deletedAt" v-if="!comment.deletedAt"
v-html="comment.text" v-html="comment.text"
dir="auto" dir="auto"
:lang="comment.language" :lang="comment.language"
/> />
<div v-else>{{ $t("[This comment has been deleted]") }}</div> <div v-else>{{ t("[This comment has been deleted]") }}</div>
<div class="load-replies" v-if="comment.totalReplies"> <div class="" v-if="comment.totalReplies">
<p v-if="!showReplies" @click="fetchReplies"> <p
<b-icon icon="chevron-down" class="reply-btn" /> v-if="!showReplies"
<span class="reply-btn">{{ @click="showReplies = true"
$tc("View a reply", comment.totalReplies, { class="flex cursor-pointer"
>
<ChevronDown />
<span>{{
t(
"View a reply",
{
totalReplies: comment.totalReplies, totalReplies: comment.totalReplies,
}) },
comment.totalReplies
)
}}</span> }}</span>
</p> </p>
<p <p
v-else-if="comment.totalReplies && showReplies" v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false" @click="showReplies = false"
class="flex cursor-pointer"
> >
<b-icon icon="chevron-up" class="reply-btn" /> <ChevronUp />
<span class="reply-btn">{{ $t("Hide replies") }}</span> <span>{{ t("Hide replies") }}</span>
</p> </p>
</div> </div>
</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
" "
>
<div class="level-left">
<span
style="cursor: pointer"
class="level-item reply-btn"
@click="createReplyToComment()" @click="createReplyToComment()"
class="flex gap-1 cursor-pointer"
> >
<span class="icon is-small"> <Reply />
<b-icon icon="reply" /> <span>{{ t("Reply") }}</span>
</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=""
</figure> width="48"
<b-icon height="48"
class="media-left" class="rounded-md"
v-else
size="is-large"
icon="account-circle"
/> />
<div class="media-content"> </figure>
<div class="content"> <AccountCircle v-else :size="48" />
<span class="first-line"> <div class="flex-1">
<strong>{{ currentActor.name }}</strong> <div class="flex gap-1 items-center">
<small dir="ltr">@{{ currentActor.preferredUsername }}</small> <strong>{{ currentActor?.name }}</strong>
</span> <small dir="ltr">@{{ currentActor?.preferredUsername }}</small>
<br /> </div>
<span class="editor-line"> <div class="flex flex-col gap-2">
<editor <editor
class="editor"
ref="commentEditor" ref="commentEditor"
v-model="newComment.text" v-model="newComment.text"
mode="comment" mode="comment"
:aria-label="$t('Comment body')" :current-actor="currentActor"
:aria-label="t('Comment body')"
class="flex-1"
/> />
<b-button <o-button
:disabled="newComment.text.trim().length === 0" :disabled="newComment.text.trim().length === 0"
native-type="submit" native-type="submit"
type="is-primary" variant="primary"
>{{ $t("Post a reply") }}</b-button class="self-end"
>{{ t("Post a reply") }}</o-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; replyTo.value = true;
if (this.comment.actor) { if (props.comment.actor) {
this.commentEditor.replyToComment(this.comment.actor); commentEditor.value?.replyToComment(props.comment.actor);
await this.$nextTick; // wait for the mention to be injected await nextTick(); // wait for the mention to be injected
this.commentEditor.focus(); 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;
}
get commentFromOrganizer(): boolean {
const organizerId = const organizerId =
this.event?.organizerActor?.id || this.event?.attributedTo?.id; props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && this.comment?.actor?.id === organizerId; return organizerId !== undefined && props.comment?.actor?.id === organizerId;
} });
get commentId(): string { const commentId = computed((): string => {
if (this.comment.originComment) if (props.comment.originComment)
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`; return `comment-${props.comment.originComment.uuid}-${props.comment.uuid}`;
return `comment-${this.comment.uuid}`; return `comment-${props.comment.uuid}`;
} });
get commentURL(): string { const commentURL = computed((): string => {
if (!this.comment.local && this.comment.url) return this.comment.url; if (!props.comment.local && props.comment.url) return props.comment.url;
return `#${this.commentId}`; return `#${commentId.value}`;
} });
reportModal(): void { const reportModal = (): void => {
if (!this.comment.actor) return; if (!props.comment.actor) return;
this.$buefy.modal.open({ emit("report-comment", props.comment);
parent: this, // this.$buefy.modal.open({
component: ReportModal, // component: ReportModal,
props: { // props: {
title: this.$t("Report this comment"), // title: t("Report this comment"),
comment: this.comment, // comment: props.comment,
onConfirm: this.reportComment, // onConfirm: reportComment,
outsideDomain: this.comment.actor.domain, // outsideDomain: props.comment.actor?.domain,
}, // },
// https://github.com/buefy/buefy/pull/3589 // // https://github.com/buefy/buefy/pull/3589
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // // @ts-ignore
closeButtonAriaLabel: this.$t("Close"), // closeButtonAriaLabel: this.t("Close"),
}); // });
} };
async reportComment(content: string, forward: boolean): Promise<void> { // const reportComment = async (
try { // content: string,
if (!this.comment.actor) return; // forward: boolean
await this.$apollo.mutate<IReport>({ // ): Promise<void> => {
mutation: CREATE_REPORT, // try {
variables: { // if (!props.comment.actor) return;
eventId: this.event.id,
reportedId: this.comment.actor.id, // const { onError, onDone } = useMutation(CREATE_REPORT, () => ({
commentsIds: [this.comment.id], // variables: {
content, // eventId: props.event.id,
forward, // reportedId: props.comment.actor?.id,
}, // commentsIds: [props.comment.id],
}); // content,
this.$buefy.notification.open({ // forward,
message: this.$t("Comment from @{username} reported", { // },
username: this.comment.actor.preferredUsername, // }));
}) as string,
type: "is-success", // // this.$buefy.notification.open({
position: "is-bottom-right", // // message: this.t("Comment from @{username} reported", {
duration: 5000, // // username: this.comment.actor.preferredUsername,
}); // // }) as string,
} catch (e: any) { // // type: "is-success",
if (e.message) { // // position: "is-bottom-right",
Snackbar.open({ // // duration: 5000,
message: e.message, // // });
type: "is-danger", // } catch (e: any) {
position: "is-bottom", // if (e.message) {
}); // // Snackbar.open({
} // // 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"
:currentActor="currentActor"
mode="comment" mode="comment"
v-model="newComment.text" v-model="newComment.text"
:aria-label="$t('Comment body')" :aria-label="t('Comment body')"
/> />
</p> <p class="" v-if="emptyCommentError">
<p class="help is-danger" 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,86 +93,80 @@ 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,
comments: {
query: COMMENTS_THREADS_WITH_REPLIES,
variables() {
return {
eventUUID: this.event.uuid,
};
},
update: (data) => data.event.comments,
skip() {
return !this.event.uuid;
},
},
},
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(); const { result: commentsResult, loading: commentsLoading } = useQuery<{
event: Pick<IEvent, "id" | "uuid" | "comments">;
}>(
COMMENTS_THREADS_WITH_REPLIES,
() => ({ eventUUID: props.event?.uuid }),
() => ({ enabled: props.event?.uuid !== undefined })
);
currentActor!: IPerson; const comments = computed(() => commentsResult.value?.event.comments ?? []);
comments: IComment[] = []; const props = defineProps<{
event: IEvent;
newComment?: IComment;
}>();
CommentModeration = CommentModeration; const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
emptyCommentError = false; const newComment = ref<IComment>(props.newComment ?? new CommentModel());
@Watch("currentActor") const emptyCommentError = ref(false);
watchCurrentActor(currentActor: IPerson): void {
this.newComment.actor = currentActor; 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);
} }
});
@Watch("newComment", { deep: true }) const {
resetEmptyCommentError(newComment: IComment): void { mutate: createCommentForEventMutation,
if (this.emptyCommentError) { onDone: createCommentForEventMutationDone,
this.emptyCommentError = ["", "<p></p>"].includes(newComment.text); onError: createCommentForEventMutationError,
} = useMutation<
{ createComment: IComment },
{
eventId: string;
text: string;
inReplyToCommentId?: string;
isAnnouncement?: boolean;
originCommentId?: string | undefined;
} }
} >(CREATE_COMMENT_FROM_EVENT, () => ({
update: (
async createCommentForEvent(comment: IComment): Promise<void> { store: ApolloCache<InMemoryCache>,
this.emptyCommentError = ["", "<p></p>"].includes(comment.text); { data }: FetchResult,
if (this.emptyCommentError) return; { variables }
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; if (data == null) return;
// comments are attached to the event, so we can pass it to replies later // comments are attached to the event, so we can pass it to replies later
const newComment = { ...data.createComment, event: this.event }; const newCommentLocal = { ...data.createComment, event: props.event };
// we load all existing threads // we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({ const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES, query: COMMENTS_THREADS_WITH_REPLIES,
variables: { variables: {
eventUUID: this.event.uuid, eventUUID: props.event?.uuid,
}, },
}); });
if (!commentThreadsData) return; if (!commentThreadsData) return;
@ -183,21 +175,20 @@ export default class CommentTree extends Vue {
// if it's no a root comment, we first need to find // if it's no a root comment, we first need to find
// existing replies and add the new reply to it // existing replies and add the new reply to it
if (comment.originComment !== undefined) { if (variables?.originCommentId !== undefined) {
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex( const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id (oldComment) => oldComment.id === variables.originCommentId
); );
const parentComment = oldComments[parentCommentIndex]; const parentComment = oldComments[parentCommentIndex];
// replace the root comment with has the updated list of replies in the thread list // replace the root comment with has the updated list of replies in the thread list
oldComments.splice(parentCommentIndex, 1, { oldComments.splice(parentCommentIndex, 1, {
...parentComment, ...parentComment,
replies: [...parentComment.replies, newComment], replies: [...parentComment.replies, newCommentLocal],
}); });
} else { } else {
// otherwise it's simply a new thread and we add it to the list // otherwise it's simply a new thread and we add it to the list
oldComments.push(newComment); oldComments.push(newCommentLocal);
} }
// finally we save the thread list // finally we save the thread list
@ -210,52 +201,72 @@ export default class CommentTree extends Vue {
}, },
}, },
variables: { variables: {
eventUUID: this.event.uuid, eventUUID: props.event?.uuid,
}, },
}); });
}, },
}); }));
createCommentForEventMutationDone(() => {
// and reset the new comment field // and reset the new comment field
this.newComment = new CommentModel(); newComment.value = new CommentModel();
} catch (errors: any) { });
const notifier = inject<Notifier>("notifier");
createCommentForEventMutationError((errors) => {
console.error(errors); console.error(errors);
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) { if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
const error = errors.graphQLErrors[0]; const error = errors.graphQLErrors[0] as AbsintheGraphQLError;
if (error.field !== "text" && error.message[0] !== "can't be blank") { if (error.field !== "text" && error.message[0] !== "can't be blank") {
this.$notifier.error(error.message); notifier?.error(error.message);
}
}
} }
} }
});
async deleteComment(comment: IComment): Promise<void> { const createCommentForEvent = (comment: IComment) => {
try { emptyCommentError.value = ["", "<p></p>"].includes(comment.text);
await this.$apollo.mutate({
mutation: DELETE_COMMENT, if (emptyCommentError.value) return;
variables: { if (!comment.actor) return;
commentId: comment.id, if (!props.event?.id) return;
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => { 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; if (data == null) return;
const deletedCommentId = data.deleteComment.id; const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({ const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES, query: COMMENTS_THREADS_WITH_REPLIES,
variables: { variables: {
eventUUID: this.event.uuid, eventUUID: props.event?.uuid,
}, },
}); });
if (!commentsData) return; if (!commentsData) return;
const { event } = commentsData; const { event } = commentsData;
let updatedComments: IComment[] = [...event.comments]; let updatedComments: IComment[] = [...event.comments];
if (comment.originComment) { if (variables?.originCommentId) {
// we have deleted a reply to a thread // we have deleted a reply to a thread
const { originComment } = comment;
const parentCommentIndex = updatedComments.findIndex( const parentCommentIndex = updatedComments.findIndex(
(oldComment) => oldComment.id === originComment.id (oldComment) => oldComment.id === variables.originCommentId
); );
const parentComment = updatedComments[parentCommentIndex]; const parentComment = updatedComments[parentCommentIndex];
const updatedReplies = parentComment.replies.map((reply) => { const updatedReplies = parentComment.replies.map((reply) => {
@ -288,7 +299,7 @@ export default class CommentTree extends Vue {
store.writeQuery({ store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES, query: COMMENTS_THREADS_WITH_REPLIES,
variables: { variables: {
eventUUID: this.event.uuid, eventUUID: props.event?.uuid,
}, },
data: { data: {
event: { event: {
@ -298,20 +309,19 @@ export default class CommentTree extends Vue {
}, },
}); });
}, },
}); }));
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
} catch (error: any) { deleteCommentMutationError((error) => {
console.error(error); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) { if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message); notifier?.error(error.graphQLErrors[0].message);
}
}
} }
});
get orderedComments(): IComment[] { const orderedComments = computed((): IComment[] => {
return this.comments return comments.value
.filter((comment) => comment.inReplyToComment == null) .filter((comment: IComment) => comment.inReplyToComment == null)
.sort((a, b) => { .sort((a: IComment, b: IComment) => {
if (a.isAnnouncement !== b.isAnnouncement) { if (a.isAnnouncement !== b.isAnnouncement) {
return ( return (
(b.isAnnouncement === true ? 1 : 0) - (b.isAnnouncement === true ? 1 : 0) -
@ -320,8 +330,7 @@ export default class CommentTree extends Vue {
} }
if (a.publishedAt && b.publishedAt) { if (a.publishedAt && b.publishedAt) {
return ( return (
new Date(b.publishedAt).getTime() - new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
new Date(a.publishedAt).getTime()
); );
} else if (a.updatedAt && b.updatedAt) { } else if (a.updatedAt && b.updatedAt) {
return ( return (
@ -330,93 +339,92 @@ export default class CommentTree extends Vue {
} }
return 0; return 0;
}); });
} });
get filteredOrderedComments(): IComment[] { const filteredOrderedComments = computed((): IComment[] => {
return this.orderedComments.filter( return orderedComments.value.filter(
(comment) => !comment.deletedAt || comment.totalReplies > 0 (comment) => !comment.deletedAt || comment.totalReplies > 0
); );
} });
get isEventOrganiser(): boolean { const isEventOrganiser = computed((): boolean => {
const organizerId = const organizerId =
this.event?.organizerActor?.id || this.event?.attributedTo?.id; props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && this.currentActor?.id === organizerId; return organizerId !== undefined && currentActor.value?.id === organizerId;
} });
get areCommentsClosed(): boolean { const areCommentsClosed = computed((): boolean => {
return ( return (
this.currentActor.id !== undefined && currentActor.value?.id !== undefined &&
this.event.options.commentModeration !== CommentModeration.CLOSED props.event?.options.commentModeration !== CommentModeration.CLOSED
); );
} });
get isAbleToComment(): boolean { const isAbleToComment = computed((): boolean => {
if (this.isConnected) { if (isConnected.value) {
return this.areCommentsClosed || this.isEventOrganiser; return areCommentsClosed.value || isEventOrganiser.value;
} }
return false; return false;
} });
get isConnected(): boolean { const isConnected = computed((): boolean => {
return this.currentActor?.id != undefined; return currentActor.value?.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, };
const updateComment = (): void => {
emit("update:modelValue", {
...comment.value,
text: updatedComment.value,
}); });
this.toggleEditMode(); 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,61 +217,62 @@ 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;
}) }>(),
export default class EditorComponent extends Vue { {
@Prop({ required: true }) value!: string; mode: "description",
maxSize: 100_000_000,
@Prop({ required: false, default: "description" }) mode!: string;
@Prop({ required: false, default: 100_000_000 }) maxSize!: number;
@Prop({ required: false }) ariaLabel!: string;
currentActor!: IPerson;
editor: Editor | null = null;
get isDescriptionMode(): boolean {
return this.mode === "description" || this.isBasicMode;
} }
);
get isCommentMode(): boolean { const emit = defineEmits(["update:modelValue"]);
return this.mode === "comment";
}
get isShortMode(): boolean { const editor = ref<Editor | null>(null);
return this.isBasicMode;
}
get isBasicMode(): boolean { const isDescriptionMode = computed((): boolean => {
return this.mode === "basic"; return props.mode === "description" || isBasicMode.value;
} });
// eslint-disable-next-line const isCommentMode = computed((): boolean => {
insertMention(obj: { range: any; attrs: any }) { 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"); console.log("initialize Mention");
} };
observer!: MutationObserver | null; const observer = ref<MutationObserver | null>(null);
mounted(): void { onMounted(() => {
this.editor = new Editor({ editor.value = new Editor({
editorProps: { editorProps: {
attributes: { attributes: {
"aria-multiline": this.isShortMode.toString(), "aria-multiline": isShortMode.value.toString(),
"aria-label": this.ariaLabel, "aria-label": props.ariaLabel ?? "",
role: "textbox", role: "textbox",
}, },
transformPastedHTML: this.transformPastedHTML, transformPastedHTML: transformPastedHTML,
}, },
extensions: [ extensions: [
Blockquote, Blockquote,
@ -299,66 +298,75 @@ export default class EditorComponent extends Vue {
}), }),
], ],
injectCSS: false, injectCSS: false,
content: this.value, content: props.modelValue,
onUpdate: () => { onUpdate: () => {
this.$emit("input", this.editor?.getHTML()); emit("update:modelValue", editor.value?.getHTML());
}, },
}); });
} });
transformPastedHTML(html: string): string { const transformPastedHTML = (html: string): string => {
// When using comment mode, limit to acceptable tags // When using comment mode, limit to acceptable tags
if (this.isCommentMode) { if (isCommentMode.value) {
return sanitizeHtml(html, { // return sanitizeHtml(html, {
allowedTags: ["b", "i", "em", "strong", "a"], // allowedTags: ["b", "i", "em", "strong", "a"],
allowedAttributes: { // allowedAttributes: {
a: ["href", "rel", "target"], // 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;
if (val !== this.editor.getHTML()) {
this.editor.commands.setContent(val, false);
}
}
/** watch(value, (val: string) => {
if (!editor.value) return;
if (val !== editor.value.getHTML()) {
editor.value.commands.setContent(val, false);
}
});
const dialog = inject<Dialog>("dialog");
const { t } = useI18n({ useScope: "global" });
/**
* Show a popup to get the link from the URL * Show a popup to get the link from the URL
*/ */
showLinkMenu(): void { const showLinkMenu = (): void => {
this.$buefy.dialog.prompt({ dialog?.prompt({
message: this.$t("Enter the link URL") as string, message: t("Enter the link URL"),
inputAttrs: { inputAttrs: {
type: "url", type: "url",
}, },
trapFocus: true, onConfirm: (prompt: string) => {
onConfirm: (value) => { if (!editor.value) return;
if (!this.editor) return undefined; editor.value.chain().focus().setLink({ href: prompt }).run();
this.editor.chain().focus().setLink({ href: value }).run();
}, },
}); });
} };
/** const {
mutate: uploadMediaMutation,
onDone: uploadMediaDone,
onError: uploadMediaError,
} = useMutation(UPLOAD_MEDIA);
/**
* Show a file prompt, upload picture and insert it into editor * Show a file prompt, upload picture and insert it into editor
*/ */
async showImagePrompt(): Promise<void> { const showImagePrompt = async (): Promise<void> => {
const image = await listenFileUpload(); const image = await listenFileUpload();
try { uploadMediaMutation({
const { data } = await this.$apollo.mutate({
mutation: UPLOAD_MEDIA,
variables: {
file: image, file: image,
name: image.name, name: image.name,
},
}); });
if (data.uploadMedia && data.uploadMedia.url && this.editor) { };
this.editor
uploadMediaDone(({ data }) => {
if (data.uploadMedia && data.uploadMedia.url && editor.value) {
editor.value
.chain() .chain()
.focus() .focus()
.setImage({ .setImage({
@ -369,20 +377,23 @@ export default class EditorComponent extends Vue {
}) })
.run(); .run();
} }
} catch (error: any) { });
const notifier = inject<Notifier>("notifier");
uploadMediaError((error) => {
console.error(error); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) { if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message); notifier?.error(error.graphQLErrors[0].message);
}
}
} }
});
/** /**
* We use this to programatically insert an actor mention when creating a reply to comment * We use this to programatically insert an actor mention when creating a reply to comment
*/ */
replyToComment(actor: IActor): void { const replyToComment = (actor: IActor): void => {
if (!this.editor) return; if (!editor.value) return;
this.editor editor.value
.chain() .chain()
.focus() .focus()
.insertContent({ .insertContent({
@ -393,16 +404,17 @@ export default class EditorComponent extends Vue {
}) })
.insertContent(" ") .insertContent(" ")
.run(); .run();
} };
focus(): void { const focus = (): void => {
this.editor?.chain().focus("end"); editor.value?.chain().focus("end");
} };
beforeDestroy(): void { defineExpose({ replyToComment, focus });
this.editor?.destroy();
} 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 } }>(
UPLOAD_MEDIA,
() => ({
variables: { variables: {
file: image, file: image,
name: image.name, 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)(() => {
const fetchItems = async (query: string): Promise<IPerson[]> => { return useQuery<{ searchPersons: Paginate<IPerson> }>(
const result = await client.query({ SEARCH_PERSONS,
query: SEARCH_PERSONS, () => ({
variables: { variables: {
searchText: query, searchText: query,
}, },
})
);
}); });
// TipTap doesn't handle async for onFilter, hence the following line.
return result.data.searchPersons.elements; onResult(({ data }) => {
resolve(data.searchPersons.elements);
});
onError(reject);
});
// // 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; });
}
onKeyDown({ event }: { event: KeyboardEvent }): boolean { const onKeyDown = ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") { if (event.key === "ArrowUp") {
this.upHandler(); upHandler();
return true; return true;
} }
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
this.downHandler(); downHandler();
return true; return true;
} }
if (event.key === "Enter") { if (event.key === "Enter") {
this.enterHandler(); enterHandler();
return true; return true;
} }
return false; return false;
} };
upHandler(): void { const upHandler = (): void => {
this.selectedIndex = selectedIndex.value =
(this.selectedIndex + this.items.length - 1) % this.items.length; (selectedIndex.value + props.items.length - 1) % props.items.length;
} };
downHandler(): void { const downHandler = (): void => {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length; selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
} };
enterHandler(): void { const enterHandler = (): void => {
this.selectItem(this.selectedIndex); selectItem(selectedIndex.value);
} };
selectItem(index: number): void { const selectItem = (index: number): void => {
const item = this.items[index]; const item = props.items[index];
if (item) { if (item) {
this.command({ id: usernameWithDomain(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