Migrate to Vue 3 and Vite
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
|
@ -1,2 +1,2 @@
|
||||||
elixir 1.13.4-otp-24
|
elixir 1.13.4-otp-25
|
||||||
erlang 24.3.3
|
erlang 25.0.3
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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__)
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
@ -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
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
presets: ["@vue/cli-plugin-babel/preset"],
|
|
||||||
};
|
|
1
js/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="histoire/vue" />
|
51
js/histoire.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
js/public/img/categories/arts-small.jpg
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
js/public/img/categories/arts.jpg
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
js/public/img/categories/business-small.jpg
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
js/public/img/categories/business.jpg
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
js/public/img/categories/crafts-small.jpg
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
js/public/img/categories/crafts.jpg
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
js/public/img/categories/film_media-small.jpg
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
js/public/img/categories/film_media.jpg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
js/public/img/categories/food_drink-small.jpg
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
js/public/img/categories/food_drink.jpg
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
js/public/img/categories/games-small.jpg
Normal file
After Width: | Height: | Size: 8 KiB |
BIN
js/public/img/categories/games.jpg
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
js/public/img/categories/health-small.jpg
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
js/public/img/categories/health.jpg
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
js/public/img/categories/lgbtq-small.jpg
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
js/public/img/categories/lgbtq.jpg
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
js/public/img/categories/movements_politics-small.jpg
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
js/public/img/categories/movements_politics.jpg
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
js/public/img/categories/music-small.jpg
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
js/public/img/categories/music.jpg
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
js/public/img/categories/outdoors_adventure-small.jpg
Normal file
After Width: | Height: | Size: 8.6 KiB |
BIN
js/public/img/categories/outdoors_adventure.jpg
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
js/public/img/categories/party-small.jpg
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
js/public/img/categories/party.jpg
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
js/public/img/categories/photography-small.jpg
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
js/public/img/categories/photography.jpg
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
js/public/img/categories/spirituality_religion_beliefs-small.jpg
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
js/public/img/categories/spirituality_religion_beliefs.jpg
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
js/public/img/categories/sports-small.jpg
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
js/public/img/categories/sports.jpg
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
js/public/img/categories/theatre-small.jpg
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
js/public/img/categories/theatre.jpg
Normal file
After Width: | Height: | Size: 54 KiB |
10
js/public/img/shape-1.svg
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!--?xml version="1.0" standalone="no"?-->
|
||||||
|
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
|
||||||
|
<stop id="stop1" stop-color="rgba(255, 231.287, 78.545, 0.3)" offset="0%"></stop>
|
||||||
|
<stop id="stop2" stop-color="rgba(254.848, 165.324, 149.009, 0.25)" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#sw-gradient)" d="M18.2,-13.5C23.5,-7.8,27.8,-0.2,27.7,8.7C27.5,17.6,22.9,27.8,14.1,33.9C5.3,39.9,-7.8,41.6,-17.7,36.8C-27.6,32,-34.2,20.7,-37.1,8.4C-39.9,-3.9,-39,-17.2,-32.2,-23.2C-25.4,-29.3,-12.7,-28.3,-3.1,-25.8C6.4,-23.3,12.8,-19.3,18.2,-13.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1,015 B |
10
js/public/img/shape-2.svg
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!--?xml version="1.0" standalone="no"?-->
|
||||||
|
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
|
||||||
|
<stop id="stop1" stop-color="rgba(181.058, 255, 167.816, 0.2)" offset="0%"></stop>
|
||||||
|
<stop id="stop2" stop-color="rgba(149.009, 254.848, 251.263, 0.25)" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#sw-gradient)" d="M20.2,-14.3C28.2,-6,38.3,2.5,37.6,9.8C36.9,17.1,25.5,23.1,15.5,25.2C5.6,27.3,-2.9,25.4,-11.2,21.9C-19.6,18.4,-27.9,13.3,-30.8,5.6C-33.7,-2.1,-31.2,-12.5,-25.2,-20.4C-19.1,-28.3,-9.6,-33.7,-1.8,-32.3C6.1,-30.9,12.1,-22.7,20.2,-14.3Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1,016 B |
10
js/public/img/shape-3.svg
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!--?xml version="1.0" standalone="no"?-->
|
||||||
|
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
|
||||||
|
<stop id="stop1" stop-color="rgba(172.198, 167.816, 255, 0.2)" offset="0%"></stop>
|
||||||
|
<stop id="stop2" stop-color="rgba(236.8, 149.009, 254.848, 0.25)" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#sw-gradient)" d="M25.3,-21.5C29.4,-15.2,26.8,-4.8,23.6,3.8C20.4,12.5,16.5,19.4,10.2,23.2C3.9,27,-4.8,27.6,-12.6,24.5C-20.3,21.4,-27,14.6,-30.1,5.6C-33.2,-3.4,-32.6,-14.4,-26.9,-21.1C-21.3,-27.8,-10.7,-30.1,0,-30.1C10.7,-30.1,21.3,-27.8,25.3,-21.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1,013 B |
|
@ -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>
|
|
454
js/src/App.vue
|
@ -1,40 +1,37 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="mobilizon">
|
<div id="mobilizon">
|
||||||
<VueAnnouncer />
|
<!-- <VueAnnouncer />
|
||||||
<VueSkipTo to="#main" :label="$t('Skip to main content')" />
|
<VueSkipTo to="#main" :label="t('Skip to main content')" /> -->
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<div v-if="config && config.demoMode">
|
<div v-if="isDemoMode">
|
||||||
<b-message
|
<o-notification
|
||||||
class="container"
|
class="container mx-auto"
|
||||||
type="is-danger"
|
variant="danger"
|
||||||
:title="$t('Warning').toLocaleUpperCase()"
|
:title="t('Warning').toLocaleUpperCase()"
|
||||||
closable
|
closable
|
||||||
:aria-close-label="$t('Close')"
|
:aria-close-label="t('Close')"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{{ $t("This is a demonstration site to test Mobilizon.") }}
|
{{ t("This is a demonstration site to test Mobilizon.") }}
|
||||||
<b>{{ $t("Please do not use it in any real way.") }}</b>
|
<b>{{ t("Please do not use it in any real way.") }}</b>
|
||||||
{{
|
{{
|
||||||
$t(
|
t(
|
||||||
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
|
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</b-message>
|
</o-notification>
|
||||||
</div>
|
</div>
|
||||||
<error v-if="error" :error="error" />
|
<ErrorComponent v-if="error" :error="error" />
|
||||||
|
|
||||||
<main id="main" v-else>
|
<main id="main" class="pt-4" v-else>
|
||||||
<transition name="fade" mode="out-in">
|
<router-view></router-view>
|
||||||
<router-view ref="routerView" />
|
|
||||||
</transition>
|
|
||||||
</main>
|
</main>
|
||||||
<mobilizon-footer />
|
<mobilizon-footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
|
|
||||||
import NavBar from "./components/NavBar.vue";
|
import NavBar from "./components/NavBar.vue";
|
||||||
import {
|
import {
|
||||||
AUTH_ACCESS_TOKEN,
|
AUTH_ACCESS_TOKEN,
|
||||||
|
@ -42,224 +39,237 @@ import {
|
||||||
AUTH_USER_ID,
|
AUTH_USER_ID,
|
||||||
AUTH_USER_ROLE,
|
AUTH_USER_ROLE,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import {
|
import { UPDATE_CURRENT_USER_CLIENT } from "./graphql/user";
|
||||||
CURRENT_USER_CLIENT,
|
import MobilizonFooter from "./components/Footer.vue";
|
||||||
UPDATE_CURRENT_USER_CLIENT,
|
|
||||||
} from "./graphql/user";
|
|
||||||
import Footer from "./components/Footer.vue";
|
|
||||||
import Logo from "./components/Logo.vue";
|
|
||||||
import { initializeCurrentActor } from "./utils/auth";
|
|
||||||
import { CONFIG } from "./graphql/config";
|
|
||||||
import { IConfig } from "./types/config.model";
|
|
||||||
import { ICurrentUser } from "./types/current-user.model";
|
|
||||||
import jwt_decode, { JwtPayload } from "jwt-decode";
|
import jwt_decode, { JwtPayload } from "jwt-decode";
|
||||||
import { refreshAccessToken } from "./apollo/utils";
|
import { refreshAccessToken } from "./apollo/utils";
|
||||||
import { Route } from "vue-router";
|
import {
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
provide,
|
||||||
|
onUnmounted,
|
||||||
|
onMounted,
|
||||||
|
onBeforeMount,
|
||||||
|
inject,
|
||||||
|
defineAsyncComponent,
|
||||||
|
} from "vue";
|
||||||
|
import { LocationType } from "./types/user-location.model";
|
||||||
|
import { useMutation } from "@vue/apollo-composable";
|
||||||
|
import { initializeCurrentActor } from "./utils/identity";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Snackbar } from "./plugins/snackbar";
|
||||||
|
import { Notifier } from "./plugins/notifier";
|
||||||
|
import {
|
||||||
|
useIsDemoMode,
|
||||||
|
useServerProvidedLocation,
|
||||||
|
} from "./composition/apollo/config";
|
||||||
|
|
||||||
@Component({
|
const ErrorComponent = defineAsyncComponent(
|
||||||
apollo: {
|
() => import("./components/ErrorComponent.vue")
|
||||||
currentUser: CURRENT_USER_CLIENT,
|
);
|
||||||
config: CONFIG,
|
|
||||||
},
|
const { t } = useI18n({ useScope: "global" });
|
||||||
components: {
|
|
||||||
Logo,
|
const { location } = useServerProvidedLocation();
|
||||||
NavBar,
|
|
||||||
error: () =>
|
const userLocation = reactive<LocationType>({
|
||||||
import(/* webpackChunkName: "editor" */ "./components/Error.vue"),
|
lon: undefined,
|
||||||
"mobilizon-footer": Footer,
|
lat: undefined,
|
||||||
},
|
name: undefined,
|
||||||
metaInfo() {
|
picture: undefined,
|
||||||
return {
|
isIPLocation: true,
|
||||||
titleTemplate: "%s | Mobilizon",
|
accuracy: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserLocation = (newLocation: LocationType) => {
|
||||||
|
userLocation.lat = newLocation.lat;
|
||||||
|
userLocation.lon = newLocation.lon;
|
||||||
|
userLocation.name = newLocation.name;
|
||||||
|
userLocation.picture = newLocation.picture;
|
||||||
|
userLocation.isIPLocation = newLocation.isIPLocation;
|
||||||
|
userLocation.accuracy = newLocation.accuracy;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateUserLocation({
|
||||||
|
lat: location.value?.latitude,
|
||||||
|
lon: location.value?.longitude,
|
||||||
|
name: "", // config.ipLocation.country.name,
|
||||||
|
isIPLocation: true,
|
||||||
|
accuracy: 150, // config.ipLocation.location.accuracy_radius * 1.5 || 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
provide("userLocation", {
|
||||||
|
userLocation,
|
||||||
|
updateUserLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// const routerView = ref("routerView");
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
const online = ref(true);
|
||||||
|
const interval = ref<number>(0);
|
||||||
|
|
||||||
|
const notifier = inject<Notifier>("notifier");
|
||||||
|
|
||||||
|
interval.value = setInterval(async () => {
|
||||||
|
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
||||||
|
if (accessToken) {
|
||||||
|
const token = jwt_decode<JwtPayload>(accessToken);
|
||||||
|
if (
|
||||||
|
token?.exp !== undefined &&
|
||||||
|
new Date(token.exp * 1000 - 60000) < new Date()
|
||||||
|
) {
|
||||||
|
refreshAccessToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000) as unknown as number;
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
if (initializeCurrentUser()) {
|
||||||
|
await initializeCurrentActor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const snackbar = inject<Snackbar>("snackbar");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
online.value = window.navigator.onLine;
|
||||||
|
window.addEventListener("offline", () => {
|
||||||
|
online.value = false;
|
||||||
|
showOfflineNetworkWarning();
|
||||||
|
console.debug("offline");
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
online.value = true;
|
||||||
|
console.debug("online");
|
||||||
|
});
|
||||||
|
document.addEventListener("refreshApp", (event: Event) => {
|
||||||
|
snackbar?.open({
|
||||||
|
queue: false,
|
||||||
|
indefinite: true,
|
||||||
|
variant: "dark",
|
||||||
|
actionText: t("Update app"),
|
||||||
|
cancelText: t("Ignore"),
|
||||||
|
message: t("A new version is available."),
|
||||||
|
onAction: async () => {
|
||||||
|
const registration = event.detail as ServiceWorkerRegistration;
|
||||||
|
try {
|
||||||
|
await refreshApp(registration);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
notifier?.error(t("An error has occured while refreshing the page."));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(interval.value);
|
||||||
|
interval.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: updateCurrentUser } = useMutation(UPDATE_CURRENT_USER_CLIENT);
|
||||||
|
|
||||||
|
const initializeCurrentUser = () => {
|
||||||
|
const userId = localStorage.getItem(AUTH_USER_ID);
|
||||||
|
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
||||||
|
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
||||||
|
const role = localStorage.getItem(AUTH_USER_ROLE);
|
||||||
|
|
||||||
|
if (userId && userEmail && accessToken && role) {
|
||||||
|
console.log("Saving current user client from localstorage", role);
|
||||||
|
updateCurrentUser({
|
||||||
|
id: userId,
|
||||||
|
email: userEmail,
|
||||||
|
isLoggedIn: true,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshApp = async (
|
||||||
|
registration: ServiceWorkerRegistration
|
||||||
|
): Promise<any> => {
|
||||||
|
const worker = registration.waiting;
|
||||||
|
if (!worker) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
console.debug("Doing worker.skipWaiting().");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
console.debug("Done worker.skipWaiting().");
|
||||||
|
if (event.data.error) {
|
||||||
|
reject(event.data);
|
||||||
|
} else {
|
||||||
|
resolve(event.data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
console.debug("calling skip waiting");
|
||||||
})
|
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
|
||||||
export default class App extends Vue {
|
});
|
||||||
config!: IConfig;
|
};
|
||||||
|
|
||||||
currentUser!: ICurrentUser;
|
const showOfflineNetworkWarning = (): void => {
|
||||||
|
notifier?.error(t("You are offline"));
|
||||||
|
};
|
||||||
|
// const extractPageTitleFromRoute = (routeWatched: RouteLocation): string => {
|
||||||
|
// if (routeWatched.meta?.announcer?.message) {
|
||||||
|
// return routeWatched.meta?.announcer?.message();
|
||||||
|
// }
|
||||||
|
// return document.title;
|
||||||
|
// };
|
||||||
|
|
||||||
error: Error | null = null;
|
// watch(route, (routeWatched) => {
|
||||||
|
// const pageTitle = extractPageTitleFromRoute(routeWatched);
|
||||||
|
// if (pageTitle) {
|
||||||
|
// // this.$announcer.polite(
|
||||||
|
// // t("Navigated to {pageTitle}", {
|
||||||
|
// // pageTitle,
|
||||||
|
// // }) as string
|
||||||
|
// // );
|
||||||
|
// }
|
||||||
|
// // Set the focus to the router view
|
||||||
|
// // https://marcus.io/blog/accessible-routing-vuejs
|
||||||
|
// setTimeout(() => {
|
||||||
|
// const focusTarget = (
|
||||||
|
// routerView.value?.$refs?.componentFocusTarget !== undefined
|
||||||
|
// ? routerView.value?.$refs?.componentFocusTarget
|
||||||
|
// : routerView.value?.$el
|
||||||
|
// ) as HTMLElement;
|
||||||
|
// if (focusTarget && focusTarget instanceof Element) {
|
||||||
|
// // Make focustarget programmatically focussable
|
||||||
|
// focusTarget.setAttribute("tabindex", "-1");
|
||||||
|
|
||||||
online = true;
|
// // Focus element
|
||||||
|
// focusTarget.focus();
|
||||||
|
|
||||||
interval: number | undefined = undefined;
|
// // Remove tabindex from focustarget.
|
||||||
|
// // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
|
||||||
|
// focusTarget.removeAttribute("tabindex");
|
||||||
|
// }
|
||||||
|
// }, 0);
|
||||||
|
// });
|
||||||
|
|
||||||
@Ref("routerView") routerView!: Vue;
|
// watch(config, async (configWatched: IConfig) => {
|
||||||
|
// if (configWatched) {
|
||||||
|
// const { statistics } = (await import("./services/statistics")) as {
|
||||||
|
// statistics: (config: IConfig, environment: Record<string, any>) => void;
|
||||||
|
// };
|
||||||
|
// statistics(configWatched, { router, version: configWatched.version });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
async created(): Promise<void> {
|
const { isDemoMode } = useIsDemoMode();
|
||||||
if (await this.initializeCurrentUser()) {
|
|
||||||
await initializeCurrentActor(this.$apollo.provider.defaultClient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorCaptured(error: Error): void {
|
|
||||||
this.error = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeCurrentUser() {
|
|
||||||
const userId = localStorage.getItem(AUTH_USER_ID);
|
|
||||||
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
|
||||||
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
|
||||||
const role = localStorage.getItem(AUTH_USER_ROLE);
|
|
||||||
|
|
||||||
if (userId && userEmail && accessToken && role) {
|
|
||||||
return this.$apollo.mutate({
|
|
||||||
mutation: UPDATE_CURRENT_USER_CLIENT,
|
|
||||||
variables: {
|
|
||||||
id: userId,
|
|
||||||
email: userEmail,
|
|
||||||
isLoggedIn: true,
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
mounted(): void {
|
|
||||||
this.online = window.navigator.onLine;
|
|
||||||
window.addEventListener("offline", () => {
|
|
||||||
this.online = false;
|
|
||||||
this.showOfflineNetworkWarning();
|
|
||||||
console.debug("offline");
|
|
||||||
});
|
|
||||||
window.addEventListener("online", () => {
|
|
||||||
this.online = true;
|
|
||||||
console.debug("online");
|
|
||||||
});
|
|
||||||
document.addEventListener("refreshApp", (event: Event) => {
|
|
||||||
this.$buefy.snackbar.open({
|
|
||||||
queue: false,
|
|
||||||
indefinite: true,
|
|
||||||
type: "is-secondary",
|
|
||||||
actionText: this.$t("Update app") as string,
|
|
||||||
cancelText: this.$t("Ignore") as string,
|
|
||||||
message: this.$t("A new version is available.") as string,
|
|
||||||
onAction: async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const detail = event.detail;
|
|
||||||
const registration = detail as ServiceWorkerRegistration;
|
|
||||||
try {
|
|
||||||
await this.refreshApp(registration);
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
this.$notifier.error(
|
|
||||||
this.$t(
|
|
||||||
"An error has occured while refreshing the page."
|
|
||||||
) as string
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.interval = setInterval(async () => {
|
|
||||||
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
|
||||||
if (accessToken) {
|
|
||||||
const token = jwt_decode<JwtPayload>(accessToken);
|
|
||||||
if (
|
|
||||||
token?.exp !== undefined &&
|
|
||||||
new Date(token.exp * 1000 - 60000) < new Date()
|
|
||||||
) {
|
|
||||||
refreshAccessToken(this.$apollo.getClient());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshApp(
|
|
||||||
registration: ServiceWorkerRegistration
|
|
||||||
): Promise<any> {
|
|
||||||
const worker = registration.waiting;
|
|
||||||
if (!worker) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
console.debug("Doing worker.skipWaiting().");
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const channel = new MessageChannel();
|
|
||||||
|
|
||||||
channel.port1.onmessage = (event) => {
|
|
||||||
console.debug("Done worker.skipWaiting().");
|
|
||||||
if (event.data.error) {
|
|
||||||
reject(event.data);
|
|
||||||
} else {
|
|
||||||
resolve(event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
console.debug("calling skip waiting");
|
|
||||||
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showOfflineNetworkWarning(): void {
|
|
||||||
this.$notifier.error(this.$t("You are offline") as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
unmounted(): void {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("config")
|
|
||||||
async initializeStatistics(config: IConfig) {
|
|
||||||
if (config) {
|
|
||||||
const { statistics } = (await import("./services/statistics")) as {
|
|
||||||
statistics: (config: IConfig, environment: Record<string, any>) => void;
|
|
||||||
};
|
|
||||||
statistics(config, { router: this.$router, version: config.version });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("$route", { immediate: true })
|
|
||||||
updateAnnouncement(route: Route): void {
|
|
||||||
const pageTitle = this.extractPageTitleFromRoute(route);
|
|
||||||
if (pageTitle) {
|
|
||||||
this.$announcer.polite(
|
|
||||||
this.$t("Navigated to {pageTitle}", {
|
|
||||||
pageTitle,
|
|
||||||
}) as string
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Set the focus to the router view
|
|
||||||
// https://marcus.io/blog/accessible-routing-vuejs
|
|
||||||
setTimeout(() => {
|
|
||||||
const focusTarget = (
|
|
||||||
this.routerView?.$refs?.componentFocusTarget !== undefined
|
|
||||||
? this.routerView?.$refs?.componentFocusTarget
|
|
||||||
: this.routerView?.$el
|
|
||||||
) as HTMLElement;
|
|
||||||
if (focusTarget && focusTarget instanceof Element) {
|
|
||||||
// Make focustarget programmatically focussable
|
|
||||||
focusTarget.setAttribute("tabindex", "-1");
|
|
||||||
|
|
||||||
// Focus element
|
|
||||||
focusTarget.focus();
|
|
||||||
|
|
||||||
// Remove tabindex from focustarget.
|
|
||||||
// Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
|
|
||||||
focusTarget.removeAttribute("tabindex");
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
extractPageTitleFromRoute(route: Route): string {
|
|
||||||
if (route.meta?.announcer?.message) {
|
|
||||||
return route.meta?.announcer?.message();
|
|
||||||
}
|
|
||||||
return document.title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import "variables";
|
@import "variables";
|
||||||
|
|
||||||
/* Icons */
|
|
||||||
$mdi-font-path: "~@mdi/font/fonts";
|
|
||||||
@import "~@mdi/font/scss/materialdesignicons";
|
|
||||||
@import "common";
|
@import "common";
|
||||||
|
|
||||||
#mobilizon {
|
#mobilizon {
|
||||||
|
|
|
@ -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`;
|
||||||
|
|
25
js/src/apollo/absinthe-socket-link.ts
Normal 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);
|
20
js/src/apollo/absinthe-upload-socket-link.ts
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
|
@ -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 });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,19 +4,16 @@ import { IFollower } from "@/types/actor/follower.model";
|
||||||
import { IParticipant } from "@/types/participant.model";
|
import { IParticipant } from "@/types/participant.model";
|
||||||
import { Paginate } from "@/types/paginate";
|
import { Paginate } from "@/types/paginate";
|
||||||
import { saveTokenData } from "@/utils/auth";
|
import { saveTokenData } from "@/utils/auth";
|
||||||
import {
|
import { FieldPolicy, Reference, TypePolicies } from "@apollo/client/core";
|
||||||
ApolloClient,
|
|
||||||
FieldPolicy,
|
|
||||||
NormalizedCacheObject,
|
|
||||||
Reference,
|
|
||||||
TypePolicies,
|
|
||||||
} from "@apollo/client/core";
|
|
||||||
import introspectionQueryResultData from "../../fragmentTypes.json";
|
import introspectionQueryResultData from "../../fragmentTypes.json";
|
||||||
import { IMember } from "@/types/actor/member.model";
|
import { IMember } from "@/types/actor/member.model";
|
||||||
import { IComment } from "@/types/comment.model";
|
import { IComment } from "@/types/comment.model";
|
||||||
import { IEvent } from "@/types/event.model";
|
import { IEvent } from "@/types/event.model";
|
||||||
import { IActivity } from "@/types/activity.model";
|
import { IActivity } from "@/types/activity.model";
|
||||||
import uniqBy from "lodash/uniqBy";
|
import uniqBy from "lodash/uniqBy";
|
||||||
|
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
|
||||||
|
import { apolloClient } from "@/vue-apollo";
|
||||||
|
import { IToken } from "@/types/login.model";
|
||||||
|
|
||||||
type possibleTypes = { name: string };
|
type possibleTypes = { name: string };
|
||||||
type schemaType = {
|
type schemaType = {
|
||||||
|
@ -73,6 +70,9 @@ export const typePolicies: TypePolicies = {
|
||||||
Instance: {
|
Instance: {
|
||||||
keyFields: ["domain"],
|
keyFields: ["domain"],
|
||||||
},
|
},
|
||||||
|
Config: {
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
RootQueryType: {
|
RootQueryType: {
|
||||||
fields: {
|
fields: {
|
||||||
relayFollowers: paginatedLimitPagination<IFollower>(),
|
relayFollowers: paginatedLimitPagination<IFollower>(),
|
||||||
|
@ -99,9 +99,7 @@ export const typePolicies: TypePolicies = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function refreshAccessToken(
|
export async function refreshAccessToken(): Promise<boolean> {
|
||||||
apolloClient: ApolloClient<NormalizedCacheObject>
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Remove invalid access token, so the next request is not authenticated
|
// Remove invalid access token, so the next request is not authenticated
|
||||||
localStorage.removeItem(AUTH_ACCESS_TOKEN);
|
localStorage.removeItem(AUTH_ACCESS_TOKEN);
|
||||||
|
|
||||||
|
@ -114,21 +112,28 @@ export async function refreshAccessToken(
|
||||||
|
|
||||||
console.log("Refreshing access token.");
|
console.log("Refreshing access token.");
|
||||||
|
|
||||||
try {
|
return new Promise((resolve, reject) => {
|
||||||
const res = await apolloClient.mutate({
|
const { mutate, onDone, onError } = provideApolloClient(apolloClient)(() =>
|
||||||
mutation: REFRESH_TOKEN,
|
useMutation<{ refreshToken: IToken }>(REFRESH_TOKEN)
|
||||||
variables: {
|
);
|
||||||
refreshToken,
|
|
||||||
},
|
mutate({
|
||||||
|
refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
saveTokenData(res.data.refreshToken);
|
onDone(({ data }) => {
|
||||||
|
if (data?.refreshToken) {
|
||||||
|
saveTokenData(data?.refreshToken);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
reject(false);
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
onError((err) => {
|
||||||
} catch (err) {
|
console.debug("Failed to refresh token");
|
||||||
console.debug("Failed to refresh token");
|
reject(false);
|
||||||
return false;
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyArgs = FieldPolicy<any>["keyArgs"];
|
type KeyArgs = FieldPolicy<any>["keyArgs"];
|
||||||
|
|
192
js/src/assets/oruga-tailwindcss.css
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
20
js/src/components/About/InstanceContactLink.story.vue
Normal 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>
|
|
@ -4,49 +4,49 @@
|
||||||
configLink.text
|
configLink.text
|
||||||
}}</a>
|
}}</a>
|
||||||
<span dir="auto" v-else-if="contact">{{ contact }}</span>
|
<span dir="auto" v-else-if="contact">{{ contact }}</span>
|
||||||
<span v-else>{{ $t("contact uninformed") }}</span>
|
<span v-else>{{ t("contact uninformed") }}</span>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
@Component
|
const props = defineProps<{
|
||||||
export default class InstanceContactLink extends Vue {
|
contact?: string;
|
||||||
@Prop({ required: true, type: String }) contact!: string;
|
}>();
|
||||||
|
|
||||||
get configLink(): { uri: string; text: string } | null {
|
const { t } = useI18n({ useScope: "global" });
|
||||||
if (!this.contact) return null;
|
|
||||||
if (this.isContactEmail) {
|
const configLink = computed((): { uri: string; text: string } | null => {
|
||||||
return {
|
if (!props.contact) return null;
|
||||||
uri: `mailto:${this.contact}`,
|
if (isContactEmail.value) {
|
||||||
text: this.contact,
|
return {
|
||||||
};
|
uri: `mailto:${props.contact}`,
|
||||||
}
|
text: props.contact,
|
||||||
if (this.isContactURL) {
|
};
|
||||||
return {
|
}
|
||||||
uri: this.contact,
|
if (isContactURL.value) {
|
||||||
text:
|
return {
|
||||||
InstanceContactLink.urlToHostname(this.contact) ||
|
uri: props.contact,
|
||||||
(this.$t("Contact") as string),
|
text: urlToHostname(props.contact) ?? "Contact",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isContactEmail = computed((): boolean => {
|
||||||
|
return (props.contact ?? "").includes("@");
|
||||||
|
});
|
||||||
|
|
||||||
|
const isContactURL = computed((): boolean => {
|
||||||
|
return (props.contact ?? "").match(/^https?:\/\//g) !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlToHostname = (url: string): string | null => {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
get isContactEmail(): boolean {
|
|
||||||
return this.contact.includes("@");
|
|
||||||
}
|
|
||||||
|
|
||||||
get isContactURL(): boolean {
|
|
||||||
return this.contact.match(/^https?:\/\//g) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static urlToHostname(url: string): string | null {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
52
js/src/components/Account/ActorCard.story.vue
Normal 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>
|
|
@ -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>
|
||||||
|
|
52
js/src/components/Account/ActorInline.story.vue
Normal 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>
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
59
js/src/components/Account/PopoverActorCard.story.vue
Normal 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>
|
|
@ -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>
|
||||||
|
|
29
js/src/components/Account/ProfileOnboarding.story.vue
Normal 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>
|
|
@ -1,71 +1,60 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="section container">
|
<div class="">
|
||||||
<div class="setting-title">
|
<h2 class="text-2xl">{{ t("Profiles and federation") }}</h2>
|
||||||
<h2>{{ $t("Profiles and federation") }}</h2>
|
</div>
|
||||||
</div>
|
<p class="my-2">
|
||||||
<div>
|
{{
|
||||||
<p class="content">
|
t(
|
||||||
{{
|
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
|
||||||
$t(
|
)
|
||||||
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
|
}}
|
||||||
)
|
</p>
|
||||||
}}
|
<hr role="presentation" />
|
||||||
</p>
|
<p class="my-2">
|
||||||
<hr role="presentation" />
|
<span>
|
||||||
<p class="content">
|
{{
|
||||||
<span>
|
t(
|
||||||
{{
|
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
|
||||||
$t(
|
)
|
||||||
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
|
}}
|
||||||
)
|
</span>
|
||||||
}}
|
<i18n-t
|
||||||
</span>
|
keypath="This instance, {instanceName}, hosts your profile, so remember its name."
|
||||||
<span
|
>
|
||||||
v-if="config"
|
<template v-slot:instanceName>
|
||||||
v-html="
|
<b>{{
|
||||||
$t(
|
t("{instanceName} ({domain})", {
|
||||||
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.',
|
domain,
|
||||||
{
|
instanceName,
|
||||||
domain,
|
})
|
||||||
instanceName: config.name,
|
}}</b>
|
||||||
}
|
</template>
|
||||||
)
|
</i18n-t>
|
||||||
"
|
</p>
|
||||||
/>
|
<hr role="presentation" />
|
||||||
</p>
|
<p class="my-2">
|
||||||
<hr role="presentation" />
|
{{
|
||||||
<p class="content">
|
t(
|
||||||
{{
|
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
|
||||||
$t(
|
)
|
||||||
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
|
}}
|
||||||
)
|
</p>
|
||||||
}}
|
<div class="text-center">
|
||||||
</p>
|
<code>{{ `${currentActor?.preferredUsername}@${domain}` }}</code>
|
||||||
<div class="has-text-centered">
|
|
||||||
<code>{{ `${currentActor.preferredUsername}@${domain}` }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
|
||||||
import { CONFIG } from "@/graphql/config";
|
|
||||||
import { IPerson } from "@/types/actor";
|
import { IPerson } from "@/types/actor";
|
||||||
import { IConfig } from "@/types/config.model";
|
import { computed } from "vue";
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
@Component({
|
defineProps<{
|
||||||
apollo: {
|
currentActor: IPerson;
|
||||||
config: CONFIG,
|
instanceName: string;
|
||||||
currentActor: CURRENT_ACTOR_CLIENT,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class ProfileOnboarding extends Vue {
|
|
||||||
config!: IConfig;
|
|
||||||
|
|
||||||
currentActor!: IPerson;
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
domain = window.location.hostname;
|
const domain = computed(() => window.location.hostname);
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,118 +1,117 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="'chat'" :type="iconColor" />
|
<o-icon :icon="'chat'" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<router-link
|
<template #discussion>
|
||||||
v-if="activity.object"
|
<router-link
|
||||||
slot="discussion"
|
v-if="activity.object"
|
||||||
:to="{
|
:to="{
|
||||||
name: RouteName.DISCUSSION,
|
name: RouteName.DISCUSSION,
|
||||||
params: { slug: subjectParams.discussion_slug },
|
params: { slug: subjectParams.discussion_slug },
|
||||||
}"
|
}"
|
||||||
>{{ subjectParams.discussion_title }}</router-link
|
>{{ subjectParams.discussion_title }}</router-link
|
||||||
>
|
>
|
||||||
<b v-else slot="discussion">{{ subjectParams.discussion_title }}</b>
|
<b v-else>{{ subjectParams.discussion_title }}</b>
|
||||||
<router-link
|
</template>
|
||||||
v-if="activity.object && subjectParams.old_discussion_title"
|
<template #old_discussion>
|
||||||
slot="old_discussion"
|
<router-link
|
||||||
:to="{
|
v-if="activity.object && subjectParams.old_discussion_title"
|
||||||
name: RouteName.DISCUSSION,
|
:to="{
|
||||||
params: { slug: subjectParams.discussion_slug },
|
name: RouteName.DISCUSSION,
|
||||||
}"
|
params: { slug: subjectParams.discussion_slug },
|
||||||
>{{ subjectParams.old_discussion_title }}</router-link
|
}"
|
||||||
>
|
>{{ subjectParams.old_discussion_title }}</router-link
|
||||||
<b
|
>
|
||||||
v-else-if="subjectParams.old_discussion_title"
|
<b v-else-if="subjectParams.old_discussion_title">{{
|
||||||
slot="old_discussion"
|
subjectParams.old_discussion_title
|
||||||
>{{ subjectParams.old_discussion_title }}</b
|
}}</b>
|
||||||
>
|
</template>
|
||||||
<popover-actor-card
|
<template #profile>
|
||||||
:actor="activity.author"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
:inline="true"
|
<b>
|
||||||
slot="profile"
|
{{
|
||||||
>
|
$t("{'@'}{username}", {
|
||||||
<b>
|
username: usernameWithDomain(activity.author),
|
||||||
{{
|
})
|
||||||
$t("@{username}", {
|
}}</b
|
||||||
username: usernameWithDomain(activity.author),
|
></popover-actor-card
|
||||||
})
|
></template
|
||||||
}}</b
|
></i18n-t
|
||||||
></popover-actor-card
|
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="has-text-grey-dark activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { usernameWithDomain } from "@/types/actor";
|
import { usernameWithDomain } from "@/types/actor";
|
||||||
import { ActivityDiscussionSubject } from "@/types/enums";
|
import { ActivityDiscussionSubject } from "@/types/enums";
|
||||||
import { Component } from "vue-property-decorator";
|
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
import ActivityMixin from "../../mixins/activity";
|
import { IActivity } from "@/types/activity.model";
|
||||||
import { mixins } from "vue-class-component";
|
import { computed } from "vue";
|
||||||
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
|
import {
|
||||||
|
useActivitySubjectParams,
|
||||||
|
useIsActivityAuthorCurrentActor,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class DiscussionActivityItem extends mixins(ActivityMixin) {
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
RouteName = RouteName;
|
|
||||||
ActivityDiscussionSubject = ActivityDiscussionSubject;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityDiscussionSubject.DISCUSSION_CREATED:
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You created the discussion {discussion}.";
|
const translation = computed((): string | undefined => {
|
||||||
}
|
switch (props.activity.subject) {
|
||||||
return "{profile} created the discussion {discussion}.";
|
case ActivityDiscussionSubject.DISCUSSION_CREATED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You created the discussion {discussion}.";
|
||||||
return "You replied to the discussion {discussion}.";
|
}
|
||||||
}
|
return "{profile} created the discussion {discussion}.";
|
||||||
return "{profile} replied to the discussion {discussion}.";
|
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You replied to the discussion {discussion}.";
|
||||||
return "You renamed the discussion from {old_discussion} to {discussion}.";
|
}
|
||||||
}
|
return "{profile} replied to the discussion {discussion}.";
|
||||||
return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
|
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You renamed the discussion from {old_discussion} to {discussion}.";
|
||||||
return "You archived the discussion {discussion}.";
|
}
|
||||||
}
|
return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
|
||||||
return "{profile} archived the discussion {discussion}.";
|
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_DELETED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You archived the discussion {discussion}.";
|
||||||
return "You deleted the discussion {discussion}.";
|
}
|
||||||
}
|
return "{profile} archived the discussion {discussion}.";
|
||||||
return "{profile} deleted the discussion {discussion}.";
|
case ActivityDiscussionSubject.DISCUSSION_DELETED:
|
||||||
default:
|
if (isAuthorCurrentActor) {
|
||||||
return undefined;
|
return "You deleted the discussion {discussion}.";
|
||||||
}
|
}
|
||||||
|
return "{profile} deleted the discussion {discussion}.";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const iconColor = computed((): string | undefined => {
|
||||||
switch (this.activity.subject) {
|
switch (props.activity.subject) {
|
||||||
case ActivityDiscussionSubject.DISCUSSION_CREATED:
|
case ActivityDiscussionSubject.DISCUSSION_CREATED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
|
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
|
||||||
return "is-success";
|
return "is-success";
|
||||||
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
|
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
|
||||||
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
|
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
|
||||||
return "is-grey";
|
return "is-grey";
|
||||||
case ActivityDiscussionSubject.DISCUSSION_DELETED:
|
case ActivityDiscussionSubject.DISCUSSION_DELETED:
|
||||||
return "is-danger";
|
return "is-danger";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -1,107 +1,107 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="'calendar'" :type="iconColor" />
|
<o-icon :icon="'calendar'" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<router-link
|
<template #event>
|
||||||
slot="event"
|
<router-link
|
||||||
v-if="activity.object"
|
v-if="activity.object"
|
||||||
:to="{
|
:to="{
|
||||||
name: RouteName.EVENT,
|
name: RouteName.EVENT,
|
||||||
params: { uuid: subjectParams.event_uuid },
|
params: { uuid: subjectParams.event_uuid },
|
||||||
}"
|
}"
|
||||||
>{{ subjectParams.event_title }}</router-link
|
>{{ subjectParams.event_title }}</router-link
|
||||||
>
|
>
|
||||||
<b v-else slot="event">{{ subjectParams.event_title }}</b>
|
<b v-else>{{ subjectParams.event_title }}</b>
|
||||||
<popover-actor-card
|
</template>
|
||||||
:actor="activity.author"
|
<template #profile>
|
||||||
:inline="true"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
slot="profile"
|
<b>
|
||||||
>
|
{{
|
||||||
<b>
|
$t("{'@'}{username}", {
|
||||||
{{
|
username: usernameWithDomain(activity.author),
|
||||||
$t("@{username}", {
|
})
|
||||||
username: usernameWithDomain(activity.author),
|
}}</b
|
||||||
})
|
></popover-actor-card
|
||||||
}}</b
|
></template
|
||||||
></popover-actor-card
|
></i18n-t
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
useActivitySubjectParams,
|
||||||
|
useIsActivityAuthorCurrentActor,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
import { IActivity } from "@/types/activity.model";
|
||||||
import { usernameWithDomain } from "@/types/actor";
|
import { usernameWithDomain } from "@/types/actor";
|
||||||
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
import {
|
import {
|
||||||
ActivityEventCommentSubject,
|
ActivityEventCommentSubject,
|
||||||
ActivityEventSubject,
|
ActivityEventSubject,
|
||||||
} from "@/types/enums";
|
} from "@/types/enums";
|
||||||
import { mixins } from "vue-class-component";
|
import { computed } from "vue";
|
||||||
import { Component } from "vue-property-decorator";
|
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
import ActivityMixin from "../../mixins/activity";
|
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class EventActivityItem extends mixins(ActivityMixin) {
|
|
||||||
ActivityEventSubject = ActivityEventSubject;
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
RouteName = RouteName;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityEventSubject.EVENT_CREATED:
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You created the event {event}.";
|
const translation = computed((): string | undefined => {
|
||||||
|
switch (props.activity.subject) {
|
||||||
|
case ActivityEventSubject.EVENT_CREATED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You created the event {event}.";
|
||||||
|
}
|
||||||
|
return "The event {event} was created by {profile}.";
|
||||||
|
case ActivityEventSubject.EVENT_UPDATED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You updated the event {event}.";
|
||||||
|
}
|
||||||
|
return "The event {event} was updated by {profile}.";
|
||||||
|
case ActivityEventSubject.EVENT_DELETED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You deleted the event {event}.";
|
||||||
|
}
|
||||||
|
return "The event {event} was deleted by {profile}.";
|
||||||
|
case ActivityEventCommentSubject.COMMENT_POSTED:
|
||||||
|
if (subjectParams.comment_reply_to) {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You replied to a comment on the event {event}.";
|
||||||
}
|
}
|
||||||
return "The event {event} was created by {profile}.";
|
return "{profile} replied to a comment on the event {event}.";
|
||||||
case ActivityEventSubject.EVENT_UPDATED:
|
}
|
||||||
if (this.isAuthorCurrentActor) {
|
if (isAuthorCurrentActor) {
|
||||||
return "You updated the event {event}.";
|
return "You posted a comment on the event {event}.";
|
||||||
}
|
}
|
||||||
return "The event {event} was updated by {profile}.";
|
return "{profile} posted a comment on the event {event}.";
|
||||||
case ActivityEventSubject.EVENT_DELETED:
|
default:
|
||||||
if (this.isAuthorCurrentActor) {
|
return undefined;
|
||||||
return "You deleted the event {event}.";
|
|
||||||
}
|
|
||||||
return "The event {event} was deleted by {profile}.";
|
|
||||||
case ActivityEventCommentSubject.COMMENT_POSTED:
|
|
||||||
if (this.subjectParams.comment_reply_to) {
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You replied to a comment on the event {event}.";
|
|
||||||
}
|
|
||||||
return "{profile} replied to a comment on the event {event}.";
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You posted a comment on the event {event}.";
|
|
||||||
}
|
|
||||||
return "{profile} posted a comment on the event {event}.";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const iconColor = computed((): string | undefined => {
|
||||||
switch (this.activity.subject) {
|
switch (props.activity.subject) {
|
||||||
case ActivityEventSubject.EVENT_CREATED:
|
case ActivityEventSubject.EVENT_CREATED:
|
||||||
case ActivityEventCommentSubject.COMMENT_POSTED:
|
case ActivityEventCommentSubject.COMMENT_POSTED:
|
||||||
return "is-success";
|
return "is-success";
|
||||||
case ActivityEventSubject.EVENT_UPDATED:
|
case ActivityEventSubject.EVENT_UPDATED:
|
||||||
return "is-grey";
|
return "is-grey";
|
||||||
case ActivityEventSubject.EVENT_DELETED:
|
case ActivityEventSubject.EVENT_DELETED:
|
||||||
return "is-danger";
|
return "is-danger";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -1,189 +1,176 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="'cog'" :type="iconColor" />
|
<o-icon :icon="'cog'" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<router-link
|
<template #group>
|
||||||
v-if="activity.object"
|
<router-link
|
||||||
slot="group"
|
v-if="activity.object"
|
||||||
:to="{
|
:to="{
|
||||||
name: RouteName.GROUP,
|
name: RouteName.GROUP,
|
||||||
params: {
|
params: {
|
||||||
preferredUsername: subjectParams.group_federated_username,
|
preferredUsername: subjectParams.group_federated_username,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>{{ subjectParams.group_name }}</router-link
|
>{{ subjectParams.group_name }}</router-link
|
||||||
>
|
>
|
||||||
<b v-else slot="post">{{ subjectParams.group_name }}</b>
|
<b v-else>{{ subjectParams.group_name }}</b>
|
||||||
<popover-actor-card
|
</template>
|
||||||
:actor="activity.author"
|
<template #profile>
|
||||||
:inline="true"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
slot="profile"
|
<b>
|
||||||
>
|
{{
|
||||||
<b>
|
$t("{'@'}{username}", {
|
||||||
{{
|
username: usernameWithDomain(activity.author),
|
||||||
$t("@{username}", {
|
})
|
||||||
username: usernameWithDomain(activity.author),
|
}}</b
|
||||||
})
|
></popover-actor-card
|
||||||
}}</b
|
></template
|
||||||
></popover-actor-card
|
></i18n-t
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<i18n
|
<i18n-t
|
||||||
:path="detail"
|
:keypath="detail"
|
||||||
v-for="detail in details"
|
v-for="detail in details"
|
||||||
:key="detail"
|
:key="detail"
|
||||||
tag="p"
|
tag="p"
|
||||||
class="has-text-grey-dark"
|
class="has-text-grey-dark"
|
||||||
>
|
>
|
||||||
<popover-actor-card
|
<template #profile>
|
||||||
:actor="activity.author"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
:inline="true"
|
<b>
|
||||||
slot="profile"
|
{{
|
||||||
>
|
$t("{'@'}{username}", {
|
||||||
<b>
|
username: usernameWithDomain(activity.author),
|
||||||
{{
|
})
|
||||||
$t("@{username}", {
|
}}</b
|
||||||
username: usernameWithDomain(activity.author),
|
></popover-actor-card
|
||||||
})
|
>
|
||||||
}}</b
|
</template>
|
||||||
></popover-actor-card
|
<template #group>
|
||||||
>
|
<router-link
|
||||||
<router-link
|
v-if="activity.object"
|
||||||
v-if="activity.object"
|
:to="{
|
||||||
slot="group"
|
|
||||||
:to="{
|
|
||||||
name: RouteName.GROUP,
|
name: RouteName.GROUP,
|
||||||
params: { preferredUsername: usernameWithDomain(activity.object) },
|
params: { preferredUsername: usernameWithDomain(activity.object as IActor) },
|
||||||
}"
|
}"
|
||||||
>{{ subjectParams.group_name }}</router-link
|
>{{ subjectParams.group_name }}</router-link
|
||||||
>
|
>
|
||||||
<b v-else slot="post">{{ subjectParams.group_name }}</b>
|
<b v-else>{{ subjectParams.group_name }}</b>
|
||||||
<b v-if="subjectParams.old_group_name" slot="old_group_name">{{
|
</template>
|
||||||
subjectParams.old_group_name
|
<template #old_group_name>
|
||||||
}}</b>
|
<b v-if="subjectParams.old_group_name">{{
|
||||||
</i18n>
|
subjectParams.old_group_name
|
||||||
|
}}</b>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="has-text-grey-dark activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { usernameWithDomain } from "@/types/actor";
|
import {
|
||||||
|
useIsActivityAuthorCurrentActor,
|
||||||
|
useActivitySubjectParams,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
import { IActivity } from "@/types/activity.model";
|
||||||
|
import { IActor, IGroup, usernameWithDomain } from "@/types/actor";
|
||||||
import { ActivityGroupSubject, GroupVisibility, Openness } from "@/types/enums";
|
import { ActivityGroupSubject, GroupVisibility, Openness } from "@/types/enums";
|
||||||
import { Component } from "vue-property-decorator";
|
import { computed } from "vue";
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
import ActivityMixin from "../../mixins/activity";
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
import { mixins } from "vue-class-component";
|
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class GroupActivityItem extends mixins(ActivityMixin) {
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
RouteName = RouteName;
|
|
||||||
ActivityGroupSubject = ActivityGroupSubject;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityGroupSubject.GROUP_CREATED:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You created the group {group}.";
|
|
||||||
}
|
|
||||||
return "{profile} created the group {group}.";
|
|
||||||
case ActivityGroupSubject.GROUP_UPDATED:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You updated the group {group}.";
|
|
||||||
}
|
|
||||||
return "{profile} updated the group {group}.";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityGroupSubject.GROUP_CREATED:
|
|
||||||
return "is-success";
|
|
||||||
case ActivityGroupSubject.GROUP_UPDATED:
|
|
||||||
return "is-grey";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get details(): string[] {
|
const translation = computed((): string | undefined => {
|
||||||
const details = [];
|
switch (props.activity.subject) {
|
||||||
const changes = this.subjectParams.group_changes.split(",");
|
case ActivityGroupSubject.GROUP_CREATED:
|
||||||
if (changes.includes("name") && this.subjectParams.old_group_name) {
|
if (isAuthorCurrentActor) {
|
||||||
details.push("{old_group_name} was renamed to {group}.");
|
return "You created the group {group}.";
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (changes.includes("visibility") && this.activity.object.visibility) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
switch (this.activity.object.visibility) {
|
|
||||||
case GroupVisibility.PRIVATE:
|
|
||||||
details.push("Visibility was set to private.");
|
|
||||||
break;
|
|
||||||
case GroupVisibility.PUBLIC:
|
|
||||||
details.push("Visibility was set to public.");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
details.push("Visibility was set to an unknown value.");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
return "{profile} created the group {group}.";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
case ActivityGroupSubject.GROUP_UPDATED:
|
||||||
// @ts-ignore
|
if (isAuthorCurrentActor) {
|
||||||
if (changes.includes("openness") && this.activity.object.openness) {
|
return "You updated the group {group}.";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
switch (this.activity.object.openness) {
|
|
||||||
case Openness.INVITE_ONLY:
|
|
||||||
details.push("The group can now only be joined with an invite.");
|
|
||||||
break;
|
|
||||||
case Openness.MODERATED:
|
|
||||||
details.push(
|
|
||||||
"The group can now be joined by anyone, but new members need to be approved by an administrator."
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Openness.OPEN:
|
|
||||||
details.push("The group can now be joined by anyone.");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
details.push("Unknown value for the openness setting.");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
return "{profile} updated the group {group}.";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
default:
|
||||||
// @ts-ignore
|
return undefined;
|
||||||
if (changes.includes("address") && this.activity.object.physicalAddress) {
|
|
||||||
details.push("The group's physical address was changed.");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (changes.includes("avatar") && this.activity.object.avatar) {
|
|
||||||
details.push("The group's avatar was changed.");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (changes.includes("banner") && this.activity.object.banner) {
|
|
||||||
details.push("The group's banner was changed.");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (changes.includes("summary") && this.activity.object.summary) {
|
|
||||||
details.push("The group's short description was changed.");
|
|
||||||
}
|
|
||||||
return details;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const iconColor = computed((): string | undefined => {
|
||||||
|
switch (props.activity.subject) {
|
||||||
|
case ActivityGroupSubject.GROUP_CREATED:
|
||||||
|
return "is-success";
|
||||||
|
case ActivityGroupSubject.GROUP_UPDATED:
|
||||||
|
return "is-grey";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = computed(() => props.activity.object as IGroup);
|
||||||
|
|
||||||
|
const details = computed((): string[] => {
|
||||||
|
const localDetails = [];
|
||||||
|
const changes = subjectParams.group_changes.split(",");
|
||||||
|
if (changes.includes("name") && subjectParams.old_group_name) {
|
||||||
|
localDetails.push("{old_group_name} was renamed to {group}.");
|
||||||
|
}
|
||||||
|
if (changes.includes("visibility") && group.value.visibility) {
|
||||||
|
switch (group.value.visibility) {
|
||||||
|
case GroupVisibility.PRIVATE:
|
||||||
|
localDetails.push("Visibility was set to private.");
|
||||||
|
break;
|
||||||
|
case GroupVisibility.PUBLIC:
|
||||||
|
localDetails.push("Visibility was set to public.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
localDetails.push("Visibility was set to an unknown value.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.includes("openness") && group.value.openness) {
|
||||||
|
switch (group.value.openness) {
|
||||||
|
case Openness.INVITE_ONLY:
|
||||||
|
localDetails.push("The group can now only be joined with an invite.");
|
||||||
|
break;
|
||||||
|
case Openness.MODERATED:
|
||||||
|
localDetails.push(
|
||||||
|
"The group can now be joined by anyone, but new members need to be approved by an administrator."
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case Openness.OPEN:
|
||||||
|
localDetails.push("The group can now be joined by anyone.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
localDetails.push("Unknown value for the openness setting.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.includes("address") && group.value.physicalAddress) {
|
||||||
|
localDetails.push("The group's physical address was changed.");
|
||||||
|
}
|
||||||
|
if (changes.includes("avatar") && group.value.avatar) {
|
||||||
|
localDetails.push("The group's avatar was changed.");
|
||||||
|
}
|
||||||
|
if (changes.includes("banner") && group.value.banner) {
|
||||||
|
localDetails.push("The group's banner was changed.");
|
||||||
|
}
|
||||||
|
if (changes.includes("summary") && group.value.summary) {
|
||||||
|
localDetails.push("The group's short description was changed.");
|
||||||
|
}
|
||||||
|
return localDetails;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -1,236 +1,232 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="icon" :type="iconColor" />
|
<o-icon :icon="icon" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<popover-actor-card
|
<template #member>
|
||||||
v-if="activity.object"
|
<popover-actor-card
|
||||||
:actor="activity.object.actor"
|
v-if="member"
|
||||||
:inline="true"
|
:actor="member.actor"
|
||||||
slot="member"
|
:inline="true"
|
||||||
>
|
>
|
||||||
<b> {{ displayName(activity.object.actor) }}</b></popover-actor-card
|
<b> {{ displayName(member.actor) }}</b></popover-actor-card
|
||||||
>
|
>
|
||||||
<b slot="member" v-else>{{
|
<b v-else>{{ subjectParams.member_actor_federated_username }}</b>
|
||||||
subjectParams.member_actor_federated_username
|
</template>
|
||||||
}}</b>
|
<template #profile>
|
||||||
<popover-actor-card
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
:actor="activity.author"
|
<b> {{ displayName(activity.author) }}</b></popover-actor-card
|
||||||
:inline="true"
|
></template
|
||||||
slot="profile"
|
></i18n-t
|
||||||
>
|
|
||||||
<b> {{ displayName(activity.author) }}</b></popover-actor-card
|
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { displayName } from "@/types/actor";
|
|
||||||
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
|
|
||||||
import { Component } from "vue-property-decorator";
|
|
||||||
import RouteName from "../../router/name";
|
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
|
||||||
import ActivityMixin from "../../mixins/activity";
|
|
||||||
import { mixins } from "vue-class-component";
|
|
||||||
|
|
||||||
export const MEMBER_ROLE_VALUE: Record<string, number> = {
|
export const MEMBER_ROLE_VALUE: Record<string, number> = {
|
||||||
[MemberRole.MEMBER]: 20,
|
[MemberRole.MEMBER]: 20,
|
||||||
[MemberRole.MODERATOR]: 50,
|
[MemberRole.MODERATOR]: 50,
|
||||||
[MemberRole.ADMINISTRATOR]: 90,
|
[MemberRole.ADMINISTRATOR]: 90,
|
||||||
[MemberRole.CREATOR]: 100,
|
[MemberRole.CREATOR]: 100,
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { displayName } from "@/types/actor";
|
||||||
|
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
|
||||||
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
|
import {
|
||||||
|
useIsActivityAuthorCurrentActor,
|
||||||
|
useActivitySubjectParams,
|
||||||
|
useIsActivityObjectCurrentActor,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
import { IActivity } from "@/types/activity.model";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { IMember } from "@/types/actor/member.model";
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class MemberActivityItem extends mixins(ActivityMixin) {
|
|
||||||
displayName = displayName;
|
|
||||||
RouteName = RouteName;
|
|
||||||
ActivityMemberSubject = ActivityMemberSubject;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityMemberSubject.MEMBER_REQUEST:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You requested to join the group.";
|
|
||||||
}
|
|
||||||
return "{member} requested to join the group.";
|
|
||||||
case ActivityMemberSubject.MEMBER_INVITED:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You invited {member}.";
|
|
||||||
}
|
|
||||||
return "{member} was invited by {profile}.";
|
|
||||||
case ActivityMemberSubject.MEMBER_ADDED:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You added the member {member}.";
|
|
||||||
}
|
|
||||||
return "{profile} added the member {member}.";
|
|
||||||
case ActivityMemberSubject.MEMBER_APPROVED:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You approved {member}'s membership.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "Your membership was approved by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} approved {member}'s membership.";
|
|
||||||
case ActivityMemberSubject.MEMBER_JOINED:
|
|
||||||
return "{member} joined the group.";
|
|
||||||
case ActivityMemberSubject.MEMBER_UPDATED:
|
|
||||||
if (this.subjectParams.member_role && this.subjectParams.old_role) {
|
|
||||||
return this.roleUpdate;
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You updated the member {member}.";
|
|
||||||
}
|
|
||||||
return "{profile} updated the member {member}.";
|
|
||||||
case ActivityMemberSubject.MEMBER_REMOVED:
|
|
||||||
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) {
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You rejected {member}'s membership request.";
|
|
||||||
}
|
|
||||||
return "{profile} rejected {member}'s membership request.";
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You excluded member {member}.";
|
|
||||||
}
|
|
||||||
return "{profile} excluded member {member}.";
|
|
||||||
case ActivityMemberSubject.MEMBER_QUIT:
|
|
||||||
return "{profile} quit the group.";
|
|
||||||
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
|
||||||
return "{member} rejected the invitation to join the group.";
|
|
||||||
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You accepted the invitation to join the group.";
|
|
||||||
}
|
|
||||||
return "{member} accepted the invitation to join the group.";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get icon(): string {
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
switch (this.activity.subject) {
|
const member = computed(() => props.activity.object as IMember);
|
||||||
case ActivityMemberSubject.MEMBER_REQUEST:
|
|
||||||
case ActivityMemberSubject.MEMBER_ADDED:
|
|
||||||
case ActivityMemberSubject.MEMBER_INVITED:
|
|
||||||
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
|
||||||
return "account-multiple-plus";
|
|
||||||
case ActivityMemberSubject.MEMBER_REMOVED:
|
|
||||||
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
|
||||||
case ActivityMemberSubject.MEMBER_QUIT:
|
|
||||||
return "account-multiple-minus";
|
|
||||||
case ActivityMemberSubject.MEMBER_UPDATED:
|
|
||||||
default:
|
|
||||||
return "account-multiple";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const isObjectMemberCurrentActor = useIsActivityObjectCurrentActor()(
|
||||||
switch (this.activity.subject) {
|
props.activity
|
||||||
case ActivityMemberSubject.MEMBER_ADDED:
|
);
|
||||||
case ActivityMemberSubject.MEMBER_INVITED:
|
|
||||||
case ActivityMemberSubject.MEMBER_JOINED:
|
|
||||||
case ActivityMemberSubject.MEMBER_APPROVED:
|
|
||||||
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
|
||||||
return "is-success";
|
|
||||||
case ActivityMemberSubject.MEMBER_REQUEST:
|
|
||||||
case ActivityMemberSubject.MEMBER_UPDATED:
|
|
||||||
return "is-grey";
|
|
||||||
case ActivityMemberSubject.MEMBER_REMOVED:
|
|
||||||
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
|
||||||
case ActivityMemberSubject.MEMBER_QUIT:
|
|
||||||
return "is-danger";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get roleUpdate(): string | undefined {
|
const translation = computed((): string | undefined => {
|
||||||
if (
|
switch (props.activity.subject) {
|
||||||
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.member_role) &&
|
case ActivityMemberSubject.MEMBER_REQUEST:
|
||||||
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.old_role)
|
if (isAuthorCurrentActor) {
|
||||||
) {
|
return "You requested to join the group.";
|
||||||
if (
|
|
||||||
MEMBER_ROLE_VALUE[this.subjectParams.member_role] >
|
|
||||||
MEMBER_ROLE_VALUE[this.subjectParams.old_role]
|
|
||||||
) {
|
|
||||||
switch (this.subjectParams.member_role) {
|
|
||||||
case MemberRole.MODERATOR:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You promoted {member} to moderator.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were promoted to moderator by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} promoted {member} to moderator.";
|
|
||||||
case MemberRole.ADMINISTRATOR:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You promoted {member} to administrator.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were promoted to administrator by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} promoted {member} to administrator.";
|
|
||||||
default:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You promoted the member {member} to an unknown role.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were promoted to an unknown role by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} promoted {member} to an unknown role.";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch (this.subjectParams.member_role) {
|
|
||||||
case MemberRole.MODERATOR:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You demoted {member} to moderator.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were demoted to moderator by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} demoted {member} to moderator.";
|
|
||||||
case MemberRole.MEMBER:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You demoted {member} to simple member.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were demoted to simple member by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} demoted {member} to simple member.";
|
|
||||||
default:
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You demoted the member {member} to an unknown role.";
|
|
||||||
}
|
|
||||||
if (this.isObjectMemberCurrentActor) {
|
|
||||||
return "You were demoted to an unknown role by {profile}.";
|
|
||||||
}
|
|
||||||
return "{profile} demoted {member} to an unknown role.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
return "{member} requested to join the group.";
|
||||||
if (this.isAuthorCurrentActor) {
|
case ActivityMemberSubject.MEMBER_INVITED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You invited {member}.";
|
||||||
|
}
|
||||||
|
return "{member} was invited by {profile}.";
|
||||||
|
case ActivityMemberSubject.MEMBER_ADDED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You added the member {member}.";
|
||||||
|
}
|
||||||
|
return "{profile} added the member {member}.";
|
||||||
|
case ActivityMemberSubject.MEMBER_APPROVED:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You approved {member}'s membership.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "Your membership was approved by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} approved {member}'s membership.";
|
||||||
|
case ActivityMemberSubject.MEMBER_JOINED:
|
||||||
|
return "{member} joined the group.";
|
||||||
|
case ActivityMemberSubject.MEMBER_UPDATED:
|
||||||
|
if (subjectParams.member_role && subjectParams.old_role) {
|
||||||
|
return roleUpdate.value;
|
||||||
|
}
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
return "You updated the member {member}.";
|
return "You updated the member {member}.";
|
||||||
}
|
}
|
||||||
return "{profile} updated the member {member}";
|
return "{profile} updated the member {member}.";
|
||||||
}
|
case ActivityMemberSubject.MEMBER_REMOVED:
|
||||||
|
if (subjectParams.member_role === MemberRole.NOT_APPROVED) {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You rejected {member}'s membership request.";
|
||||||
|
}
|
||||||
|
return "{profile} rejected {member}'s membership request.";
|
||||||
|
}
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You excluded member {member}.";
|
||||||
|
}
|
||||||
|
return "{profile} excluded member {member}.";
|
||||||
|
case ActivityMemberSubject.MEMBER_QUIT:
|
||||||
|
return "{profile} quit the group.";
|
||||||
|
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
||||||
|
return "{member} rejected the invitation to join the group.";
|
||||||
|
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You accepted the invitation to join the group.";
|
||||||
|
}
|
||||||
|
return "{member} accepted the invitation to join the group.";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get isObjectMemberCurrentActor(): boolean {
|
const icon = computed((): string => {
|
||||||
return (
|
switch (props.activity.subject) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
case ActivityMemberSubject.MEMBER_REQUEST:
|
||||||
// @ts-ignore
|
case ActivityMemberSubject.MEMBER_ADDED:
|
||||||
this.activity?.object?.actor?.id === this.currentActor?.id &&
|
case ActivityMemberSubject.MEMBER_INVITED:
|
||||||
this.currentActor?.id !== undefined
|
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
||||||
);
|
return "account-multiple-plus";
|
||||||
|
case ActivityMemberSubject.MEMBER_REMOVED:
|
||||||
|
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
||||||
|
case ActivityMemberSubject.MEMBER_QUIT:
|
||||||
|
return "account-multiple-minus";
|
||||||
|
case ActivityMemberSubject.MEMBER_UPDATED:
|
||||||
|
default:
|
||||||
|
return "account-multiple";
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const iconColor = computed((): string | undefined => {
|
||||||
|
switch (props.activity.subject) {
|
||||||
|
case ActivityMemberSubject.MEMBER_ADDED:
|
||||||
|
case ActivityMemberSubject.MEMBER_INVITED:
|
||||||
|
case ActivityMemberSubject.MEMBER_JOINED:
|
||||||
|
case ActivityMemberSubject.MEMBER_APPROVED:
|
||||||
|
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
|
||||||
|
return "is-success";
|
||||||
|
case ActivityMemberSubject.MEMBER_REQUEST:
|
||||||
|
case ActivityMemberSubject.MEMBER_UPDATED:
|
||||||
|
return "is-grey";
|
||||||
|
case ActivityMemberSubject.MEMBER_REMOVED:
|
||||||
|
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
|
||||||
|
case ActivityMemberSubject.MEMBER_QUIT:
|
||||||
|
return "is-danger";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleUpdate = computed((): string | undefined => {
|
||||||
|
if (
|
||||||
|
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.member_role) &&
|
||||||
|
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.old_role)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
MEMBER_ROLE_VALUE[subjectParams.member_role] >
|
||||||
|
MEMBER_ROLE_VALUE[subjectParams.old_role]
|
||||||
|
) {
|
||||||
|
switch (subjectParams.member_role) {
|
||||||
|
case MemberRole.MODERATOR:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You promoted {member} to moderator.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were promoted to moderator by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} promoted {member} to moderator.";
|
||||||
|
case MemberRole.ADMINISTRATOR:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You promoted {member} to administrator.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were promoted to administrator by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} promoted {member} to administrator.";
|
||||||
|
default:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You promoted the member {member} to an unknown role.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were promoted to an unknown role by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} promoted {member} to an unknown role.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (subjectParams.member_role) {
|
||||||
|
case MemberRole.MODERATOR:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You demoted {member} to moderator.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were demoted to moderator by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} demoted {member} to moderator.";
|
||||||
|
case MemberRole.MEMBER:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You demoted {member} to simple member.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were demoted to simple member by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} demoted {member} to simple member.";
|
||||||
|
default:
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You demoted the member {member} to an unknown role.";
|
||||||
|
}
|
||||||
|
if (isObjectMemberCurrentActor) {
|
||||||
|
return "You were demoted to an unknown role by {profile}.";
|
||||||
|
}
|
||||||
|
return "{profile} demoted {member} to an unknown role.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You updated the member {member}.";
|
||||||
|
}
|
||||||
|
return "{profile} updated the member {member}";
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -1,92 +1,92 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="'bullhorn'" :type="iconColor" />
|
<o-icon :icon="'bullhorn'" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<router-link
|
<template #post>
|
||||||
v-if="activity.object"
|
<router-link
|
||||||
slot="post"
|
v-if="activity.object"
|
||||||
:to="{
|
:to="{
|
||||||
name: RouteName.POST,
|
name: RouteName.POST,
|
||||||
params: { slug: subjectParams.post_slug },
|
params: { slug: subjectParams.post_slug },
|
||||||
}"
|
}"
|
||||||
>{{ subjectParams.post_title }}</router-link
|
>{{ subjectParams.post_title }}</router-link
|
||||||
>
|
>
|
||||||
<b v-else slot="post">{{ subjectParams.post_title }}</b>
|
<b v-else>{{ subjectParams.post_title }}</b>
|
||||||
<popover-actor-card
|
</template>
|
||||||
:actor="activity.author"
|
<template #profile>
|
||||||
:inline="true"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
slot="profile"
|
<b>
|
||||||
>
|
{{
|
||||||
<b>
|
$t("{'@'}{username}", {
|
||||||
{{
|
username: usernameWithDomain(activity.author),
|
||||||
$t("@{username}", {
|
})
|
||||||
username: usernameWithDomain(activity.author),
|
}}</b
|
||||||
})
|
></popover-actor-card
|
||||||
}}</b
|
></template
|
||||||
></popover-actor-card
|
></i18n-t
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { usernameWithDomain } from "@/types/actor";
|
import { usernameWithDomain } from "@/types/actor";
|
||||||
import { ActivityPostSubject } from "@/types/enums";
|
import { ActivityPostSubject } from "@/types/enums";
|
||||||
import { Component } from "vue-property-decorator";
|
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
import ActivityMixin from "../../mixins/activity";
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
import { mixins } from "vue-class-component";
|
import {
|
||||||
|
useIsActivityAuthorCurrentActor,
|
||||||
|
useActivitySubjectParams,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
import { IActivity } from "@/types/activity.model";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class PostActivityItem extends mixins(ActivityMixin) {
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
RouteName = RouteName;
|
|
||||||
ActivityPostSubject = ActivityPostSubject;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityPostSubject.POST_CREATED:
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You created the post {post}.";
|
const translation = computed((): string | undefined => {
|
||||||
}
|
switch (props.activity.subject) {
|
||||||
return "The post {post} was created by {profile}.";
|
case ActivityPostSubject.POST_CREATED:
|
||||||
case ActivityPostSubject.POST_UPDATED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You created the post {post}.";
|
||||||
return "You updated the post {post}.";
|
}
|
||||||
}
|
return "The post {post} was created by {profile}.";
|
||||||
return "The post {post} was updated by {profile}.";
|
case ActivityPostSubject.POST_UPDATED:
|
||||||
case ActivityPostSubject.POST_DELETED:
|
if (isAuthorCurrentActor) {
|
||||||
if (this.isAuthorCurrentActor) {
|
return "You updated the post {post}.";
|
||||||
return "You deleted the post {post}.";
|
}
|
||||||
}
|
return "The post {post} was updated by {profile}.";
|
||||||
return "The post {post} was deleted by {profile}.";
|
case ActivityPostSubject.POST_DELETED:
|
||||||
default:
|
if (isAuthorCurrentActor) {
|
||||||
return undefined;
|
return "You deleted the post {post}.";
|
||||||
}
|
}
|
||||||
|
return "The post {post} was deleted by {profile}.";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const iconColor = computed((): string | undefined => {
|
||||||
switch (this.activity.subject) {
|
switch (props.activity.subject) {
|
||||||
case ActivityPostSubject.POST_CREATED:
|
case ActivityPostSubject.POST_CREATED:
|
||||||
return "is-success";
|
return "is-success";
|
||||||
case ActivityPostSubject.POST_UPDATED:
|
case ActivityPostSubject.POST_UPDATED:
|
||||||
return "is-grey";
|
return "is-grey";
|
||||||
case ActivityPostSubject.POST_DELETED:
|
case ActivityPostSubject.POST_DELETED:
|
||||||
return "is-danger";
|
return "is-danger";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -1,189 +1,193 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<b-icon :icon="'link'" :type="iconColor" />
|
<o-icon :icon="'link'" :type="iconColor" />
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<i18n :path="translation" tag="p">
|
<i18n-t :keypath="translation" tag="p">
|
||||||
<router-link v-if="activity.object" slot="resource" :to="path">{{
|
<template #resource>
|
||||||
subjectParams.resource_title
|
<router-link v-if="activity.object" :to="path">{{
|
||||||
}}</router-link>
|
subjectParams.resource_title
|
||||||
<b v-else slot="resource">{{ subjectParams.resource_title }}</b>
|
}}</router-link>
|
||||||
<router-link v-if="activity.object" slot="new_path" :to="path">{{
|
<b v-else>{{ subjectParams.resource_title }}</b>
|
||||||
parentDirectory
|
</template>
|
||||||
}}</router-link>
|
<template #new_path>
|
||||||
<b v-else slot="new_path">{{ parentDirectory }}</b>
|
<router-link v-if="activity.object" :to="path">{{
|
||||||
<router-link
|
parentDirectory
|
||||||
v-if="activity.object && subjectParams.old_resource_title"
|
}}</router-link>
|
||||||
slot="old_resource_title"
|
<b v-else>{{ parentDirectory }}</b>
|
||||||
:to="path"
|
</template>
|
||||||
>{{ subjectParams.old_resource_title }}</router-link
|
<template #old_resource_title>
|
||||||
>
|
<router-link
|
||||||
<b
|
v-if="activity.object && subjectParams.old_resource_title"
|
||||||
v-else-if="subjectParams.old_resource_title"
|
:to="path"
|
||||||
slot="old_resource_title"
|
>{{ subjectParams.old_resource_title }}</router-link
|
||||||
>{{ subjectParams.old_resource_title }}</b
|
>
|
||||||
>
|
<b v-else-if="subjectParams.old_resource_title">{{
|
||||||
|
subjectParams.old_resource_title
|
||||||
|
}}</b>
|
||||||
|
</template>
|
||||||
|
|
||||||
<popover-actor-card
|
<template #profile>
|
||||||
:actor="activity.author"
|
<popover-actor-card :actor="activity.author" :inline="true">
|
||||||
:inline="true"
|
<b>
|
||||||
slot="profile"
|
{{
|
||||||
>
|
$t("{'@'}{username}", {
|
||||||
<b>
|
username: usernameWithDomain(activity.author),
|
||||||
{{
|
})
|
||||||
$t("@{username}", {
|
}}</b
|
||||||
username: usernameWithDomain(activity.author),
|
></popover-actor-card
|
||||||
})
|
></template
|
||||||
}}</b
|
></i18n-t
|
||||||
></popover-actor-card
|
|
||||||
></i18n
|
|
||||||
>
|
>
|
||||||
<small class="has-text-grey-dark activity-date">{{
|
<small class="activity-date">{{
|
||||||
activity.insertedAt | formatTimeString
|
formatTimeString(activity.insertedAt)
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { usernameWithDomain } from "@/types/actor";
|
import { usernameWithDomain } from "@/types/actor";
|
||||||
import { ActivityResourceSubject } from "@/types/enums";
|
import { ActivityResourceSubject } from "@/types/enums";
|
||||||
import { Component } from "vue-property-decorator";
|
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
import ActivityMixin from "../../mixins/activity";
|
import { formatTimeString } from "@/filters/datetime";
|
||||||
import { mixins } from "vue-class-component";
|
import {
|
||||||
import { Location } from "vue-router";
|
useIsActivityAuthorCurrentActor,
|
||||||
|
useActivitySubjectParams,
|
||||||
|
} from "@/composition/activity";
|
||||||
|
import { IActivity } from "@/types/activity.model";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { IResource } from "@/types/resource";
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
activity: IActivity;
|
||||||
PopoverActorCard,
|
}>();
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class ResourceActivityItem extends mixins(ActivityMixin) {
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
RouteName = RouteName;
|
|
||||||
|
|
||||||
get translation(): string | undefined {
|
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
|
||||||
switch (this.activity.subject) {
|
|
||||||
case ActivityResourceSubject.RESOURCE_CREATED:
|
const subjectParams = useActivitySubjectParams()(props.activity);
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
const resource = computed(() => props.activity.object as IResource);
|
||||||
if (this.activity?.object?.type === "folder") {
|
|
||||||
if (this.isAuthorCurrentActor) {
|
const translation = computed((): string | undefined => {
|
||||||
return "You created the folder {resource}.";
|
switch (props.activity.subject) {
|
||||||
|
case ActivityResourceSubject.RESOURCE_CREATED:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.activity?.object?.type === "folder") {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You created the folder {resource}.";
|
||||||
|
}
|
||||||
|
return "{profile} created the folder {resource}.";
|
||||||
|
}
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You created the resource {resource}.";
|
||||||
|
}
|
||||||
|
return "{profile} created the resource {resource}.";
|
||||||
|
case ActivityResourceSubject.RESOURCE_MOVED:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.activity?.object?.type === "folder") {
|
||||||
|
if (parentDirectory.value === null) {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You moved the folder {resource} to the root folder.";
|
||||||
}
|
}
|
||||||
return "{profile} created the folder {resource}.";
|
return "{profile} moved the folder {resource} to the root folder.";
|
||||||
}
|
}
|
||||||
if (this.isAuthorCurrentActor) {
|
if (isAuthorCurrentActor) {
|
||||||
return "You created the resource {resource}.";
|
return "You moved the folder {resource} into {new_path}.";
|
||||||
}
|
}
|
||||||
return "{profile} created the resource {resource}.";
|
return "{profile} moved the folder {resource} into {new_path}.";
|
||||||
case ActivityResourceSubject.RESOURCE_MOVED:
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
if (parentDirectory.value === null) {
|
||||||
// @ts-ignore
|
if (isAuthorCurrentActor) {
|
||||||
if (this.activity?.object?.type === "folder") {
|
return "You moved the resource {resource} to the root folder.";
|
||||||
if (this.parentDirectory === null) {
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You moved the folder {resource} to the root folder.";
|
|
||||||
}
|
|
||||||
return "{profile} moved the folder {resource} to the root folder.";
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You moved the folder {resource} into {new_path}.";
|
|
||||||
}
|
|
||||||
return "{profile} moved the folder {resource} into {new_path}.";
|
|
||||||
}
|
}
|
||||||
if (this.parentDirectory === null) {
|
return "{profile} moved the resource {resource} to the root folder.";
|
||||||
if (this.isAuthorCurrentActor) {
|
}
|
||||||
return "You moved the resource {resource} to the root folder.";
|
if (isAuthorCurrentActor) {
|
||||||
}
|
return "You moved the resource {resource} into {new_path}.";
|
||||||
return "{profile} moved the resource {resource} to the root folder.";
|
}
|
||||||
|
return "{profile} moved the resource {resource} into {new_path}.";
|
||||||
|
case ActivityResourceSubject.RESOURCE_UPDATED:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.activity?.object?.type === "folder") {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You renamed the folder from {old_resource_title} to {resource}.";
|
||||||
}
|
}
|
||||||
if (this.isAuthorCurrentActor) {
|
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
|
||||||
return "You moved the resource {resource} into {new_path}.";
|
}
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You renamed the resource from {old_resource_title} to {resource}.";
|
||||||
|
}
|
||||||
|
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
|
||||||
|
case ActivityResourceSubject.RESOURCE_DELETED:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.activity?.object?.type === "folder") {
|
||||||
|
if (isAuthorCurrentActor) {
|
||||||
|
return "You deleted the folder {resource}.";
|
||||||
}
|
}
|
||||||
return "{profile} moved the resource {resource} into {new_path}.";
|
return "{profile} deleted the folder {resource}.";
|
||||||
case ActivityResourceSubject.RESOURCE_UPDATED:
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
if (isAuthorCurrentActor) {
|
||||||
// @ts-ignore
|
return "You deleted the resource {resource}.";
|
||||||
if (this.activity?.object?.type === "folder") {
|
}
|
||||||
if (this.isAuthorCurrentActor) {
|
return "{profile} deleted the resource {resource}.";
|
||||||
return "You renamed the folder from {old_resource_title} to {resource}.";
|
default:
|
||||||
}
|
return undefined;
|
||||||
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You renamed the resource from {old_resource_title} to {resource}.";
|
|
||||||
}
|
|
||||||
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
|
|
||||||
case ActivityResourceSubject.RESOURCE_DELETED:
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
if (this.activity?.object?.type === "folder") {
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You deleted the folder {resource}.";
|
|
||||||
}
|
|
||||||
return "{profile} deleted the folder {resource}.";
|
|
||||||
}
|
|
||||||
if (this.isAuthorCurrentActor) {
|
|
||||||
return "You deleted the resource {resource}.";
|
|
||||||
}
|
|
||||||
return "{profile} deleted the resource {resource}.";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get iconColor(): string | undefined {
|
const iconColor = computed((): string | undefined => {
|
||||||
switch (this.activity.subject) {
|
switch (props.activity.subject) {
|
||||||
case ActivityResourceSubject.RESOURCE_CREATED:
|
case ActivityResourceSubject.RESOURCE_CREATED:
|
||||||
return "is-success";
|
return "is-success";
|
||||||
case ActivityResourceSubject.RESOURCE_MOVED:
|
case ActivityResourceSubject.RESOURCE_MOVED:
|
||||||
case ActivityResourceSubject.RESOURCE_UPDATED:
|
case ActivityResourceSubject.RESOURCE_UPDATED:
|
||||||
return "is-grey";
|
return "is-grey";
|
||||||
case ActivityResourceSubject.RESOURCE_DELETED:
|
case ActivityResourceSubject.RESOURCE_DELETED:
|
||||||
return "is-danger";
|
return "is-danger";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
get path(): Location {
|
const path = computed(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
const localPath = parentPath(resource.value?.path);
|
||||||
// @ts-ignore
|
if (localPath === "") {
|
||||||
const path = this.parentPath(this.activity?.object?.path);
|
|
||||||
if (path === "") {
|
|
||||||
return {
|
|
||||||
name: RouteName.RESOURCE_FOLDER_ROOT,
|
|
||||||
params: {
|
|
||||||
preferredUsername: usernameWithDomain(this.activity.group),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
name: RouteName.RESOURCE_FOLDER,
|
name: RouteName.RESOURCE_FOLDER_ROOT,
|
||||||
params: {
|
params: {
|
||||||
path,
|
preferredUsername: usernameWithDomain(props.activity.group),
|
||||||
preferredUsername: usernameWithDomain(this.activity.group),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
name: RouteName.RESOURCE_FOLDER,
|
||||||
|
params: {
|
||||||
|
path: localPath,
|
||||||
|
preferredUsername: usernameWithDomain(props.activity.group),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
get parentDirectory(): string | undefined | null {
|
const parentPath = (parent: string | undefined): string | undefined => {
|
||||||
if (this.subjectParams.resource_path) {
|
if (!parent) return undefined;
|
||||||
const parentPath = this.parentPath(this.subjectParams.resource_path);
|
const localPath = parent.split("/");
|
||||||
const directory = parentPath.split("/");
|
localPath.pop();
|
||||||
const res = directory.pop();
|
return localPath.join("/").replace(/^\//, "");
|
||||||
res === "" ? null : res;
|
};
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPath(parent: string): string {
|
const parentDirectory = computed((): string | undefined | null => {
|
||||||
const path = parent.split("/");
|
if (subjectParams.resource_path) {
|
||||||
path.pop();
|
const parentPathResult = parentPath(subjectParams.resource_path);
|
||||||
return path.join("/").replace(/^\//, "");
|
const directory = parentPathResult?.split("/");
|
||||||
|
const res = directory?.pop();
|
||||||
|
res === "" ? null : res;
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "./activity.scss";
|
@import "./activity.scss";
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
31
js/src/components/Address/AddressInfo.story.vue
Normal 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>
|
|
@ -1,22 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<address dir="auto">
|
<address dir="auto">
|
||||||
<b-icon
|
<o-icon
|
||||||
v-if="showIcon"
|
v-if="showIcon"
|
||||||
:icon="address.poiInfos.poiIcon.icon"
|
:icon="poiInfos?.poiIcon.icon"
|
||||||
size="is-medium"
|
size="is-medium"
|
||||||
class="icon"
|
class="icon"
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
<span
|
<span
|
||||||
class="addressDescription"
|
class="addressDescription"
|
||||||
:title="address.poiInfos.name"
|
:title="poiInfos.name"
|
||||||
v-if="address.poiInfos.name"
|
v-if="poiInfos?.name"
|
||||||
>
|
>
|
||||||
{{ address.poiInfos.name }}
|
{{ poiInfos.name }}
|
||||||
</span>
|
</span>
|
||||||
<br v-if="address.poiInfos.name" />
|
<br v-if="poiInfos?.name" />
|
||||||
<span class="has-text-grey-dark">
|
<span>
|
||||||
{{ address.poiInfos.alternativeName }}
|
{{ poiInfos?.alternativeName }}
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<small
|
<small
|
||||||
|
@ -25,7 +25,6 @@
|
||||||
longShortTimezoneNamesDifferent &&
|
longShortTimezoneNamesDifferent &&
|
||||||
timezoneLongNameValid
|
timezoneLongNameValid
|
||||||
"
|
"
|
||||||
class="has-text-grey-dark"
|
|
||||||
>
|
>
|
||||||
🌐
|
🌐
|
||||||
{{
|
{{
|
||||||
|
@ -35,72 +34,75 @@
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</small>
|
</small>
|
||||||
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark">
|
<small v-else-if="userTimezoneDifferent" class="">
|
||||||
🌐 {{ timezoneShortName }}
|
🌐 {{ timezoneShortName }}
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
</address>
|
</address>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { IAddress } from "@/types/address.model";
|
import { addressToPoiInfos, IAddress } from "@/types/address.model";
|
||||||
import { PropType } from "vue";
|
import { computed } from "vue";
|
||||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
|
||||||
|
|
||||||
@Component
|
const props = withDefaults(
|
||||||
export default class AddressInfo extends Vue {
|
defineProps<{
|
||||||
@Prop({ required: true, type: Object as PropType<IAddress> })
|
address: IAddress;
|
||||||
address!: IAddress;
|
showIcon?: boolean;
|
||||||
|
showTimezone?: boolean;
|
||||||
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean;
|
userTimezone?: string;
|
||||||
@Prop({ required: false, default: false, type: Boolean })
|
}>(),
|
||||||
showTimezone!: boolean;
|
{
|
||||||
@Prop({ required: false, type: String }) userTimezone!: string;
|
showIcon: false,
|
||||||
|
showTimezone: false,
|
||||||
get userTimezoneDifferent(): boolean {
|
|
||||||
return (
|
|
||||||
this.userTimezone != undefined &&
|
|
||||||
this.address.timezone != undefined &&
|
|
||||||
this.userTimezone !== this.address.timezone
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
get longShortTimezoneNamesDifferent(): boolean {
|
const poiInfos = computed(() => addressToPoiInfos(props.address));
|
||||||
return (
|
|
||||||
this.timezoneLongName != undefined &&
|
|
||||||
this.timezoneShortName != undefined &&
|
|
||||||
this.timezoneLongName !== this.timezoneShortName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get timezoneLongName(): string | undefined {
|
const userTimezoneDifferent = computed((): boolean => {
|
||||||
return this.timezoneName("long");
|
return (
|
||||||
}
|
props.userTimezone != undefined &&
|
||||||
|
props.address.timezone != undefined &&
|
||||||
|
props.userTimezone !== props.address.timezone
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
get timezoneShortName(): string | undefined {
|
const longShortTimezoneNamesDifferent = computed((): boolean => {
|
||||||
return this.timezoneName("short");
|
return (
|
||||||
}
|
timezoneLongName.value != undefined &&
|
||||||
|
timezoneShortName.value != undefined &&
|
||||||
|
timezoneLongName.value !== timezoneShortName.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
get timezoneLongNameValid(): boolean {
|
const timezoneLongName = computed((): string | undefined => {
|
||||||
return (
|
return timezoneName("long");
|
||||||
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/)
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private timezoneName(format: "long" | "short"): string | undefined {
|
const timezoneShortName = computed((): string | undefined => {
|
||||||
return this.extractTimezone(
|
return timezoneName("short");
|
||||||
new Intl.DateTimeFormat(undefined, {
|
});
|
||||||
timeZoneName: format,
|
|
||||||
timeZone: this.address.timezone,
|
|
||||||
}).formatToParts()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTimezone(
|
const timezoneLongNameValid = computed((): boolean => {
|
||||||
parts: Intl.DateTimeFormatPart[]
|
return (
|
||||||
): string | undefined {
|
timezoneLongName.value != undefined && !timezoneLongName.value.match(/UTC/)
|
||||||
return parts.find((part) => part.type === "timeZoneName")?.value;
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
const timezoneName = (format: "long" | "short"): string | undefined => {
|
||||||
|
return extractTimezone(
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZoneName: format,
|
||||||
|
timeZone: props.address.timezone,
|
||||||
|
}).formatToParts()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractTimezone = (
|
||||||
|
parts: Intl.DateTimeFormatPart[]
|
||||||
|
): string | undefined => {
|
||||||
|
return parts.find((part) => part.type === "timeZoneName")?.value;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/_mixins" as *;
|
@use "@/styles/_mixins" as *;
|
||||||
|
|
27
js/src/components/Address/InlineAddress.story.vue
Normal 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>
|
|
@ -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>
|
||||||
|
|
29
js/src/components/Categories/CategoryCard.story.vue
Normal 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>
|
78
js/src/components/Categories/CategoryCard.vue
Normal 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>
|
296
js/src/components/Categories/constants.ts
Normal 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",
|
||||||
|
];
|
177
js/src/components/Comment/Comment.story.vue
Normal 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>
|
|
@ -2,347 +2,348 @@
|
||||||
<li
|
<li
|
||||||
:class="{
|
:class="{
|
||||||
reply: comment.inReplyToComment,
|
reply: comment.inReplyToComment,
|
||||||
announcement: comment.isAnnouncement,
|
'bg-purple-2': comment.isAnnouncement,
|
||||||
selected: commentSelected,
|
'bg-violet-1': commentSelected,
|
||||||
|
'shadow-none': !rootComment,
|
||||||
}"
|
}"
|
||||||
class="comment-element"
|
class="mbz-card p-2"
|
||||||
>
|
>
|
||||||
<article class="media" :id="commentId" dir="auto">
|
<article :id="commentId" dir="auto">
|
||||||
<popover-actor-card
|
<div>
|
||||||
:actor="comment.actor"
|
<div class="flex items-center gap-2">
|
||||||
:inline="true"
|
<div class="flex items-center gap-1" v-if="actorComment">
|
||||||
v-if="comment.actor"
|
<popover-actor-card
|
||||||
>
|
:actor="actorComment"
|
||||||
<figure
|
:inline="true"
|
||||||
class="image is-32x32 media-left"
|
v-if="!comment.deletedAt && actorComment.avatar"
|
||||||
v-if="!comment.deletedAt && comment.actor.avatar"
|
>
|
||||||
>
|
<figure>
|
||||||
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
|
<img
|
||||||
</figure>
|
class="rounded-xl"
|
||||||
<b-icon class="media-left" v-else icon="account-circle" />
|
:src="actorComment.avatar.url"
|
||||||
</popover-actor-card>
|
alt=""
|
||||||
<div v-else class="media-left">
|
width="24"
|
||||||
<figure
|
height="24"
|
||||||
class="image is-32x32"
|
/>
|
||||||
v-if="!comment.deletedAt && comment.actor.avatar"
|
</figure>
|
||||||
>
|
</popover-actor-card>
|
||||||
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
|
<AccountCircle v-else />
|
||||||
</figure>
|
<strong
|
||||||
<b-icon v-else icon="account-circle" />
|
v-if="!comment.deletedAt"
|
||||||
</div>
|
dir="auto"
|
||||||
<div class="media-content">
|
:class="{ organizer: commentFromOrganizer }"
|
||||||
<div class="content">
|
>{{ actorComment?.name }}</strong
|
||||||
<span class="first-line" v-if="!comment.deletedAt" dir="auto">
|
>
|
||||||
<strong :class="{ organizer: commentFromOrganizer }">{{
|
</div>
|
||||||
comment.actor.name
|
|
||||||
}}</strong>
|
<a v-else :href="commentURL">
|
||||||
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small>
|
<span>{{ t("[deleted]") }}</span>
|
||||||
</span>
|
|
||||||
<a v-else class="comment-link" :href="commentURL">
|
|
||||||
<span>{{ $t("[deleted]") }}</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="comment-link" :href="commentURL">
|
<a :href="commentURL">
|
||||||
<small>{{
|
<small v-if="comment.updatedAt">{{
|
||||||
formatDistanceToNow(new Date(comment.updatedAt), {
|
formatDistanceToNow(new Date(comment.updatedAt), {
|
||||||
locale: $dateFnsLocale,
|
locale: dateFnsLocale,
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})
|
})
|
||||||
}}</small>
|
}}</small>
|
||||||
</a>
|
</a>
|
||||||
<span class="icons" v-if="!comment.deletedAt">
|
<div v-if="!comment.deletedAt" class="flex">
|
||||||
<button
|
<button
|
||||||
v-if="comment.actor.id === currentActor.id"
|
v-if="actorComment?.id === currentActor?.id"
|
||||||
@click="deleteComment"
|
@click="deleteComment"
|
||||||
>
|
>
|
||||||
<b-icon icon="delete" size="is-small" aria-hidden="true" />
|
<Delete :size="16" />
|
||||||
<span class="visually-hidden">{{ $t("Delete") }}</span>
|
<span class="sr-only">{{ t("Delete") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="reportModal()">
|
<button @click="reportModal">
|
||||||
<b-icon icon="alert" size="is-small" />
|
<Alert :size="16" />
|
||||||
<span class="visually-hidden">{{ $t("Report") }}</span>
|
<span class="sr-only">{{ t("Report") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<div
|
|
||||||
v-if="!comment.deletedAt"
|
|
||||||
v-html="comment.text"
|
|
||||||
dir="auto"
|
|
||||||
:lang="comment.language"
|
|
||||||
/>
|
|
||||||
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
|
|
||||||
<div class="load-replies" v-if="comment.totalReplies">
|
|
||||||
<p v-if="!showReplies" @click="fetchReplies">
|
|
||||||
<b-icon icon="chevron-down" class="reply-btn" />
|
|
||||||
<span class="reply-btn">{{
|
|
||||||
$tc("View a reply", comment.totalReplies, {
|
|
||||||
totalReplies: comment.totalReplies,
|
|
||||||
})
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else-if="comment.totalReplies && showReplies"
|
|
||||||
@click="showReplies = false"
|
|
||||||
>
|
|
||||||
<b-icon icon="chevron-up" class="reply-btn" />
|
|
||||||
<span class="reply-btn">{{ $t("Hide replies") }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!comment.deletedAt"
|
||||||
|
v-html="comment.text"
|
||||||
|
dir="auto"
|
||||||
|
:lang="comment.language"
|
||||||
|
/>
|
||||||
|
<div v-else>{{ t("[This comment has been deleted]") }}</div>
|
||||||
|
<div class="" v-if="comment.totalReplies">
|
||||||
|
<p
|
||||||
|
v-if="!showReplies"
|
||||||
|
@click="showReplies = true"
|
||||||
|
class="flex cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronDown />
|
||||||
|
<span>{{
|
||||||
|
t(
|
||||||
|
"View a reply",
|
||||||
|
{
|
||||||
|
totalReplies: comment.totalReplies,
|
||||||
|
},
|
||||||
|
comment.totalReplies
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="comment.totalReplies && showReplies"
|
||||||
|
@click="showReplies = false"
|
||||||
|
class="flex cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronUp />
|
||||||
|
<span>{{ t("Hide replies") }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<nav
|
<nav
|
||||||
class="reply-action level is-mobile"
|
|
||||||
v-if="
|
v-if="
|
||||||
currentActor.id &&
|
currentActor?.id &&
|
||||||
event.options.commentModeration !== CommentModeration.CLOSED &&
|
event.options.commentModeration !== CommentModeration.CLOSED &&
|
||||||
!comment.deletedAt
|
!comment.deletedAt
|
||||||
"
|
"
|
||||||
|
@click="createReplyToComment()"
|
||||||
|
class="flex gap-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="level-left">
|
<Reply />
|
||||||
<span
|
<span>{{ t("Reply") }}</span>
|
||||||
style="cursor: pointer"
|
|
||||||
class="level-item reply-btn"
|
|
||||||
@click="createReplyToComment()"
|
|
||||||
>
|
|
||||||
<span class="icon is-small">
|
|
||||||
<b-icon icon="reply" />
|
|
||||||
</span>
|
|
||||||
<span>{{ $t("Reply") }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<form
|
<form
|
||||||
class="reply"
|
|
||||||
@submit.prevent="replyToComment"
|
@submit.prevent="replyToComment"
|
||||||
v-if="currentActor.id"
|
v-if="currentActor?.id"
|
||||||
v-show="replyTo"
|
v-show="replyTo"
|
||||||
>
|
>
|
||||||
<article class="media reply">
|
<article class="flex gap-2">
|
||||||
<figure class="media-left" v-if="currentActor.avatar">
|
<figure v-if="currentActor?.avatar" class="mt-4">
|
||||||
<p class="image is-48x48">
|
<img
|
||||||
<img :src="currentActor.avatar.url" alt="" />
|
:src="currentActor?.avatar.url"
|
||||||
</p>
|
alt=""
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
class="rounded-md"
|
||||||
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<b-icon
|
<AccountCircle v-else :size="48" />
|
||||||
class="media-left"
|
<div class="flex-1">
|
||||||
v-else
|
<div class="flex gap-1 items-center">
|
||||||
size="is-large"
|
<strong>{{ currentActor?.name }}</strong>
|
||||||
icon="account-circle"
|
<small dir="ltr">@{{ currentActor?.preferredUsername }}</small>
|
||||||
/>
|
</div>
|
||||||
<div class="media-content">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="content">
|
<editor
|
||||||
<span class="first-line">
|
ref="commentEditor"
|
||||||
<strong>{{ currentActor.name }}</strong>
|
v-model="newComment.text"
|
||||||
<small dir="ltr">@{{ currentActor.preferredUsername }}</small>
|
mode="comment"
|
||||||
</span>
|
:current-actor="currentActor"
|
||||||
<br />
|
:aria-label="t('Comment body')"
|
||||||
<span class="editor-line">
|
class="flex-1"
|
||||||
<editor
|
/>
|
||||||
class="editor"
|
<o-button
|
||||||
ref="commentEditor"
|
:disabled="newComment.text.trim().length === 0"
|
||||||
v-model="newComment.text"
|
native-type="submit"
|
||||||
mode="comment"
|
variant="primary"
|
||||||
:aria-label="$t('Comment body')"
|
class="self-end"
|
||||||
/>
|
>{{ t("Post a reply") }}</o-button
|
||||||
<b-button
|
>
|
||||||
:disabled="newComment.text.trim().length === 0"
|
|
||||||
native-type="submit"
|
|
||||||
type="is-primary"
|
|
||||||
>{{ $t("Post a reply") }}</b-button
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</form>
|
</form>
|
||||||
<div class="replies">
|
<div>
|
||||||
<div class="left">
|
<div>
|
||||||
<div class="vertical-border" @click="showReplies = false" />
|
<div @click="showReplies = false" />
|
||||||
</div>
|
</div>
|
||||||
<transition-group
|
<transition-group
|
||||||
name="comment-replies"
|
name="comment-replies"
|
||||||
v-if="showReplies"
|
v-if="showReplies"
|
||||||
class="comment-replies"
|
|
||||||
tag="ul"
|
tag="ul"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<comment
|
<Comment
|
||||||
class="reply"
|
|
||||||
v-for="reply in comment.replies"
|
v-for="reply in comment.replies"
|
||||||
:key="reply.id"
|
:key="reply.id"
|
||||||
:comment="reply"
|
:comment="reply"
|
||||||
:event="event"
|
:event="event"
|
||||||
@create-comment="$emit('create-comment', $event)"
|
:currentActor="currentActor"
|
||||||
@delete-comment="$emit('delete-comment', $event)"
|
:rootComment="false"
|
||||||
|
@create-comment="emit('create-comment', $event)"
|
||||||
|
@delete-comment="emit('delete-comment', $event)"
|
||||||
|
@report-comment="emit('report-comment', $event)"
|
||||||
/>
|
/>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
|
|
||||||
import EditorComponent from "@/components/Editor.vue";
|
import EditorComponent from "@/components/Editor.vue";
|
||||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { CommentModeration } from "@/types/enums";
|
import { CommentModeration } from "@/types/enums";
|
||||||
import { CommentModel, IComment } from "../../types/comment.model";
|
import { CommentModel, IComment } from "../../types/comment.model";
|
||||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
import { IPerson } from "../../types/actor";
|
||||||
import { IPerson, usernameWithDomain } from "../../types/actor";
|
|
||||||
import { IEvent } from "../../types/event.model";
|
import { IEvent } from "../../types/event.model";
|
||||||
import ReportModal from "../Report/ReportModal.vue";
|
|
||||||
import { IReport } from "../../types/report.model";
|
|
||||||
import { CREATE_REPORT } from "../../graphql/report";
|
|
||||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineAsyncComponent,
|
||||||
|
inject,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
nextTick,
|
||||||
|
} from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||||
|
import Delete from "vue-material-design-icons/Delete.vue";
|
||||||
|
import Alert from "vue-material-design-icons/Alert.vue";
|
||||||
|
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
|
||||||
|
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
|
||||||
|
import Reply from "vue-material-design-icons/Reply.vue";
|
||||||
|
|
||||||
@Component({
|
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
|
||||||
apollo: {
|
|
||||||
currentActor: {
|
|
||||||
query: CURRENT_ACTOR_CLIENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
editor: () =>
|
|
||||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
|
||||||
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
|
|
||||||
PopoverActorCard,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class Comment extends Vue {
|
|
||||||
@Prop({ required: true, type: Object }) comment!: IComment;
|
|
||||||
|
|
||||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
comment: IComment;
|
||||||
|
event: IEvent;
|
||||||
|
currentActor: IPerson;
|
||||||
|
rootComment?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ rootComment: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Hack because Vue only exports it's own interface.
|
const emit = defineEmits([
|
||||||
// See https://github.com/kaorun343/vue-property-decorator/issues/257
|
"create-comment",
|
||||||
@Ref() readonly commentEditor!: EditorComponent & {
|
"delete-comment",
|
||||||
replyToComment: (comment: IComment) => void;
|
"report-comment",
|
||||||
focus: () => void;
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
currentActor!: IPerson;
|
const commentEditor = ref<typeof EditorComponent | null>(null);
|
||||||
|
|
||||||
newComment: IComment = new CommentModel();
|
// Hack because Vue only exports it's own interface.
|
||||||
|
// See https://github.com/kaorun343/vue-property-decorator/issues/257
|
||||||
|
// @Ref() readonly commentEditor!: EditorComponent & {
|
||||||
|
// replyToComment: (comment: IComment) => void;
|
||||||
|
// focus: () => void;
|
||||||
|
// };
|
||||||
|
|
||||||
replyTo = false;
|
const newComment = ref<IComment>(new CommentModel());
|
||||||
|
const replyTo = ref(false);
|
||||||
|
const showReplies = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
showReplies = false;
|
onMounted(() => {
|
||||||
|
if (route?.hash.includes(`#comment-${props.comment.uuid}`)) {
|
||||||
CommentModeration = CommentModeration;
|
showReplies.value = true;
|
||||||
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
|
||||||
|
|
||||||
formatDistanceToNow = formatDistanceToNow;
|
|
||||||
|
|
||||||
async mounted(): Promise<void> {
|
|
||||||
const { hash } = this.$route;
|
|
||||||
if (hash.includes(`#comment-${this.comment.uuid}`)) {
|
|
||||||
this.fetchReplies();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async createReplyToComment(): Promise<void> {
|
const createReplyToComment = async (): Promise<void> => {
|
||||||
if (this.replyTo) {
|
if (replyTo.value) {
|
||||||
this.replyTo = false;
|
replyTo.value = false;
|
||||||
this.newComment = new CommentModel();
|
newComment.value = new CommentModel();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
this.replyTo = true;
|
|
||||||
if (this.comment.actor) {
|
|
||||||
this.commentEditor.replyToComment(this.comment.actor);
|
|
||||||
await this.$nextTick; // wait for the mention to be injected
|
|
||||||
this.commentEditor.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
replyTo.value = true;
|
||||||
|
if (props.comment.actor) {
|
||||||
|
commentEditor.value?.replyToComment(props.comment.actor);
|
||||||
|
await nextTick(); // wait for the mention to be injected
|
||||||
|
commentEditor.value?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
replyToComment(): void {
|
const replyToComment = (): void => {
|
||||||
this.newComment.inReplyToComment = this.comment;
|
newComment.value.inReplyToComment = props.comment;
|
||||||
this.newComment.originComment = this.comment.originComment || this.comment;
|
newComment.value.originComment = props.comment.originComment ?? props.comment;
|
||||||
this.newComment.actor = this.currentActor;
|
newComment.value.actor = props.currentActor;
|
||||||
this.$emit("create-comment", this.newComment);
|
console.log(newComment.value);
|
||||||
this.newComment = new CommentModel();
|
emit("create-comment", newComment.value);
|
||||||
this.replyTo = false;
|
newComment.value = new CommentModel();
|
||||||
this.showReplies = true;
|
replyTo.value = false;
|
||||||
}
|
showReplies.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
deleteComment(): void {
|
const deleteComment = (): void => {
|
||||||
this.$emit("delete-comment", this.comment);
|
emit("delete-comment", props.comment);
|
||||||
this.showReplies = false;
|
showReplies.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
fetchReplies(): void {
|
const commentSelected = computed((): boolean => {
|
||||||
this.showReplies = true;
|
return `#${commentId.value}` === route?.hash;
|
||||||
}
|
});
|
||||||
|
|
||||||
get commentSelected(): boolean {
|
const commentFromOrganizer = computed((): boolean => {
|
||||||
return `#${this.commentId}` === this.$route.hash;
|
const organizerId =
|
||||||
}
|
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
|
||||||
|
return organizerId !== undefined && props.comment?.actor?.id === organizerId;
|
||||||
|
});
|
||||||
|
|
||||||
get commentFromOrganizer(): boolean {
|
const commentId = computed((): string => {
|
||||||
const organizerId =
|
if (props.comment.originComment)
|
||||||
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
|
return `comment-${props.comment.originComment.uuid}-${props.comment.uuid}`;
|
||||||
return organizerId !== undefined && this.comment?.actor?.id === organizerId;
|
return `comment-${props.comment.uuid}`;
|
||||||
}
|
});
|
||||||
|
|
||||||
get commentId(): string {
|
const commentURL = computed((): string => {
|
||||||
if (this.comment.originComment)
|
if (!props.comment.local && props.comment.url) return props.comment.url;
|
||||||
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
|
return `#${commentId.value}`;
|
||||||
return `comment-${this.comment.uuid}`;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
get commentURL(): string {
|
const reportModal = (): void => {
|
||||||
if (!this.comment.local && this.comment.url) return this.comment.url;
|
if (!props.comment.actor) return;
|
||||||
return `#${this.commentId}`;
|
emit("report-comment", props.comment);
|
||||||
}
|
// this.$buefy.modal.open({
|
||||||
|
// component: ReportModal,
|
||||||
|
// props: {
|
||||||
|
// title: t("Report this comment"),
|
||||||
|
// comment: props.comment,
|
||||||
|
// onConfirm: reportComment,
|
||||||
|
// outsideDomain: props.comment.actor?.domain,
|
||||||
|
// },
|
||||||
|
// // https://github.com/buefy/buefy/pull/3589
|
||||||
|
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// // @ts-ignore
|
||||||
|
// closeButtonAriaLabel: this.t("Close"),
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
reportModal(): void {
|
// const reportComment = async (
|
||||||
if (!this.comment.actor) return;
|
// content: string,
|
||||||
this.$buefy.modal.open({
|
// forward: boolean
|
||||||
parent: this,
|
// ): Promise<void> => {
|
||||||
component: ReportModal,
|
// try {
|
||||||
props: {
|
// if (!props.comment.actor) return;
|
||||||
title: this.$t("Report this comment"),
|
|
||||||
comment: this.comment,
|
|
||||||
onConfirm: this.reportComment,
|
|
||||||
outsideDomain: this.comment.actor.domain,
|
|
||||||
},
|
|
||||||
// https://github.com/buefy/buefy/pull/3589
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
closeButtonAriaLabel: this.$t("Close"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async reportComment(content: string, forward: boolean): Promise<void> {
|
// const { onError, onDone } = useMutation(CREATE_REPORT, () => ({
|
||||||
try {
|
// variables: {
|
||||||
if (!this.comment.actor) return;
|
// eventId: props.event.id,
|
||||||
await this.$apollo.mutate<IReport>({
|
// reportedId: props.comment.actor?.id,
|
||||||
mutation: CREATE_REPORT,
|
// commentsIds: [props.comment.id],
|
||||||
variables: {
|
// content,
|
||||||
eventId: this.event.id,
|
// forward,
|
||||||
reportedId: this.comment.actor.id,
|
// },
|
||||||
commentsIds: [this.comment.id],
|
// }));
|
||||||
content,
|
|
||||||
forward,
|
// // this.$buefy.notification.open({
|
||||||
},
|
// // message: this.t("Comment from @{username} reported", {
|
||||||
});
|
// // username: this.comment.actor.preferredUsername,
|
||||||
this.$buefy.notification.open({
|
// // }) as string,
|
||||||
message: this.$t("Comment from @{username} reported", {
|
// // type: "is-success",
|
||||||
username: this.comment.actor.preferredUsername,
|
// // position: "is-bottom-right",
|
||||||
}) as string,
|
// // duration: 5000,
|
||||||
type: "is-success",
|
// // });
|
||||||
position: "is-bottom-right",
|
// } catch (e: any) {
|
||||||
duration: 5000,
|
// if (e.message) {
|
||||||
});
|
// // Snackbar.open({
|
||||||
} catch (e: any) {
|
// // message: e.message,
|
||||||
if (e.message) {
|
// // type: "is-danger",
|
||||||
Snackbar.open({
|
// // position: "is-bottom",
|
||||||
message: e.message,
|
// // });
|
||||||
type: "is-danger",
|
// }
|
||||||
position: "is-bottom",
|
// }
|
||||||
});
|
// };
|
||||||
}
|
const actorComment = computed(() => props.comment.actor);
|
||||||
}
|
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/_mixins" as *;
|
@use "@/styles/_mixins" as *;
|
||||||
|
@ -364,9 +365,9 @@ form.reply {
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > small {
|
// & > small {
|
||||||
@include margin-left(0.3rem);
|
// @include margin-left(0.3rem);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-line {
|
.editor-line {
|
||||||
|
@ -375,15 +376,15 @@ form.reply {
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@include padding-right(10px);
|
// @include padding-right(10px);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.comment-link {
|
a.comment-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@include margin-left(5px);
|
// @include margin-left(5px);
|
||||||
color: $text;
|
color: text;
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
@ -425,9 +426,9 @@ a.comment-link {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-left {
|
// .media-left {
|
||||||
@include margin-right(5px);
|
// @include margin-right(5px);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
.root-comment .replies {
|
.root-comment .replies {
|
||||||
|
@ -437,7 +438,7 @@ a.comment-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@include margin-right(10px);
|
// @include margin-right(10px);
|
||||||
|
|
||||||
.vertical-border {
|
.vertical-border {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
|
@ -528,9 +529,9 @@ article {
|
||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-action .icon {
|
// .reply-action .icon {
|
||||||
@include padding-right(0.4rem);
|
// @include padding-right(0.4rem);
|
||||||
}
|
// }
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
@ -1,69 +1,62 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<form
|
<form
|
||||||
class="new-comment"
|
class=""
|
||||||
v-if="isAbleToComment"
|
v-if="isAbleToComment"
|
||||||
@submit.prevent="createCommentForEvent(newComment)"
|
@submit.prevent="createCommentForEvent(newComment)"
|
||||||
@keyup.ctrl.enter="createCommentForEvent(newComment)"
|
@keyup.ctrl.enter="createCommentForEvent(newComment)"
|
||||||
>
|
>
|
||||||
<b-notification
|
<o-notification
|
||||||
v-if="isEventOrganiser && !areCommentsClosed"
|
v-if="isEventOrganiser && !areCommentsClosed"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
>{{ $t("Comments are closed for everybody else.") }}</b-notification
|
>{{ t("Comments are closed for everybody else.") }}</o-notification
|
||||||
>
|
>
|
||||||
<article class="media">
|
<article class="flex flex-wrap items-start gap-2">
|
||||||
<figure class="media-left" v-if="newComment.actor">
|
<figure class="" v-if="newComment.actor">
|
||||||
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
|
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
|
||||||
</figure>
|
</figure>
|
||||||
<div class="media-content">
|
<div class="flex-1">
|
||||||
<div class="field">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="field">
|
<div class="editor-wrapper">
|
||||||
<p class="control">
|
<Editor
|
||||||
<editor
|
ref="commenteditor"
|
||||||
ref="commenteditor"
|
v-if="currentActor"
|
||||||
mode="comment"
|
:currentActor="currentActor"
|
||||||
v-model="newComment.text"
|
mode="comment"
|
||||||
:aria-label="$t('Comment body')"
|
v-model="newComment.text"
|
||||||
/>
|
:aria-label="t('Comment body')"
|
||||||
</p>
|
/>
|
||||||
<p class="help is-danger" v-if="emptyCommentError">
|
<p class="" v-if="emptyCommentError">
|
||||||
{{ $t("Comment text can't be empty") }}
|
{{ t("Comment text can't be empty") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field notify-participants" v-if="isEventOrganiser">
|
<div class="" v-if="isEventOrganiser">
|
||||||
<b-switch
|
<o-switch
|
||||||
aria-labelledby="notify-participants-toggle"
|
aria-labelledby="notify-participants-toggle"
|
||||||
v-model="newComment.isAnnouncement"
|
v-model="newComment.isAnnouncement"
|
||||||
>{{ $t("Notify participants") }}</b-switch
|
>{{ t("Notify participants") }}</o-switch
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="send-comment">
|
<div class="">
|
||||||
<b-button
|
<o-button native-type="submit" variant="primary" icon-left="send">{{
|
||||||
native-type="submit"
|
t("Send")
|
||||||
type="is-primary"
|
}}</o-button>
|
||||||
class="comment-button-submit"
|
|
||||||
icon-left="send"
|
|
||||||
>{{ $t("Send") }}</b-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</form>
|
</form>
|
||||||
<b-notification v-else-if="isConnected" :closable="false">{{
|
<o-notification v-else-if="isConnected" :closable="false">{{
|
||||||
$t("The organiser has chosen to close comments.")
|
t("The organiser has chosen to close comments.")
|
||||||
}}</b-notification>
|
}}</o-notification>
|
||||||
<p
|
<p v-if="commentsLoading" class="text-center">
|
||||||
v-if="$apollo.queries.comments.loading"
|
{{ t("Loading comments…") }}
|
||||||
class="loading has-text-centered"
|
|
||||||
>
|
|
||||||
{{ $t("Loading comments…") }}
|
|
||||||
</p>
|
</p>
|
||||||
<transition-group tag="div" name="comment-empty-list" v-else>
|
<transition-group tag="div" name="comment-empty-list" v-else>
|
||||||
<transition-group
|
<transition-group
|
||||||
key="list"
|
key="list"
|
||||||
name="comment-list"
|
name="comment-list"
|
||||||
v-if="filteredOrderedComments.length"
|
v-if="filteredOrderedComments.length && currentActor"
|
||||||
class="comment-list"
|
class="comment-list"
|
||||||
tag="ul"
|
tag="ul"
|
||||||
>
|
>
|
||||||
|
@ -71,21 +64,26 @@
|
||||||
class="root-comment"
|
class="root-comment"
|
||||||
:comment="comment"
|
:comment="comment"
|
||||||
:event="event"
|
:event="event"
|
||||||
|
:currentActor="currentActor"
|
||||||
v-for="comment in filteredOrderedComments"
|
v-for="comment in filteredOrderedComments"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
@create-comment="createCommentForEvent"
|
@create-comment="createCommentForEvent"
|
||||||
@delete-comment="deleteComment"
|
@delete-comment="
|
||||||
|
deleteComment({
|
||||||
|
commentId: comment.id as string,
|
||||||
|
originCommentId: comment.originComment?.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
<empty-content v-else icon="comment" key="no-comments" :inline="true">
|
<empty-content v-else icon="comment" key="no-comments" :inline="true">
|
||||||
<span>{{ $t("No comments yet") }}</span>
|
<span>{{ t("No comments yet") }}</span>
|
||||||
</empty-content>
|
</empty-content>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
|
|
||||||
import Comment from "@/components/Comment/Comment.vue";
|
import Comment from "@/components/Comment/Comment.vue";
|
||||||
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
|
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
|
||||||
import { CommentModeration } from "@/types/enums";
|
import { CommentModeration } from "@/types/enums";
|
||||||
|
@ -95,328 +93,338 @@ import {
|
||||||
DELETE_COMMENT,
|
DELETE_COMMENT,
|
||||||
COMMENTS_THREADS_WITH_REPLIES,
|
COMMENTS_THREADS_WITH_REPLIES,
|
||||||
} from "../../graphql/comment";
|
} from "../../graphql/comment";
|
||||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
|
||||||
import { IPerson } from "../../types/actor";
|
|
||||||
import { IEvent } from "../../types/event.model";
|
import { IEvent } from "../../types/event.model";
|
||||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||||
|
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||||
|
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||||
|
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
|
||||||
|
import { IPerson } from "@/types/actor";
|
||||||
|
import { AbsintheGraphQLError } from "@/types/errors.model";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Notifier } from "@/plugins/notifier";
|
||||||
|
|
||||||
@Component({
|
const { currentActor } = useCurrentActorClient();
|
||||||
apollo: {
|
|
||||||
currentActor: CURRENT_ACTOR_CLIENT,
|
const { result: commentsResult, loading: commentsLoading } = useQuery<{
|
||||||
comments: {
|
event: Pick<IEvent, "id" | "uuid" | "comments">;
|
||||||
|
}>(
|
||||||
|
COMMENTS_THREADS_WITH_REPLIES,
|
||||||
|
() => ({ eventUUID: props.event?.uuid }),
|
||||||
|
() => ({ enabled: props.event?.uuid !== undefined })
|
||||||
|
);
|
||||||
|
|
||||||
|
const comments = computed(() => commentsResult.value?.event.comments ?? []);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: IEvent;
|
||||||
|
newComment?: IComment;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
|
||||||
|
|
||||||
|
const newComment = ref<IComment>(props.newComment ?? new CommentModel());
|
||||||
|
|
||||||
|
const emptyCommentError = ref(false);
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
|
watch(currentActor, () => {
|
||||||
|
newComment.value.actor = currentActor.value as IPerson;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(newComment, (newCommentUpdated: IComment) => {
|
||||||
|
if (emptyCommentError.value) {
|
||||||
|
emptyCommentError.value = ["", "<p></p>"].includes(newCommentUpdated.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: createCommentForEventMutation,
|
||||||
|
onDone: createCommentForEventMutationDone,
|
||||||
|
onError: createCommentForEventMutationError,
|
||||||
|
} = useMutation<
|
||||||
|
{ createComment: IComment },
|
||||||
|
{
|
||||||
|
eventId: string;
|
||||||
|
text: string;
|
||||||
|
inReplyToCommentId?: string;
|
||||||
|
isAnnouncement?: boolean;
|
||||||
|
originCommentId?: string | undefined;
|
||||||
|
}
|
||||||
|
>(CREATE_COMMENT_FROM_EVENT, () => ({
|
||||||
|
update: (
|
||||||
|
store: ApolloCache<InMemoryCache>,
|
||||||
|
{ data }: FetchResult,
|
||||||
|
{ variables }
|
||||||
|
) => {
|
||||||
|
if (data == null) return;
|
||||||
|
// comments are attached to the event, so we can pass it to replies later
|
||||||
|
const newCommentLocal = { ...data.createComment, event: props.event };
|
||||||
|
|
||||||
|
// we load all existing threads
|
||||||
|
const commentThreadsData = store.readQuery<{ event: IEvent }>({
|
||||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||||
variables() {
|
variables: {
|
||||||
return {
|
eventUUID: props.event?.uuid,
|
||||||
eventUUID: this.event.uuid,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
update: (data) => data.event.comments,
|
});
|
||||||
skip() {
|
if (!commentThreadsData) return;
|
||||||
return !this.event.uuid;
|
const { event } = commentThreadsData;
|
||||||
|
const oldComments = [...event.comments];
|
||||||
|
|
||||||
|
// if it's no a root comment, we first need to find
|
||||||
|
// existing replies and add the new reply to it
|
||||||
|
if (variables?.originCommentId !== undefined) {
|
||||||
|
const parentCommentIndex = oldComments.findIndex(
|
||||||
|
(oldComment) => oldComment.id === variables.originCommentId
|
||||||
|
);
|
||||||
|
const parentComment = oldComments[parentCommentIndex];
|
||||||
|
|
||||||
|
// replace the root comment with has the updated list of replies in the thread list
|
||||||
|
oldComments.splice(parentCommentIndex, 1, {
|
||||||
|
...parentComment,
|
||||||
|
replies: [...parentComment.replies, newCommentLocal],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// otherwise it's simply a new thread and we add it to the list
|
||||||
|
oldComments.push(newCommentLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// finally we save the thread list
|
||||||
|
store.writeQuery({
|
||||||
|
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
comments: oldComments,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
variables: {
|
||||||
|
eventUUID: props.event?.uuid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
createCommentForEventMutationDone(() => {
|
||||||
|
// and reset the new comment field
|
||||||
|
newComment.value = new CommentModel();
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifier = inject<Notifier>("notifier");
|
||||||
|
|
||||||
|
createCommentForEventMutationError((errors) => {
|
||||||
|
console.error(errors);
|
||||||
|
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
|
||||||
|
const error = errors.graphQLErrors[0] as AbsintheGraphQLError;
|
||||||
|
if (error.field !== "text" && error.message[0] !== "can't be blank") {
|
||||||
|
notifier?.error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCommentForEvent = (comment: IComment) => {
|
||||||
|
emptyCommentError.value = ["", "<p></p>"].includes(comment.text);
|
||||||
|
|
||||||
|
if (emptyCommentError.value) return;
|
||||||
|
if (!comment.actor) return;
|
||||||
|
if (!props.event?.id) return;
|
||||||
|
|
||||||
|
createCommentForEventMutation({
|
||||||
|
eventId: props.event?.id,
|
||||||
|
text: comment.text,
|
||||||
|
inReplyToCommentId: comment.inReplyToComment?.id,
|
||||||
|
isAnnouncement: comment.isAnnouncement,
|
||||||
|
originCommentId: comment.originComment?.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: deleteComment, onError: deleteCommentMutationError } =
|
||||||
|
useMutation<
|
||||||
|
{ deleteComment: { id: string } },
|
||||||
|
{ commentId: string; originCommentId?: string }
|
||||||
|
>(DELETE_COMMENT, () => ({
|
||||||
|
update: (
|
||||||
|
store: ApolloCache<InMemoryCache>,
|
||||||
|
{ data }: FetchResult,
|
||||||
|
{ variables }
|
||||||
|
) => {
|
||||||
|
if (data == null) return;
|
||||||
|
const deletedCommentId = data.deleteComment.id;
|
||||||
|
|
||||||
|
const commentsData = store.readQuery<{ event: IEvent }>({
|
||||||
|
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||||
|
variables: {
|
||||||
|
eventUUID: props.event?.uuid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!commentsData) return;
|
||||||
|
const { event } = commentsData;
|
||||||
|
let updatedComments: IComment[] = [...event.comments];
|
||||||
|
|
||||||
|
if (variables?.originCommentId) {
|
||||||
|
// we have deleted a reply to a thread
|
||||||
|
const parentCommentIndex = updatedComments.findIndex(
|
||||||
|
(oldComment) => oldComment.id === variables.originCommentId
|
||||||
|
);
|
||||||
|
const parentComment = updatedComments[parentCommentIndex];
|
||||||
|
const updatedReplies = parentComment.replies.map((reply) => {
|
||||||
|
if (reply.id === deletedCommentId) {
|
||||||
|
return {
|
||||||
|
...reply,
|
||||||
|
deletedAt: new Date().toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
updatedComments.splice(parentCommentIndex, 1, {
|
||||||
|
...parentComment,
|
||||||
|
replies: updatedReplies,
|
||||||
|
totalReplies: parentComment.totalReplies - 1,
|
||||||
|
});
|
||||||
|
console.log("updatedComments", updatedComments);
|
||||||
|
} else {
|
||||||
|
// we have deleted a thread itself
|
||||||
|
updatedComments = updatedComments.map((reply) => {
|
||||||
|
if (reply.id === deletedCommentId) {
|
||||||
|
return {
|
||||||
|
...reply,
|
||||||
|
deletedAt: new Date().toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
store.writeQuery({
|
||||||
|
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||||
|
variables: {
|
||||||
|
eventUUID: props.event?.uuid,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
comments: updatedComments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
}));
|
||||||
components: {
|
|
||||||
Comment,
|
|
||||||
IdentityPickerWrapper,
|
|
||||||
EmptyContent,
|
|
||||||
editor: () =>
|
|
||||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class CommentTree extends Vue {
|
|
||||||
@Prop({ required: false, type: Object }) event!: IEvent;
|
|
||||||
|
|
||||||
newComment: IComment = new CommentModel();
|
deleteCommentMutationError((error) => {
|
||||||
|
console.error(error);
|
||||||
currentActor!: IPerson;
|
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||||
|
notifier?.error(error.graphQLErrors[0].message);
|
||||||
comments: IComment[] = [];
|
|
||||||
|
|
||||||
CommentModeration = CommentModeration;
|
|
||||||
|
|
||||||
emptyCommentError = false;
|
|
||||||
|
|
||||||
@Watch("currentActor")
|
|
||||||
watchCurrentActor(currentActor: IPerson): void {
|
|
||||||
this.newComment.actor = currentActor;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@Watch("newComment", { deep: true })
|
const orderedComments = computed((): IComment[] => {
|
||||||
resetEmptyCommentError(newComment: IComment): void {
|
return comments.value
|
||||||
if (this.emptyCommentError) {
|
.filter((comment: IComment) => comment.inReplyToComment == null)
|
||||||
this.emptyCommentError = ["", "<p></p>"].includes(newComment.text);
|
.sort((a: IComment, b: IComment) => {
|
||||||
}
|
if (a.isAnnouncement !== b.isAnnouncement) {
|
||||||
}
|
return (
|
||||||
|
(b.isAnnouncement === true ? 1 : 0) -
|
||||||
async createCommentForEvent(comment: IComment): Promise<void> {
|
(a.isAnnouncement === true ? 1 : 0)
|
||||||
this.emptyCommentError = ["", "<p></p>"].includes(comment.text);
|
);
|
||||||
if (this.emptyCommentError) return;
|
|
||||||
try {
|
|
||||||
if (!comment.actor) return;
|
|
||||||
await this.$apollo.mutate({
|
|
||||||
mutation: CREATE_COMMENT_FROM_EVENT,
|
|
||||||
variables: {
|
|
||||||
eventId: this.event.id,
|
|
||||||
text: comment.text,
|
|
||||||
inReplyToCommentId: comment.inReplyToComment
|
|
||||||
? comment.inReplyToComment.id
|
|
||||||
: null,
|
|
||||||
isAnnouncement: comment.isAnnouncement,
|
|
||||||
},
|
|
||||||
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
|
|
||||||
if (data == null) return;
|
|
||||||
// comments are attached to the event, so we can pass it to replies later
|
|
||||||
const newComment = { ...data.createComment, event: this.event };
|
|
||||||
|
|
||||||
// we load all existing threads
|
|
||||||
const commentThreadsData = store.readQuery<{ event: IEvent }>({
|
|
||||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
|
||||||
variables: {
|
|
||||||
eventUUID: this.event.uuid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!commentThreadsData) return;
|
|
||||||
const { event } = commentThreadsData;
|
|
||||||
const oldComments = [...event.comments];
|
|
||||||
|
|
||||||
// if it's no a root comment, we first need to find
|
|
||||||
// existing replies and add the new reply to it
|
|
||||||
if (comment.originComment !== undefined) {
|
|
||||||
const { originComment } = comment;
|
|
||||||
const parentCommentIndex = oldComments.findIndex(
|
|
||||||
(oldComment) => oldComment.id === originComment.id
|
|
||||||
);
|
|
||||||
const parentComment = oldComments[parentCommentIndex];
|
|
||||||
|
|
||||||
// replace the root comment with has the updated list of replies in the thread list
|
|
||||||
oldComments.splice(parentCommentIndex, 1, {
|
|
||||||
...parentComment,
|
|
||||||
replies: [...parentComment.replies, newComment],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// otherwise it's simply a new thread and we add it to the list
|
|
||||||
oldComments.push(newComment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// finally we save the thread list
|
|
||||||
store.writeQuery({
|
|
||||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
|
||||||
data: {
|
|
||||||
event: {
|
|
||||||
...event,
|
|
||||||
comments: oldComments,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variables: {
|
|
||||||
eventUUID: this.event.uuid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// and reset the new comment field
|
|
||||||
this.newComment = new CommentModel();
|
|
||||||
} catch (errors: any) {
|
|
||||||
console.error(errors);
|
|
||||||
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
|
|
||||||
const error = errors.graphQLErrors[0];
|
|
||||||
if (error.field !== "text" && error.message[0] !== "can't be blank") {
|
|
||||||
this.$notifier.error(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (a.publishedAt && b.publishedAt) {
|
||||||
}
|
return (
|
||||||
|
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||||
async deleteComment(comment: IComment): Promise<void> {
|
);
|
||||||
try {
|
} else if (a.updatedAt && b.updatedAt) {
|
||||||
await this.$apollo.mutate({
|
return (
|
||||||
mutation: DELETE_COMMENT,
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
variables: {
|
);
|
||||||
commentId: comment.id,
|
|
||||||
},
|
|
||||||
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
|
|
||||||
if (data == null) return;
|
|
||||||
const deletedCommentId = data.deleteComment.id;
|
|
||||||
|
|
||||||
const commentsData = store.readQuery<{ event: IEvent }>({
|
|
||||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
|
||||||
variables: {
|
|
||||||
eventUUID: this.event.uuid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!commentsData) return;
|
|
||||||
const { event } = commentsData;
|
|
||||||
let updatedComments: IComment[] = [...event.comments];
|
|
||||||
|
|
||||||
if (comment.originComment) {
|
|
||||||
// we have deleted a reply to a thread
|
|
||||||
const { originComment } = comment;
|
|
||||||
|
|
||||||
const parentCommentIndex = updatedComments.findIndex(
|
|
||||||
(oldComment) => oldComment.id === originComment.id
|
|
||||||
);
|
|
||||||
const parentComment = updatedComments[parentCommentIndex];
|
|
||||||
const updatedReplies = parentComment.replies.map((reply) => {
|
|
||||||
if (reply.id === deletedCommentId) {
|
|
||||||
return {
|
|
||||||
...reply,
|
|
||||||
deletedAt: new Date().toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
});
|
|
||||||
updatedComments.splice(parentCommentIndex, 1, {
|
|
||||||
...parentComment,
|
|
||||||
replies: updatedReplies,
|
|
||||||
totalReplies: parentComment.totalReplies - 1,
|
|
||||||
});
|
|
||||||
console.log("updatedComments", updatedComments);
|
|
||||||
} else {
|
|
||||||
// we have deleted a thread itself
|
|
||||||
updatedComments = updatedComments.map((reply) => {
|
|
||||||
if (reply.id === deletedCommentId) {
|
|
||||||
return {
|
|
||||||
...reply,
|
|
||||||
deletedAt: new Date().toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
store.writeQuery({
|
|
||||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
|
||||||
variables: {
|
|
||||||
eventUUID: this.event.uuid,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
event: {
|
|
||||||
...event,
|
|
||||||
comments: updatedComments,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
|
||||||
this.$notifier.error(error.graphQLErrors[0].message);
|
|
||||||
}
|
}
|
||||||
}
|
return 0;
|
||||||
}
|
});
|
||||||
|
});
|
||||||
|
|
||||||
get orderedComments(): IComment[] {
|
const filteredOrderedComments = computed((): IComment[] => {
|
||||||
return this.comments
|
return orderedComments.value.filter(
|
||||||
.filter((comment) => comment.inReplyToComment == null)
|
(comment) => !comment.deletedAt || comment.totalReplies > 0
|
||||||
.sort((a, b) => {
|
);
|
||||||
if (a.isAnnouncement !== b.isAnnouncement) {
|
});
|
||||||
return (
|
|
||||||
(b.isAnnouncement === true ? 1 : 0) -
|
|
||||||
(a.isAnnouncement === true ? 1 : 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (a.publishedAt && b.publishedAt) {
|
|
||||||
return (
|
|
||||||
new Date(b.publishedAt).getTime() -
|
|
||||||
new Date(a.publishedAt).getTime()
|
|
||||||
);
|
|
||||||
} else if (a.updatedAt && b.updatedAt) {
|
|
||||||
return (
|
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredOrderedComments(): IComment[] {
|
const isEventOrganiser = computed((): boolean => {
|
||||||
return this.orderedComments.filter(
|
const organizerId =
|
||||||
(comment) => !comment.deletedAt || comment.totalReplies > 0
|
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
|
||||||
);
|
return organizerId !== undefined && currentActor.value?.id === organizerId;
|
||||||
}
|
});
|
||||||
|
|
||||||
get isEventOrganiser(): boolean {
|
const areCommentsClosed = computed((): boolean => {
|
||||||
const organizerId =
|
return (
|
||||||
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
|
currentActor.value?.id !== undefined &&
|
||||||
return organizerId !== undefined && this.currentActor?.id === organizerId;
|
props.event?.options.commentModeration !== CommentModeration.CLOSED
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
get areCommentsClosed(): boolean {
|
const isAbleToComment = computed((): boolean => {
|
||||||
return (
|
if (isConnected.value) {
|
||||||
this.currentActor.id !== undefined &&
|
return areCommentsClosed.value || isEventOrganiser.value;
|
||||||
this.event.options.commentModeration !== CommentModeration.CLOSED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
get isAbleToComment(): boolean {
|
const isConnected = computed((): boolean => {
|
||||||
if (this.isConnected) {
|
return currentActor.value?.id != undefined;
|
||||||
return this.areCommentsClosed || this.isEventOrganiser;
|
});
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isConnected(): boolean {
|
|
||||||
return this.currentActor?.id != undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/_mixins" as *;
|
// @use "@/styles/_mixins" as *;
|
||||||
@import "~bulma/sass/utilities/mixins.sass";
|
// // @import "node_modules/bulma/sass/utilities/mixins.sass";
|
||||||
form.new-comment {
|
// form.new-comment {
|
||||||
padding-bottom: 1rem;
|
// padding-bottom: 1rem;
|
||||||
|
|
||||||
.media {
|
// .media {
|
||||||
flex-wrap: wrap;
|
// flex-wrap: wrap;
|
||||||
justify-content: center;
|
// justify-content: center;
|
||||||
.media-left {
|
// // .media-left {
|
||||||
@include mobile {
|
// // @include >mobile {
|
||||||
@include margin-right(0.5rem);
|
// // @include margin-right(0.5rem);
|
||||||
@include margin-left(0.5rem);
|
// // @include margin-left(0.5rem);
|
||||||
}
|
// // }
|
||||||
}
|
// // }
|
||||||
|
|
||||||
.media-content {
|
// .media-content {
|
||||||
display: flex;
|
// display: flex;
|
||||||
align-items: center;
|
// align-items: center;
|
||||||
align-content: center;
|
// align-content: center;
|
||||||
width: min-content;
|
// width: min-content;
|
||||||
|
|
||||||
.field {
|
// .field {
|
||||||
flex: 1;
|
// flex: 1;
|
||||||
@include padding-right(10px);
|
// // @include padding-right(10px);
|
||||||
margin-bottom: 0;
|
// margin-bottom: 0;
|
||||||
|
|
||||||
&.notify-participants {
|
// &.notify-participants {
|
||||||
margin-top: 0.5rem;
|
// margin-top: 0.5rem;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
.no-comments {
|
// .no-comments {
|
||||||
display: flex;
|
// display: flex;
|
||||||
flex-direction: column;
|
// flex-direction: column;
|
||||||
|
|
||||||
span {
|
// span {
|
||||||
text-align: center;
|
// text-align: center;
|
||||||
margin-bottom: 10px;
|
// margin-bottom: 10px;
|
||||||
}
|
// }
|
||||||
|
|
||||||
img {
|
// img {
|
||||||
max-width: 250px;
|
// max-width: 250px;
|
||||||
align-self: center;
|
// align-self: center;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
ul.comment-list li {
|
// ul.comment-list li {
|
||||||
margin-bottom: 16px;
|
// margin-bottom: 16px;
|
||||||
}
|
// }
|
||||||
|
|
||||||
.comment-list-enter-active,
|
.comment-list-enter-active,
|
||||||
.comment-list-leave-active,
|
.comment-list-leave-active,
|
||||||
|
@ -447,11 +455,11 @@ ul.comment-list li {
|
||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.comment-empty-list-enter-active {*/
|
// .comment-empty-list-enter-active {
|
||||||
/* transition: opacity .5s;*/
|
// transition: opacity .5s;
|
||||||
/*}*/
|
// }
|
||||||
|
|
||||||
/*.comment-empty-list-enter {*/
|
// .comment-empty-list-enter {
|
||||||
/* opacity: 0;*/
|
// opacity: 0;
|
||||||
/*}*/
|
// }
|
||||||
</style>
|
</style>
|
||||||
|
|
49
js/src/components/Discussion/DiscussionComment.story.vue
Normal 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>
|
|
@ -1,23 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<article class="comment">
|
<article class="flex gap-1">
|
||||||
<div class="avatar">
|
<div class="">
|
||||||
<figure
|
<figure class="" v-if="comment.actor && comment.actor.avatar">
|
||||||
class="image is-48x48"
|
<img
|
||||||
v-if="comment.actor && comment.actor.avatar"
|
class="rounded-xl"
|
||||||
>
|
:src="comment.actor.avatar.url"
|
||||||
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
|
alt=""
|
||||||
|
:width="48"
|
||||||
|
:height="48"
|
||||||
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<b-icon v-else size="is-large" icon="account-circle" />
|
<AccountCircle :size="48" v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="mb-2 pt-1 flex-1">
|
||||||
<div class="meta" dir="auto">
|
<div class="flex items-center gap-1" dir="auto">
|
||||||
<span
|
<div
|
||||||
class="first-line name"
|
class="flex flex-1 flex-col"
|
||||||
v-if="comment.actor && !comment.deletedAt"
|
v-if="comment.actor && !comment.deletedAt"
|
||||||
>
|
>
|
||||||
<strong>{{ comment.actor.name }}</strong>
|
<strong v-if="comment.actor.name">{{ comment.actor.name }}</strong>
|
||||||
<small>@{{ usernameWithDomain(comment.actor) }}</small>
|
<small>@{{ usernameWithDomain(comment.actor) }}</small>
|
||||||
</span>
|
</div>
|
||||||
<span v-else class="name comment-link has-text-grey">
|
<span v-else class="name comment-link has-text-grey">
|
||||||
{{ $t("[deleted]") }}
|
{{ $t("[deleted]") }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -26,39 +29,44 @@
|
||||||
v-if="
|
v-if="
|
||||||
comment.actor &&
|
comment.actor &&
|
||||||
!comment.deletedAt &&
|
!comment.deletedAt &&
|
||||||
comment.actor.id === currentActor.id
|
comment.actor.id === currentActor?.id
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<b-dropdown aria-role="list">
|
<o-dropdown aria-role="list">
|
||||||
<b-icon slot="trigger" role="button" icon="dots-horizontal" />
|
<template #trigger>
|
||||||
|
<o-icon role="button" icon="dots-horizontal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<b-dropdown-item
|
<o-dropdown-item
|
||||||
v-if="comment.actor.id === currentActor.id"
|
v-if="comment.actor?.id === currentActor?.id"
|
||||||
@click="toggleEditMode"
|
@click="toggleEditMode"
|
||||||
aria-role="menuitem"
|
aria-role="menuitem"
|
||||||
>
|
>
|
||||||
<b-icon icon="pencil"></b-icon>
|
<o-icon icon="pencil"></o-icon>
|
||||||
{{ $t("Edit") }}
|
{{ $t("Edit") }}
|
||||||
</b-dropdown-item>
|
</o-dropdown-item>
|
||||||
<b-dropdown-item
|
<o-dropdown-item
|
||||||
v-if="comment.actor.id === currentActor.id"
|
v-if="comment.actor?.id === currentActor?.id"
|
||||||
@click="$emit('delete-comment', comment)"
|
@click="emit('deleteComment', comment)"
|
||||||
aria-role="menuitem"
|
aria-role="menuitem"
|
||||||
>
|
>
|
||||||
<b-icon icon="delete"></b-icon>
|
<o-icon icon="delete"></o-icon>
|
||||||
{{ $t("Delete") }}
|
{{ $t("Delete") }}
|
||||||
</b-dropdown-item>
|
</o-dropdown-item>
|
||||||
<!-- <b-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
|
<!-- <o-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
|
||||||
<b-icon icon="flag" />
|
<o-icon icon="flag" />
|
||||||
{{ $t("Report") }}
|
{{ $t("Report") }}
|
||||||
</b-dropdown-item> -->
|
</o-dropdown-item> -->
|
||||||
</b-dropdown>
|
</o-dropdown>
|
||||||
</span>
|
</span>
|
||||||
<div class="post-infos">
|
<div class="self-center">
|
||||||
<span :title="comment.insertedAt | formatDateTimeString">
|
<span
|
||||||
|
:title="formatDateTimeString(comment.updatedAt?.toString())"
|
||||||
|
v-if="comment.updatedAt"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
formatDistanceToNow(new Date(comment.updatedAt), {
|
formatDistanceToNow(new Date(comment.updatedAt?.toString()), {
|
||||||
locale: $dateFnsLocale,
|
locale: dateFnsLocale,
|
||||||
}) || $t("Right now")
|
}) || $t("Right now")
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
|
@ -69,20 +77,24 @@
|
||||||
class="text-wrapper"
|
class="text-wrapper"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>
|
>
|
||||||
<div class="description-content" v-html="comment.text"></div>
|
<div
|
||||||
|
class="prose md:prose-lg lg:prose-xl dark:prose-invert"
|
||||||
|
v-html="comment.text"
|
||||||
|
></div>
|
||||||
<p
|
<p
|
||||||
|
class="text-sm"
|
||||||
v-if="
|
v-if="
|
||||||
comment.insertedAt &&
|
comment.insertedAt &&
|
||||||
comment.updatedAt &&
|
comment.updatedAt &&
|
||||||
new Date(comment.insertedAt).getTime() !==
|
new Date(comment.insertedAt).getTime() !==
|
||||||
new Date(comment.updatedAt).getTime()
|
new Date(comment.updatedAt).getTime()
|
||||||
"
|
"
|
||||||
:title="comment.updatedAt | formatDateTimeString"
|
:title="formatDateTimeString(comment.updatedAt.toString())"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$t("Edited {ago}", {
|
$t("Edited {ago}", {
|
||||||
ago: formatDistanceToNow(new Date(comment.updatedAt), {
|
ago: formatDistanceToNow(new Date(comment.updatedAt), {
|
||||||
locale: $dateFnsLocale,
|
locale: dateFnsLocale,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
@ -92,66 +104,66 @@
|
||||||
{{ $t("[This comment has been deleted by it's author]") }}
|
{{ $t("[This comment has been deleted by it's author]") }}
|
||||||
</div>
|
</div>
|
||||||
<form v-else class="edition" @submit.prevent="updateComment">
|
<form v-else class="edition" @submit.prevent="updateComment">
|
||||||
<editor v-model="updatedComment" :aria-label="$t('Comment body')" />
|
<Editor
|
||||||
<div class="buttons">
|
v-model="updatedComment"
|
||||||
<b-button
|
:aria-label="$t('Comment body')"
|
||||||
|
:current-actor="currentActor"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<o-button
|
||||||
native-type="submit"
|
native-type="submit"
|
||||||
:disabled="['<p></p>', '', comment.text].includes(updatedComment)"
|
:disabled="['<p></p>', '', comment.text].includes(updatedComment)"
|
||||||
type="is-primary"
|
variant="primary"
|
||||||
>{{ $t("Update") }}</b-button
|
>{{ $t("Update") }}</o-button
|
||||||
>
|
>
|
||||||
<b-button native-type="button" @click="toggleEditMode">{{
|
<o-button native-type="button" @click="toggleEditMode">{{
|
||||||
$t("Cancel")
|
$t("Cancel")
|
||||||
}}</b-button>
|
}}</o-button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { IComment } from "../../types/comment.model";
|
import { IComment } from "../../types/comment.model";
|
||||||
import { usernameWithDomain, IPerson } from "../../types/actor";
|
import { IPerson, usernameWithDomain } from "../../types/actor";
|
||||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
import { computed, defineAsyncComponent, inject, ref } from "vue";
|
||||||
|
import { formatDateTimeString } from "@/filters/datetime";
|
||||||
|
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||||
|
import type { Locale } from "date-fns";
|
||||||
|
|
||||||
@Component({
|
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
|
||||||
apollo: {
|
|
||||||
currentActor: CURRENT_ACTOR_CLIENT,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
editor: () =>
|
|
||||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class DiscussionComment extends Vue {
|
|
||||||
@Prop({ required: true, type: Object }) comment!: IComment;
|
|
||||||
|
|
||||||
editMode = false;
|
const props = defineProps<{
|
||||||
|
modelValue: IComment;
|
||||||
|
currentActor: IPerson;
|
||||||
|
}>();
|
||||||
|
|
||||||
updatedComment = "";
|
const emit = defineEmits(["update:modelValue", "deleteComment"]);
|
||||||
|
|
||||||
currentActor!: IPerson;
|
const comment = computed(() => props.modelValue);
|
||||||
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
const editMode = ref(false);
|
||||||
|
|
||||||
formatDistanceToNow = formatDistanceToNow;
|
const updatedComment = ref("");
|
||||||
|
|
||||||
// isReportModalActive: boolean = false;
|
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||||
|
|
||||||
toggleEditMode(): void {
|
// isReportModalActive: boolean = false;
|
||||||
this.updatedComment = this.comment.text;
|
|
||||||
this.editMode = !this.editMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComment(): void {
|
const toggleEditMode = (): void => {
|
||||||
this.$emit("update-comment", {
|
updatedComment.value = comment.value.text;
|
||||||
...this.comment,
|
editMode.value = !editMode.value;
|
||||||
text: this.updatedComment,
|
};
|
||||||
});
|
|
||||||
this.toggleEditMode();
|
const updateComment = (): void => {
|
||||||
}
|
emit("update:modelValue", {
|
||||||
}
|
...comment.value,
|
||||||
|
text: updatedComment.value,
|
||||||
|
});
|
||||||
|
toggleEditMode();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/_mixins" as *;
|
@use "@/styles/_mixins" as *;
|
||||||
|
@ -170,7 +182,7 @@ article.comment {
|
||||||
padding: 0 1rem 0.3em;
|
padding: 0 1rem 0.3em;
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@include margin-right(auto);
|
// @include margin-right(auto);
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@ -200,33 +212,33 @@ article.comment {
|
||||||
div.description-content {
|
div.description-content {
|
||||||
padding-bottom: 0.3rem;
|
padding-bottom: 0.3rem;
|
||||||
|
|
||||||
::v-deep h1 {
|
:deep(h1) {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep h2 {
|
:deep(h2) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep h3 {
|
:deep(h3) {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep ul {
|
:deep(ul) {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep li {
|
:deep(li) {
|
||||||
margin: 10px auto 10px 2rem;
|
margin: 10px auto 10px 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep blockquote {
|
:deep(blockquote) {
|
||||||
border-left: 0.2em solid #333;
|
border-left: 0.2em solid #333;
|
||||||
display: block;
|
display: block;
|
||||||
@include padding-left(1em);
|
// @include padding-left(1em);
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep p {
|
:deep(p) {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
33
js/src/components/Discussion/DiscussionListItem.story.vue
Normal 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>
|
|
@ -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>
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="editor">
|
<div v-if="editor !== null">
|
||||||
<div
|
<div
|
||||||
class="editor"
|
class="editor"
|
||||||
:class="{ short_mode: isShortMode, comment_mode: isCommentMode }"
|
:class="{ short_mode: isShortMode, comment_mode: isCommentMode }"
|
||||||
|
@ -14,64 +14,64 @@
|
||||||
<button
|
<button
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('bold') }"
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor?.chain().focus().toggleBold().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Bold')"
|
:title="$t('Bold')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-bold" />
|
<o-icon icon="format-bold" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('italic') }"
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
@click="editor?.chain().focus().toggleItalic().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Italic')"
|
:title="$t('Italic')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-italic" />
|
<o-icon icon="format-italic" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('underline') }"
|
:class="{ 'is-active': editor.isActive('underline') }"
|
||||||
@click="editor.chain().focus().toggleUnderline().run()"
|
@click="editor?.chain().focus().toggleUnderline().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Underline')"
|
:title="$t('Underline')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-underline" />
|
<o-icon icon="format-underline" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
@click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Heading Level 1')"
|
:title="$t('Heading Level 1')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-1" />
|
<o-icon icon="format-header-1" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
@click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Heading Level 2')"
|
:title="$t('Heading Level 2')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-2" />
|
<o-icon icon="format-header-2" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
@click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Heading Level 3')"
|
:title="$t('Heading Level 3')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-3" />
|
<o-icon icon="format-header-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@ -81,17 +81,17 @@
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Add link')"
|
:title="$t('Add link')"
|
||||||
>
|
>
|
||||||
<b-icon icon="link" />
|
<o-icon icon="link" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="editor.isActive('link')"
|
v-if="editor.isActive('link')"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().unsetLink().run()"
|
@click="editor?.chain().focus().unsetLink().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Remove link')"
|
:title="$t('Remove link')"
|
||||||
>
|
>
|
||||||
<b-icon icon="link-off" />
|
<o-icon icon="link-off" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@ -101,60 +101,60 @@
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Add picture')"
|
:title="$t('Add picture')"
|
||||||
>
|
>
|
||||||
<b-icon icon="image" />
|
<o-icon icon="image" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||||
@click="editor.chain().focus().toggleBulletList().run()"
|
@click="editor?.chain().focus().toggleBulletList().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Bullet list')"
|
:title="$t('Bullet list')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-list-bulleted" />
|
<o-icon icon="format-list-bulleted" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
@click="editor?.chain().focus().toggleOrderedList().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Ordered list')"
|
:title="$t('Ordered list')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-list-numbered" />
|
<o-icon icon="format-list-numbered" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
@click="editor?.chain().focus().toggleBlockquote().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Quote')"
|
:title="$t('Quote')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-quote-close" />
|
<o-icon icon="format-quote-close" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().undo().run()"
|
@click="editor?.chain().focus().undo().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Undo')"
|
:title="$t('Undo')"
|
||||||
>
|
>
|
||||||
<b-icon icon="undo" />
|
<o-icon icon="undo" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().redo().run()"
|
@click="editor?.chain().focus().redo().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Redo')"
|
:title="$t('Redo')"
|
||||||
>
|
>
|
||||||
<b-icon icon="redo" />
|
<o-icon icon="redo" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -167,34 +167,33 @@
|
||||||
<button
|
<button
|
||||||
class="menububble__button"
|
class="menububble__button"
|
||||||
:class="{ 'is-active': editor.isActive('bold') }"
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor?.chain().focus().toggleBold().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Bold')"
|
:title="$t('Bold')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-bold" />
|
<o-icon icon="format-bold" />
|
||||||
<span class="visually-hidden">{{ $t("Bold") }}</span>
|
<span class="visually-hidden">{{ $t("Bold") }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="menububble__button"
|
class="menububble__button"
|
||||||
:class="{ 'is-active': editor.isActive('italic') }"
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
@click="editor?.chain().focus().toggleItalic().run()"
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('Italic')"
|
:title="$t('Italic')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-italic" />
|
<o-icon icon="format-italic" />
|
||||||
<span class="visually-hidden">{{ $t("Italic") }}</span>
|
<span class="visually-hidden">{{ $t("Italic") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</bubble-menu>
|
</bubble-menu>
|
||||||
|
|
||||||
<editor-content class="editor__content" :editor="editor" />
|
<editor-content class="editor__content" :editor="editor" v-if="editor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
|
||||||
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-2";
|
|
||||||
import Blockquote from "@tiptap/extension-blockquote";
|
import Blockquote from "@tiptap/extension-blockquote";
|
||||||
import BulletList from "@tiptap/extension-bullet-list";
|
import BulletList from "@tiptap/extension-bullet-list";
|
||||||
import Heading from "@tiptap/extension-heading";
|
import Heading from "@tiptap/extension-heading";
|
||||||
|
@ -211,7 +210,6 @@ import { IActor, IPerson, usernameWithDomain } from "../types/actor";
|
||||||
import CustomImage from "./Editor/Image";
|
import CustomImage from "./Editor/Image";
|
||||||
import { UPLOAD_MEDIA } from "../graphql/upload";
|
import { UPLOAD_MEDIA } from "../graphql/upload";
|
||||||
import { listenFileUpload } from "../utils/upload";
|
import { listenFileUpload } from "../utils/upload";
|
||||||
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
|
|
||||||
import Mention from "@tiptap/extension-mention";
|
import Mention from "@tiptap/extension-mention";
|
||||||
import MentionOptions from "./Editor/Mention";
|
import MentionOptions from "./Editor/Mention";
|
||||||
import OrderedList from "@tiptap/extension-ordered-list";
|
import OrderedList from "@tiptap/extension-ordered-list";
|
||||||
|
@ -219,190 +217,204 @@ import ListItem from "@tiptap/extension-list-item";
|
||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import { AutoDir } from "./Editor/Autodir";
|
import { AutoDir } from "./Editor/Autodir";
|
||||||
import sanitizeHtml from "sanitize-html";
|
// import sanitizeHtml from "sanitize-html";
|
||||||
|
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { Dialog } from "@/plugins/dialog";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useMutation } from "@vue/apollo-composable";
|
||||||
|
import { Notifier } from "@/plugins/notifier";
|
||||||
|
|
||||||
@Component({
|
const props = withDefaults(
|
||||||
components: { EditorContent, BubbleMenu },
|
defineProps<{
|
||||||
apollo: {
|
modelValue: string;
|
||||||
currentActor: {
|
mode?: string;
|
||||||
query: CURRENT_ACTOR_CLIENT,
|
maxSize?: number;
|
||||||
|
ariaLabel?: string;
|
||||||
|
currentActor: IPerson;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: "description",
|
||||||
|
maxSize: 100_000_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const editor = ref<Editor | null>(null);
|
||||||
|
|
||||||
|
const isDescriptionMode = computed((): boolean => {
|
||||||
|
return props.mode === "description" || isBasicMode.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCommentMode = computed((): boolean => {
|
||||||
|
return props.mode === "comment";
|
||||||
|
});
|
||||||
|
|
||||||
|
const isShortMode = computed((): boolean => {
|
||||||
|
return isBasicMode.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBasicMode = computed((): boolean => {
|
||||||
|
return props.mode === "basic";
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertMention = (obj: { range: any; attrs: any }) => {
|
||||||
|
console.log("initialize Mention");
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = ref<MutationObserver | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
editor.value = new Editor({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
"aria-multiline": isShortMode.value.toString(),
|
||||||
|
"aria-label": props.ariaLabel ?? "",
|
||||||
|
role: "textbox",
|
||||||
|
},
|
||||||
|
transformPastedHTML: transformPastedHTML,
|
||||||
},
|
},
|
||||||
},
|
extensions: [
|
||||||
})
|
Blockquote,
|
||||||
export default class EditorComponent extends Vue {
|
BulletList,
|
||||||
@Prop({ required: true }) value!: string;
|
Heading,
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
OrderedList,
|
||||||
|
ListItem,
|
||||||
|
Mention.configure(MentionOptions),
|
||||||
|
CustomImage,
|
||||||
|
AutoDir,
|
||||||
|
Underline,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strike,
|
||||||
|
Dropcursor,
|
||||||
|
Gapcursor,
|
||||||
|
History,
|
||||||
|
Link.configure({
|
||||||
|
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
injectCSS: false,
|
||||||
|
content: props.modelValue,
|
||||||
|
onUpdate: () => {
|
||||||
|
emit("update:modelValue", editor.value?.getHTML());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@Prop({ required: false, default: "description" }) mode!: string;
|
const transformPastedHTML = (html: string): string => {
|
||||||
|
// When using comment mode, limit to acceptable tags
|
||||||
@Prop({ required: false, default: 100_000_000 }) maxSize!: number;
|
if (isCommentMode.value) {
|
||||||
|
// return sanitizeHtml(html, {
|
||||||
@Prop({ required: false }) ariaLabel!: string;
|
// allowedTags: ["b", "i", "em", "strong", "a"],
|
||||||
|
// allowedAttributes: {
|
||||||
currentActor!: IPerson;
|
// a: ["href", "rel", "target"],
|
||||||
|
// },
|
||||||
editor: Editor | null = null;
|
// });
|
||||||
|
|
||||||
get isDescriptionMode(): boolean {
|
|
||||||
return this.mode === "description" || this.isBasicMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCommentMode(): boolean {
|
|
||||||
return this.mode === "comment";
|
|
||||||
}
|
|
||||||
|
|
||||||
get isShortMode(): boolean {
|
|
||||||
return this.isBasicMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isBasicMode(): boolean {
|
|
||||||
return this.mode === "basic";
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
insertMention(obj: { range: any; attrs: any }) {
|
|
||||||
console.log("initialize Mention");
|
|
||||||
}
|
|
||||||
|
|
||||||
observer!: MutationObserver | null;
|
|
||||||
|
|
||||||
mounted(): void {
|
|
||||||
this.editor = new Editor({
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
"aria-multiline": this.isShortMode.toString(),
|
|
||||||
"aria-label": this.ariaLabel,
|
|
||||||
role: "textbox",
|
|
||||||
},
|
|
||||||
transformPastedHTML: this.transformPastedHTML,
|
|
||||||
},
|
|
||||||
extensions: [
|
|
||||||
Blockquote,
|
|
||||||
BulletList,
|
|
||||||
Heading,
|
|
||||||
Document,
|
|
||||||
Paragraph,
|
|
||||||
Text,
|
|
||||||
OrderedList,
|
|
||||||
ListItem,
|
|
||||||
Mention.configure(MentionOptions),
|
|
||||||
CustomImage,
|
|
||||||
AutoDir,
|
|
||||||
Underline,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Strike,
|
|
||||||
Dropcursor,
|
|
||||||
Gapcursor,
|
|
||||||
History,
|
|
||||||
Link.configure({
|
|
||||||
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
injectCSS: false,
|
|
||||||
content: this.value,
|
|
||||||
onUpdate: () => {
|
|
||||||
this.$emit("input", this.editor?.getHTML());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
transformPastedHTML(html: string): string {
|
|
||||||
// When using comment mode, limit to acceptable tags
|
|
||||||
if (this.isCommentMode) {
|
|
||||||
return sanitizeHtml(html, {
|
|
||||||
allowedTags: ["b", "i", "em", "strong", "a"],
|
|
||||||
allowedAttributes: {
|
|
||||||
a: ["href", "rel", "target"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
@Watch("value")
|
const value = computed(() => props.modelValue);
|
||||||
onValueChanged(val: string): void {
|
|
||||||
if (!this.editor) return;
|
watch(value, (val: string) => {
|
||||||
if (val !== this.editor.getHTML()) {
|
if (!editor.value) return;
|
||||||
this.editor.commands.setContent(val, false);
|
if (val !== editor.value.getHTML()) {
|
||||||
}
|
editor.value.commands.setContent(val, false);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
const dialog = inject<Dialog>("dialog");
|
||||||
* Show a popup to get the link from the URL
|
const { t } = useI18n({ useScope: "global" });
|
||||||
*/
|
|
||||||
showLinkMenu(): void {
|
|
||||||
this.$buefy.dialog.prompt({
|
|
||||||
message: this.$t("Enter the link URL") as string,
|
|
||||||
inputAttrs: {
|
|
||||||
type: "url",
|
|
||||||
},
|
|
||||||
trapFocus: true,
|
|
||||||
onConfirm: (value) => {
|
|
||||||
if (!this.editor) return undefined;
|
|
||||||
this.editor.chain().focus().setLink({ href: value }).run();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a file prompt, upload picture and insert it into editor
|
* Show a popup to get the link from the URL
|
||||||
*/
|
*/
|
||||||
async showImagePrompt(): Promise<void> {
|
const showLinkMenu = (): void => {
|
||||||
const image = await listenFileUpload();
|
dialog?.prompt({
|
||||||
try {
|
message: t("Enter the link URL"),
|
||||||
const { data } = await this.$apollo.mutate({
|
inputAttrs: {
|
||||||
mutation: UPLOAD_MEDIA,
|
type: "url",
|
||||||
variables: {
|
},
|
||||||
file: image,
|
onConfirm: (prompt: string) => {
|
||||||
name: image.name,
|
if (!editor.value) return;
|
||||||
},
|
editor.value.chain().focus().setLink({ href: prompt }).run();
|
||||||
});
|
},
|
||||||
if (data.uploadMedia && data.uploadMedia.url && this.editor) {
|
});
|
||||||
this.editor
|
};
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setImage({
|
|
||||||
src: data.uploadMedia.url,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
"data-media-id": data.uploadMedia.id,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
|
||||||
this.$notifier.error(error.graphQLErrors[0].message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const {
|
||||||
* We use this to programatically insert an actor mention when creating a reply to comment
|
mutate: uploadMediaMutation,
|
||||||
*/
|
onDone: uploadMediaDone,
|
||||||
replyToComment(actor: IActor): void {
|
onError: uploadMediaError,
|
||||||
if (!this.editor) return;
|
} = useMutation(UPLOAD_MEDIA);
|
||||||
this.editor
|
|
||||||
|
/**
|
||||||
|
* Show a file prompt, upload picture and insert it into editor
|
||||||
|
*/
|
||||||
|
const showImagePrompt = async (): Promise<void> => {
|
||||||
|
const image = await listenFileUpload();
|
||||||
|
uploadMediaMutation({
|
||||||
|
file: image,
|
||||||
|
name: image.name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadMediaDone(({ data }) => {
|
||||||
|
if (data.uploadMedia && data.uploadMedia.url && editor.value) {
|
||||||
|
editor.value
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContent({
|
.setImage({
|
||||||
type: "mention",
|
src: data.uploadMedia.url,
|
||||||
attrs: {
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
id: usernameWithDomain(actor),
|
// @ts-ignore
|
||||||
},
|
"data-media-id": data.uploadMedia.id,
|
||||||
})
|
})
|
||||||
.insertContent(" ")
|
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
focus(): void {
|
const notifier = inject<Notifier>("notifier");
|
||||||
this.editor?.chain().focus("end");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeDestroy(): void {
|
uploadMediaError((error) => {
|
||||||
this.editor?.destroy();
|
console.error(error);
|
||||||
|
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||||
|
notifier?.error(error.graphQLErrors[0].message);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use this to programatically insert an actor mention when creating a reply to comment
|
||||||
|
*/
|
||||||
|
const replyToComment = (actor: IActor): void => {
|
||||||
|
if (!editor.value) return;
|
||||||
|
editor.value
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
id: usernameWithDomain(actor),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.insertContent(" ")
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const focus = (): void => {
|
||||||
|
editor.value?.chain().focus("end");
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ replyToComment, focus });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "@/styles/_mixins" as *;
|
@use "@/styles/_mixins" as *;
|
||||||
|
@ -422,7 +434,7 @@ $color-white: #eee;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: $color-black;
|
color: $color-black;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
@include margin-right(0.2rem);
|
// @include margin-right(0.2rem);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@ -492,10 +504,10 @@ $color-white: #eee;
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
// ul,
|
||||||
ol {
|
// ol {
|
||||||
@include padding-left(1rem);
|
// @include padding-left(1rem);
|
||||||
}
|
// }
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
|
@ -510,7 +522,7 @@ $color-white: #eee;
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 3px solid rgba($color-black, 0.1);
|
border-left: 3px solid rgba($color-black, 0.1);
|
||||||
color: rgba($color-black, 0.8);
|
color: rgba($color-black, 0.8);
|
||||||
@include padding-left(0.8rem);
|
// @include padding-left(0.8rem);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { UPLOAD_MEDIA } from "@/graphql/upload";
|
import { UPLOAD_MEDIA } from "@/graphql/upload";
|
||||||
import apolloProvider from "@/vue-apollo";
|
import { apolloClient } from "@/vue-apollo";
|
||||||
import { ApolloClient } from "@apollo/client/core/ApolloClient";
|
|
||||||
import { Plugin } from "prosemirror-state";
|
import { Plugin } from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import { NormalizedCacheObject } from "@apollo/client/cache";
|
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
|
||||||
|
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
|
|
||||||
|
@ -60,21 +59,25 @@ const CustomImage = Image.extend({
|
||||||
top: realEvent.clientY,
|
top: realEvent.clientY,
|
||||||
});
|
});
|
||||||
if (!coordinates) return false;
|
if (!coordinates) return false;
|
||||||
const client =
|
|
||||||
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
|
|
||||||
|
|
||||||
try {
|
images.forEach((image) => {
|
||||||
images.forEach(async (image) => {
|
const { onDone, onError } = provideApolloClient(apolloClient)(
|
||||||
const { data } = await client.mutate({
|
() =>
|
||||||
mutation: UPLOAD_MEDIA,
|
useMutation<{ uploadMedia: { url: string; id: string } }>(
|
||||||
variables: {
|
UPLOAD_MEDIA,
|
||||||
file: image,
|
() => ({
|
||||||
name: image.name,
|
variables: {
|
||||||
},
|
file: image,
|
||||||
});
|
name: image.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onDone(({ data }) => {
|
||||||
const node = schema.nodes.image.create({
|
const node = schema.nodes.image.create({
|
||||||
src: data.uploadMedia.url,
|
src: data?.uploadMedia.url,
|
||||||
"data-media-id": data.uploadMedia.id,
|
"data-media-id": data?.uploadMedia.id,
|
||||||
});
|
});
|
||||||
const transaction = view.state.tr.insert(
|
const transaction = view.state.tr.insert(
|
||||||
coordinates.pos,
|
coordinates.pos,
|
||||||
|
@ -82,11 +85,13 @@ const CustomImage = Image.extend({
|
||||||
);
|
);
|
||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
} catch (error) {
|
onError((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,27 +1,38 @@
|
||||||
import { SEARCH_PERSONS } from "@/graphql/search";
|
import { SEARCH_PERSONS } from "@/graphql/search";
|
||||||
import { VueRenderer } from "@tiptap/vue-2";
|
import { VueRenderer } from "@tiptap/vue-3";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import MentionList from "./MentionList.vue";
|
import MentionList from "./MentionList.vue";
|
||||||
import { ApolloClient } from "@apollo/client/core/ApolloClient";
|
import { apolloClient } from "@/vue-apollo";
|
||||||
import apolloProvider from "@/vue-apollo";
|
|
||||||
import { IPerson } from "@/types/actor";
|
import { IPerson } from "@/types/actor";
|
||||||
import pDebounce from "p-debounce";
|
import pDebounce from "p-debounce";
|
||||||
import { NormalizedCacheObject } from "@apollo/client/cache/inmemory/types";
|
|
||||||
import { MentionOptions } from "@tiptap/extension-mention";
|
import { MentionOptions } from "@tiptap/extension-mention";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
|
||||||
|
import { Paginate } from "@/types/paginate";
|
||||||
|
import { onError } from "@apollo/client/link/error";
|
||||||
|
|
||||||
const client =
|
const fetchItems = (query: string): Promise<IPerson[]> => {
|
||||||
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
|
return new Promise((resolve, reject) => {
|
||||||
|
const { onResult } = provideApolloClient(apolloClient)(() => {
|
||||||
|
return useQuery<{ searchPersons: Paginate<IPerson> }>(
|
||||||
|
SEARCH_PERSONS,
|
||||||
|
() => ({
|
||||||
|
variables: {
|
||||||
|
searchText: query,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const fetchItems = async (query: string): Promise<IPerson[]> => {
|
onResult(({ data }) => {
|
||||||
const result = await client.query({
|
resolve(data.searchPersons.elements);
|
||||||
query: SEARCH_PERSONS,
|
});
|
||||||
variables: {
|
|
||||||
searchText: query,
|
onError(reject);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// TipTap doesn't handle async for onFilter, hence the following line.
|
|
||||||
return result.data.searchPersons.elements;
|
// // TipTap doesn't handle async for onFilter, hence the following line.
|
||||||
|
// return result.data.searchPersons.elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedFetchItems = pDebounce(fetchItems, 200);
|
const debouncedFetchItems = pDebounce(fetchItems, 200);
|
||||||
|
@ -53,7 +64,6 @@ const mentionOptions: MentionOptions = {
|
||||||
return {
|
return {
|
||||||
onStart: (props: any) => {
|
onStart: (props: any) => {
|
||||||
component = new VueRenderer(MentionList, {
|
component = new VueRenderer(MentionList, {
|
||||||
parent: this,
|
|
||||||
propsData: props,
|
propsData: props,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -12,70 +12,64 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { Vue, Component, Prop, Watch } from "vue-property-decorator";
|
import { usernameWithDomain } from "@/types/actor/actor.model";
|
||||||
import { displayName, usernameWithDomain } from "@/types/actor/actor.model";
|
|
||||||
import { IPerson } from "@/types/actor";
|
import { IPerson } from "@/types/actor";
|
||||||
import ActorInline from "../../components/Account/ActorInline.vue";
|
import ActorInline from "../../components/Account/ActorInline.vue";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
@Component({
|
const props = defineProps<{
|
||||||
components: {
|
items: IPerson[];
|
||||||
ActorInline,
|
command: ({ id }: { id: string }) => {};
|
||||||
},
|
}>();
|
||||||
})
|
|
||||||
export default class MentionList extends Vue {
|
|
||||||
@Prop({ type: Array, required: true }) items!: Array<IPerson>;
|
|
||||||
@Prop({ type: Function, required: true }) command!: any;
|
|
||||||
|
|
||||||
selectedIndex = 0;
|
// @Prop({ type: Function, required: true }) command!: any;
|
||||||
|
|
||||||
displayName = displayName;
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
@Watch("items")
|
watch(props.items, () => {
|
||||||
watchItems(): void {
|
selectedIndex.value = 0;
|
||||||
this.selectedIndex = 0;
|
});
|
||||||
|
|
||||||
|
const onKeyDown = ({ event }: { event: KeyboardEvent }): boolean => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
upHandler();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown({ event }: { event: KeyboardEvent }): boolean {
|
if (event.key === "ArrowDown") {
|
||||||
if (event.key === "ArrowUp") {
|
downHandler();
|
||||||
this.upHandler();
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
this.downHandler();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
this.enterHandler();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upHandler(): void {
|
if (event.key === "Enter") {
|
||||||
this.selectedIndex =
|
enterHandler();
|
||||||
(this.selectedIndex + this.items.length - 1) % this.items.length;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
downHandler(): void {
|
return false;
|
||||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
enterHandler(): void {
|
const upHandler = (): void => {
|
||||||
this.selectItem(this.selectedIndex);
|
selectedIndex.value =
|
||||||
}
|
(selectedIndex.value + props.items.length - 1) % props.items.length;
|
||||||
|
};
|
||||||
|
|
||||||
selectItem(index: number): void {
|
const downHandler = (): void => {
|
||||||
const item = this.items[index];
|
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
|
||||||
|
};
|
||||||
|
|
||||||
if (item) {
|
const enterHandler = (): void => {
|
||||||
this.command({ id: usernameWithDomain(item) });
|
selectItem(selectedIndex.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const selectItem = (index: number): void => {
|
||||||
|
const item = props.items[index];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
props.command({ id: usernameWithDomain(item) });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -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>
|
|
217
js/src/components/ErrorComponent.vue
Normal 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>
|
|
@ -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 {
|
||||||
|
|
14
js/src/components/Event/DateCalendarIcon.story.vue
Normal 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>
|
|
@ -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 {
|
||||||
|
|