Merge branch 'upstream_1.0.3' into chapril

This commit is contained in:
tykayn 2021-01-10 10:36:16 +01:00 committed by Baptiste Lemoine
commit 247c76e038
489 changed files with 28317 additions and 15550 deletions

View File

@ -39,9 +39,9 @@ lint:
- mix format --check-formatted --dry-run || export EXITVALUE=1 - mix format --check-formatted --dry-run || export EXITVALUE=1
- cd js - cd js
- yarn install - yarn install
#- yarn run lint || export EXITVALUE=1 - yarn run lint || export EXITVALUE=1
- yarn run prettier --ignore-path="src/i18n/*" -c . || export EXITVALUE=1 - yarn run prettier -c . || export EXITVALUE=1
- yarn run build - yarn run build:assets
- cd ../ - cd ../
- exit $EXITVALUE - exit $EXITVALUE
artifacts: artifacts:
@ -69,7 +69,7 @@ exunit:
before_script: before_script:
- cd js - cd js
- yarn install - yarn install
- yarn run build - yarn run build:assets
- cd ../ - cd ../
- mix deps.get - mix deps.get
- MIX_ENV=test mix ecto.create - MIX_ENV=test mix ecto.create
@ -78,6 +78,21 @@ exunit:
- lint - lint
script: script:
- mix coveralls - mix coveralls
jest:
stage: test
before_script:
- cd js
- yarn install
dependencies:
- lint
script:
- yarn run test:unit --no-color
artifacts:
when: always
paths:
- js/coverage
expire_in: 30 days
# cypress: # cypress:
# stage: test # stage: test
# services: # services:

View File

@ -1,8 +0,0 @@
projects:
Mobilizon:
schemaPath: schema.graphql
extensions:
endpoints:
dev:
url: 'http://localhost:4000/api'
introspect: true

View File

@ -4,7 +4,96 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 1.0.3 - 18-12-2020
**This release adds new migrations, be sure to run them before restarting Mobilizon**
**This release has repair steps, be sure to execute them right after restarting Mobilizon**
### Special operations
* **Reattach media files to their entity.**
When media files were uploaded and added in events and posts bodies, they were only attached to the profile that uploaded them, not to the event or post. This task attaches them back to their entity so that the command to clean orphan media files doesn't remove them.
* Source install
`MIX_ENV=prod mix mobilizon.maintenance.fix_unattached_media_in_body`
* Docker
`docker-compose exec mobilizon mobilizon_ctl maintenance.fix_unattached_media_in_body`
* **Refresh remote profiles to save avatars locally**
Profile avatars and banners were previously only proxified and cached. Now we save them locally. Refreshing all remote actors will save profile media locally instead.
* Source install
`MIX_ENV=prod mix mobilizon.actors.refresh --all`
* Docker
`docker-compose exec mobilizon mobilizon_ctl actors.refresh --all`
* **imagemagick and webp are now a required dependency** to build Mobilizon.
Optimized versions of Mobilizon's pictures are now produced during front-end build.
See [the documentation](https://docs.joinmobilizon.org/administration/dependencies/#misc) to make sure these dependencies are installed.
### Added
- **Add a command to clean orphan media files**. There's a `--dry-run` option to see what files would have been deleted.
**Make sure all media files have been reattached properly (see above) before running this command.**
In 1.1.0 a scheduled job will be enabled to clear orphan media files automatically after a while.
- Added user and actors media usage information in administration
- Added a scheduled job to clean unconfirmed users (and their eventual initial profile) after a 48 hour grace period
- Added a mix task to manually clean unconfirmed users
- Added OpenStreetMap (OSRM) or GoogleMaps routing pages on the event map modal
- Added PWA support, Mobilizon can now be installed on Android (Firefox and Chrome), iOS (Safari) and desktop (Chrome)
- Added possibility to pick language through a setting on the footer for unlogged users
### Changed
- Save remote avatars and banners instead of proxifying them
- Forbid creating usernames with uppercase characters
- Allow LDAP admin to use a fully qualified DN (different than the one for the users)
- Allow LDAP users to be filtered by LDAP attribute `memberOf`.
- Improve the "My events" and "My groups" page when there's nothing here yet
- Show identity concerned when listing event participations (in "My events") and group membership (in "My groups")
- The datetime picker on the event's edition page has been changed and allows directly editing the text
- Allow to clear and remove pictures from events and posts
### Fixed
- Fixed inline media that weren't being tracked, so that they are not considered orphans media files.
- Fixed permissions on the Docker volume
- Fixed emails not using user timezone
- Fixed draft status not being shown on group events & posts inside admin section
- Fixed cancelled status not being shown on cancelled events cards
- Fixed membership notification emails not being sent with the user's language
- Fixed group posts ActivityPub endpoint
- Fixed unlisted groups being available in search
- Fixed inline media pictures being unattached when editing an event or a post
- Fixed adding an instance to follow with spaces
- Fixed past groups showing up on group's page
- Fixed error message not showing up when you are already an anonymous participant for an event
- Fixed error message not showing up when you pick an username already in user for a new profile or a group
- Fixed translations not fallbacking properly to english when not found
-
### Security
- Stop logging user JWT tokens in Websocket Mobilizon logs
### Translations
Updated translations:
- Catalan
- Dutch
- English
- Finnish
- French
- Galician
- German
- Hungarian
- Italian
- Norwegian
- Occitan
- Polish
- Spanish
- Swedish
## 1.0.2 - 2020-11-15 ## 1.0.2 - 2020-11-15

View File

@ -52,7 +52,7 @@ We appreciate any contribution to Mobilizon. Check our [CONTRIBUTING](CONTRIBUTI
* 📜 Documentation [https://docs.joinmobilizon.org](https://docs.joinmobilizon.org) * 📜 Documentation [https://docs.joinmobilizon.org](https://docs.joinmobilizon.org)
### Discuss ### Discuss
* 💬 Riot/Matrix: [https://riot.im/app/#/room/#Mobilizon:matrix.org](https://riot.im/app/#/room/#Mobilizon:matrix.org) * 💬 Element/Matrix: [https://matrix.to/#/#Mobilizon:matrix.org](https://matrix.to/#/#Mobilizon:matrix.org)
* 🗣️ Forum: [https://framacolibri.org/c/mobilizon](https://framacolibri.org/c/mobilizon) * 🗣️ Forum: [https://framacolibri.org/c/mobilizon](https://framacolibri.org/c/mobilizon)
### Follow ### Follow

View File

@ -28,6 +28,10 @@ config :mobilizon, :instance,
upload_limit: 10_000_000, upload_limit: 10_000_000,
avatar_upload_limit: 2_000_000, avatar_upload_limit: 2_000_000,
banner_upload_limit: 4_000_000, banner_upload_limit: 4_000_000,
remove_orphan_uploads: true,
orphan_upload_grace_period_hours: 48,
remove_unconfirmed_users: true,
unconfirmed_user_grace_period_hours: 48,
email_from: "noreply@localhost", email_from: "noreply@localhost",
email_reply_to: "noreply@localhost" email_reply_to: "noreply@localhost"
@ -77,17 +81,6 @@ config :mobilizon, Mobilizon.Web.Upload,
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "uploads" config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "uploads"
config :mobilizon, :media_proxy,
enabled: true,
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
http: [
follow_redirect: true,
pool: :media
]
]
config :mobilizon, Mobilizon.Web.Email.Mailer, config :mobilizon, Mobilizon.Web.Email.Mailer,
adapter: Bamboo.SMTPAdapter, adapter: Bamboo.SMTPAdapter,
server: "localhost", server: "localhost",
@ -142,6 +135,10 @@ config :mobilizon, :ldap,
base: System.get_env("LDAP_BASE") || "dc=example,dc=com", base: System.get_env("LDAP_BASE") || "dc=example,dc=com",
uid: System.get_env("LDAP_UID") || "cn", uid: System.get_env("LDAP_UID") || "cn",
require_bind_for_search: !(System.get_env("LDAP_REQUIRE_BIND_FOR_SEARCH") == "false"), require_bind_for_search: !(System.get_env("LDAP_REQUIRE_BIND_FOR_SEARCH") == "false"),
# The full CN to filter by `memberOf`, or `false` if disabled
group: false,
# Either the admin UID matching the field in `uid`,
# Either a tuple with the fully qualified DN: {:full, uid=admin,dc=example.com,dc=local}
bind_uid: System.get_env("LDAP_BIND_UID"), bind_uid: System.get_env("LDAP_BIND_UID"),
bind_password: System.get_env("LDAP_BIND_PASSWORD") bind_password: System.get_env("LDAP_BIND_PASSWORD")
@ -154,22 +151,20 @@ config :geolix,
} }
] ]
config :auto_linker, config :mobilizon, Mobilizon.Service.Formatter,
opts: [ class: false,
scheme: true, rel: "noopener noreferrer ugc",
extra: true, new_window: true,
# TODO: Set to :no_scheme when it works properly truncate: false,
validate_tld: true, strip_prefix: false,
class: false, extra: true,
strip_prefix: false, validate_tld: :no_scheme
new_window: true,
rel: "noopener noreferrer ugc"
]
config :tesla, adapter: Tesla.Adapter.Hackney config :tesla, adapter: Tesla.Adapter.Hackney
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
config :phoenix, :filter_parameters, ["password", "token"]
config :ex_cldr, config :ex_cldr,
default_locale: "en", default_locale: "en",
@ -180,26 +175,8 @@ config :http_signatures,
config :mobilizon, :cldr, config :mobilizon, :cldr,
locales: [ locales: [
"ar",
"be",
"ca",
"cs",
"de",
"en",
"es",
"fi",
"fr", "fr",
"gl", "en"
"hu",
"it",
"ja",
"nl",
"nn",
"oc",
"pl",
"pt",
"ru",
"sv"
] ]
config :mobilizon, :activitypub, config :mobilizon, :activitypub,
@ -233,6 +210,9 @@ config :mobilizon, :maps,
tiles: [ tiles: [
endpoint: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", endpoint: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "© The OpenStreetMap Contributors" attribution: "© The OpenStreetMap Contributors"
],
routing: [
type: :openstreetmap
] ]
config :mobilizon, :anonymous, config :mobilizon, :anonymous,
@ -266,7 +246,10 @@ config :mobilizon, Oban,
queues: [default: 10, search: 5, mailers: 10, background: 5], queues: [default: 10, search: 5, mailers: 10, background: 5],
crontab: [ crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background}, {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 * * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background} {"17 * * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
# To be activated in Mobilizon 1.2
# {"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background}
] ]
config :mobilizon, :rich_media, config :mobilizon, :rich_media,

View File

@ -19,7 +19,6 @@ config :mobilizon, Mobilizon.Web.Endpoint,
code_reloader: true, code_reloader: true,
check_origin: false, check_origin: false,
watchers: [ watchers: [
# yarn: ["run", "dev", cd: Path.expand("../js", __DIR__)]
node: [ node: [
"node_modules/webpack/bin/webpack.js", "node_modules/webpack/bin/webpack.js",
"--mode", "--mode",
@ -53,8 +52,8 @@ config :mobilizon, Mobilizon.Web.Endpoint,
patterns: [ patterns: [
~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
~r{priv/gettext/.*(po)$}, ~r{priv/gettext/.*(po)$},
~r{lib/mobilizon_web/views/.*(ex)$}, ~r{lib/web/(live|views)/.*(ex)$},
~r{lib/mobilizon_web/templates/.*(eex)$} ~r{lib/web/templates/.*(eex)$}
] ]
] ]
@ -92,13 +91,6 @@ config :mobilizon, :instance,
# config :mobilizon, :activitypub, sign_object_fetches: false # config :mobilizon, :activitypub, sign_object_fetches: false
# No need to compile every locale in development environment
config :mobilizon, :cldr,
locales: [
"fr",
"en"
]
config :mobilizon, :anonymous, config :mobilizon, :anonymous,
reports: [ reports: [
allowed: true allowed: true

View File

@ -13,6 +13,31 @@ config :mobilizon, Mobilizon.Web.Endpoint,
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info
# Load all locales in production
config :mobilizon, :cldr,
locales: [
"ar",
"be",
"ca",
"cs",
"de",
"en",
"es",
"fi",
"fr",
"gl",
"hu",
"it",
"ja",
"nl",
"nn",
"oc",
"pl",
"pt",
"ru",
"sv"
]
cond do cond do
System.get_env("INSTANCE_CONFIG") && System.get_env("INSTANCE_CONFIG") &&
File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") -> File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") ->

View File

@ -1,7 +1,7 @@
# First build the application assets # First build the application assets
FROM node:alpine as assets FROM node:alpine as assets
RUN apk add --no-cache python build-base RUN apk add --no-cache python build-base libwebp-tools bash imagemagick ncurses
COPY js . COPY js .
RUN yarn install \ RUN yarn install \
@ -33,6 +33,8 @@ FROM alpine
RUN apk add --no-cache openssl ncurses-libs file postgresql-client RUN apk add --no-cache openssl ncurses-libs file postgresql-client
RUN mkdir -p /app/uploads && chown nobody:nobody /app/uploads
USER nobody USER nobody
EXPOSE 4000 EXPOSE 4000

View File

@ -4,4 +4,4 @@ indent_size = 2
end_of_line = lf end_of_line = lf
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 100 max_line_length = 80

View File

@ -7,13 +7,9 @@ module.exports = {
extends: [ extends: [
"plugin:vue/essential", "plugin:vue/essential",
"@vue/airbnb",
"@vue/typescript/recommended",
"plugin:cypress/recommended",
"plugin:prettier/recommended",
"prettier",
"eslint:recommended", "eslint:recommended",
"@vue/prettier", "@vue/prettier",
"@vue/typescript/recommended",
"@vue/prettier/@typescript-eslint", "@vue/prettier/@typescript-eslint",
], ],
@ -21,6 +17,7 @@ module.exports = {
parserOptions: { parserOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
parser: "@typescript-eslint/parser",
}, },
rules: { rules: {
@ -35,29 +32,24 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"cypress/no-unnecessary-waiting": "off", "cypress/no-unnecessary-waiting": "off",
"vue/max-len": [ "vue/max-len": [
"error", "off",
{ {
ignoreStrings: true, ignoreStrings: true,
ignoreHTMLTextContents: true,
ignoreTemplateLiterals: true,
ignoreComments: true,
template: 170, template: 170,
code: 100, code: 80,
}, },
], ],
"prettier/prettier": "error", "prettier/prettier": "error",
"@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-use-before-define": "off", "@typescript-eslint/no-use-before-define": "off",
"import/prefer-default-export": "off",
"import/extensions": "off", "import/extensions": "off",
"import/no-unresolved": "off", "import/no-unresolved": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
}, },
ignorePatterns: ["src/typings/*.d.ts", "vue.config.js"], ignorePatterns: ["src/typings/*.d.ts", "vue.config.js"],
overrides: [
{
files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"],
env: {
mocha: true,
},
},
],
}; };

1
js/.gitignore vendored
View File

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

View File

@ -1 +1,2 @@
src/i18n/*.json src/i18n/*.json
coverage/

View File

@ -24,7 +24,9 @@ fetch(`http://localhost:4000/api`, {
.then((result) => result.json()) .then((result) => result.json())
.then((result) => { .then((result) => {
// here we're filtering out any type information unrelated to unions or interfaces // here we're filtering out any type information unrelated to unions or interfaces
const filteredData = result.data.__schema.types.filter((type) => type.possibleTypes !== null); const filteredData = result.data.__schema.types.filter(
(type) => type.possibleTypes !== null
);
result.data.__schema.types = filteredData; result.data.__schema.types = filteredData;
fs.writeFile("./fragmentTypes.json", JSON.stringify(result.data), (err) => { fs.writeFile("./fragmentTypes.json", JSON.stringify(result.data), (err) => {
if (err) { if (err) {

19
js/jest.config.js Normal file
View File

@ -0,0 +1,19 @@
module.exports = {
preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",
collectCoverage: true,
collectCoverageFrom: [
"**/*.{vue,ts}",
"!**/node_modules/**",
"!get_union_json.ts",
],
coverageReporters: ["html", "text", "text-summary"],
// The following should fix the issue with svgs and ?inline loader (see Logo.vue), but doesn't work
//
// transform: {
// "^.+\\.svg$": "<rootDir>/tests/unit/svgTransform.js",
// },
// moduleNameMapper: {
// "^@/(.*svg)(\\?inline)$": "<rootDir>/src/$1",
// "^@/(.*)$": "<rootDir>/src/$1",
// },
};

View File

@ -1,11 +1,13 @@
{ {
"name": "mobilizon", "name": "mobilizon",
"version": "1.0.2", "version": "1.0.3",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build --modern", "build:assets": "vue-cli-service build --modern",
"test:unit": "vue-cli-service test:unit", "build:pictures": "bash ./scripts/build/pictures.sh",
"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 vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e", "test:e2e": "vue-cli-service test:e2e",
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
@ -29,7 +31,7 @@
"eslint-plugin-cypress": "^2.10.3", "eslint-plugin-cypress": "^2.10.3",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"intersection-observer": "^0.11.0", "intersection-observer": "^0.12.0",
"leaflet": "^1.4.0", "leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.72.0", "leaflet.locatecontrol": "^0.72.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
@ -39,6 +41,7 @@
"tippy.js": "^6.2.3", "tippy.js": "^6.2.3",
"tiptap": "^1.26.0", "tiptap": "^1.26.0",
"tiptap-extensions": "^1.29.1", "tiptap-extensions": "^1.29.1",
"unfetch": "^4.2.0",
"v-tooltip": "2.0.2", "v-tooltip": "2.0.2",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-apollo": "^3.0.3", "vue-apollo": "^3.0.3",
@ -52,6 +55,7 @@
"vuedraggable": "2.23.2" "vuedraggable": "2.23.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^26.0.18",
"@types/leaflet": "^1.5.2", "@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.60.7", "@types/leaflet.locatecontrol": "^0.60.7",
"@types/lodash": "^4.14.141", "@types/lodash": "^4.14.141",
@ -63,27 +67,27 @@
"@types/vuedraggable": "^2.23.0", "@types/vuedraggable": "^2.23.0",
"@typescript-eslint/eslint-plugin": "^4.0.1", "@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1", "@typescript-eslint/parser": "^4.0.1",
"@vue/cli-plugin-babel": "~4.5.8", "@vue/cli-plugin-babel": "~4.5.9",
"@vue/cli-plugin-e2e-cypress": "~4.5.8", "@vue/cli-plugin-e2e-cypress": "~4.5.9",
"@vue/cli-plugin-eslint": "~4.5.8", "@vue/cli-plugin-eslint": "~4.5.9",
"@vue/cli-plugin-pwa": "~4.5.8", "@vue/cli-plugin-pwa": "~4.5.9",
"@vue/cli-plugin-router": "~4.5.8", "@vue/cli-plugin-router": "~4.5.9",
"@vue/cli-plugin-typescript": "~4.5.8", "@vue/cli-plugin-typescript": "~4.5.9",
"@vue/cli-service": "~4.5.8", "@vue/cli-plugin-unit-jest": "~4.5.0",
"@vue/eslint-config-airbnb": "^5.0.2", "@vue/cli-service": "~4.5.9",
"@vue/eslint-config-prettier": "^6.0.0", "@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^7.0.0", "@vue/eslint-config-typescript": "^7.0.0",
"@vue/test-utils": "^1.1.0", "@vue/test-utils": "^1.1.0",
"eslint": "^7.7.0", "eslint": "^7.7.0",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^7.0.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^7.0.0", "eslint-plugin-vue": "^7.0.0",
"prettier": "2.1.2", "mock-apollo-client": "^0.4",
"prettier-eslint": "^11.0.0", "prettier": "2.2.1",
"prettier-eslint": "^12.0.0",
"sass": "^1.29.0", "sass": "^1.29.0",
"sass-loader": "^10.0.1", "sass-loader": "^10.0.1",
"typescript": "~4.0.2", "typescript": "~4.1.2",
"vue-cli-plugin-svg": "~0.1.3", "vue-cli-plugin-svg": "~0.1.3",
"vue-i18n-extract": "^1.0.2", "vue-i18n-extract": "^1.0.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",

View File

Before

Width:  |  Height:  |  Size: 725 KiB

After

Width:  |  Height:  |  Size: 725 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

3291
js/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

90
js/scripts/build/pictures.sh Executable file
View File

@ -0,0 +1,90 @@
#!/bin/bash
set -eu
output_dir="../priv/static/img/pics"
resolutions=(
480
1024
1920
)
ignore=(
homepage_background.png
)
file_extension () {
filename=$(basename -- "$file")
echo "${filename##*.}"
}
file_name () {
filename=$(basename -- "$file")
echo "${filename%.*}"
}
convert_image () {
name=$(file_name)
extension=$(file_extension)
res="$1w"
output="$output_dir/$name-$res.$extension"
convert -geometry "$resolution"x $file $output
}
produce_webp () {
name=$(file_name)
output="$output_dir/$name.webp"
cwebp $file -quiet -o $output
}
progress() {
local w=80 p=$1; shift
# create a string of spaces, then change them to dots
printf -v dots "%*s" "$(( $p*$w/100 ))" ""; dots=${dots// /.};
# print those dots on a fixed-width space plus the percentage etc.
printf "\r\e[K|%-*s| %3d %% %s" "$w" "$dots" "$p" "$*";
}
echo "Generating responsive versions of the pictures…"
if ! command -v convert &> /dev/null
then
echo "$(tput setaf 1)ERROR: The convert command could not be found. You need to install ImageMagick.$(tput sgr 0)"
exit 1
fi
nb_files=$( shopt -s nullglob ; set -- $output_dir/* ; echo $#)
tasks=$((${#resolutions[@]}*$nb_files))
i=1
for file in $output_dir/*
do
if [[ -f $file ]]; then
for resolution in "${resolutions[@]}"; do
convert_image $resolution
progress $(($i*100/$tasks)) still working...
i=$((i+1))
done
fi
done
echo -e "\nDone!"
echo "Generating optimized versions of the pictures…"
if ! command -v cwebp &> /dev/null
then
echo "$(tput setaf 1)ERROR: The cwebp command could not be found. You need to install webp.$(tput sgr 0)"
exit 1
fi
nb_files=$( shopt -s nullglob ; set -- $output_dir/* ; echo $#)
i=1
for file in $output_dir/*
do
if [[ -f $file ]]; then
produce_webp
progress $(($i*100/$nb_files)) still working...
i=$((i+1))
fi
done
echo -e "\nDone!"

18
js/src/@types/dom.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
declare global {
interface GeolocationCoordinates {
readonly accuracy: number;
readonly altitude: number | null;
readonly altitudeAccuracy: number | null;
readonly heading: number | null;
readonly latitude: number;
readonly longitude: number;
readonly speed: number | null;
}
interface GeolocationPosition {
readonly coords: GeolocationCoordinates;
readonly timestamp: number;
}
}
export {};

View File

@ -32,8 +32,16 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import NavBar from "./components/NavBar.vue"; import NavBar from "./components/NavBar.vue";
import { AUTH_ACCESS_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from "./constants"; import {
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from "./graphql/user"; AUTH_ACCESS_TOKEN,
AUTH_USER_EMAIL,
AUTH_USER_ID,
AUTH_USER_ROLE,
} from "./constants";
import {
CURRENT_USER_CLIENT,
UPDATE_CURRENT_USER_CLIENT,
} from "./graphql/user";
import Footer from "./components/Footer.vue"; import Footer from "./components/Footer.vue";
import Logo from "./components/Logo.vue"; import Logo from "./components/Logo.vue";
import { initializeCurrentActor } from "./utils/auth"; import { initializeCurrentActor } from "./utils/auth";

View File

@ -1,8 +1,11 @@
import { ICurrentUserRole } from "@/types/enums";
import { ApolloCache } from "apollo-cache"; import { ApolloCache } from "apollo-cache";
import { NormalizedCacheObject } from "apollo-cache-inmemory"; import { NormalizedCacheObject } from "apollo-cache-inmemory";
import { ICurrentUserRole } from "@/types/current-user.model"; import { Resolvers } from "apollo-client/core/types";
export default function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) { export default function buildCurrentUserResolver(
cache: ApolloCache<NormalizedCacheObject>
): Resolvers {
cache.writeData({ cache.writeData({
data: { data: {
currentUser: { currentUser: {
@ -53,7 +56,12 @@ export default function buildCurrentUserResolver(cache: ApolloCache<NormalizedCa
preferredUsername, preferredUsername,
avatar, avatar,
name, name,
}: { id: string; preferredUsername: string; avatar: string; name: string }, }: {
id: string;
preferredUsername: string;
avatar: string;
name: string;
},
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> } { cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => { ) => {
const data = { const data = {

View File

@ -1,4 +1,7 @@
import { IntrospectionFragmentMatcher, NormalizedCacheObject } from "apollo-cache-inmemory"; import {
IntrospectionFragmentMatcher,
NormalizedCacheObject,
} from "apollo-cache-inmemory";
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants"; import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants";
import { REFRESH_TOKEN } from "@/graphql/auth"; import { REFRESH_TOKEN } from "@/graphql/auth";
import { saveTokenData } from "@/utils/auth"; import { saveTokenData } from "@/utils/auth";
@ -11,7 +14,11 @@ export const fragmentMatcher = new IntrospectionFragmentMatcher({
{ {
kind: "UNION", kind: "UNION",
name: "SearchResult", name: "SearchResult",
possibleTypes: [{ name: "Event" }, { name: "Person" }, { name: "Group" }], possibleTypes: [
{ name: "Event" },
{ name: "Person" },
{ name: "Group" },
],
}, },
{ {
kind: "INTERFACE", kind: "INTERFACE",

View File

@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 248.16 46.78">
<title>Mobilizon Logo</title>
<g data-name="header">
<path d="M0 45.82l3.18-40.8a29.88 29.88 0 015.07-.36 27.74 27.74 0 014.95.36l4.86 17.16a92.19 92.19 0 012.34 10.08h.36a92.19 92.19 0 012.34-10.08L28 5.02a29.23 29.23 0 015-.36 29.23 29.23 0 015 .36l3.18 40.8a13.61 13.61 0 01-3.63.42 23.41 23.41 0 01-3.63-.24l-1.2-19.92q-.36-5.52-.48-12.84h-.44l-7.32 26.51a25.62 25.62 0 01-4 .3 23.36 23.36 0 01-3.84-.3L9.36 13.24H9q-.3 8.94-.48 12.84L7.26 46a22.47 22.47 0 01-3.6.24A13.75 13.75 0 010 45.82zM74 31.06q0 8-4.26 12.3a12.21 12.21 0 01-9 3.42 12.21 12.21 0 01-9-3.42q-4.26-4.26-4.26-12.3t4.24-12.31a12.21 12.21 0 019-3.42 12.21 12.21 0 019 3.42Q74 23.02 74 31.06zM60.75 20.98q-5.67 0-5.67 10.08t5.67 10.08q5.67 0 5.67-10.08t-5.67-10.08zM103.2 19.75q2.7 4.11 2.7 11.28T102 42.31a13.18 13.18 0 01-10 4.11 31.41 31.41 0 01-11.34-2V2.2l.4-.45h2.76A4 4 0 0187 2.83a5.38 5.38 0 01.93 3.57v11.94a12.08 12.08 0 017.56-2.7 8.71 8.71 0 017.71 4.11zm-9.72 2a7.28 7.28 0 00-5.58 2.82v16a15 15 0 004.08.54 5.25 5.25 0 004.68-2.67q1.68-2.67 1.68-7.59 0-9.03-4.86-9.1zM121 22v23.94a20.85 20.85 0 01-3.66.3 23 23 0 01-3.78-.3V24.75q0-3.24-2.7-3.24h-.72a9.32 9.32 0 01-.3-2.58 10.7 10.7 0 01.3-2.7 39.63 39.63 0 014.38-.24h1a5.19 5.19 0 014 1.62A6.27 6.27 0 01121 22z" />
<path d="M119.82.84a7.37 7.37 0 01.6 3 7.37 7.37 0 01-.6 3 7.46 7.46 0 01-3.87.84 6.49 6.49 0 01-3.69-.93 7.37 7.37 0 01-.6-3 7.37 7.37 0 01.6-3 8.09 8.09 0 013.87-.84 7.05 7.05 0 013.69.93z" fill="#fff" />
<path d="M139.08 40.42h2a10.23 10.23 0 01.6 3.18 9.24 9.24 0 01-.18 2.1 38.47 38.47 0 01-5.64.54q-6.48 0-6.48-7v-37l.36-.42h2.88a3.94 3.94 0 013.12 1.05 5.52 5.52 0 01.9 3.57v31.31q-.02 2.67 2.44 2.67zM155.94 22v23.94a20.85 20.85 0 01-3.66.3 23 23 0 01-3.78-.3V24.75q0-3.24-2.7-3.24h-.72a9.32 9.32 0 01-.3-2.58 10.7 10.7 0 01.3-2.7 39.63 39.63 0 014.38-.24h1a5.19 5.19 0 014.05 1.62 6.27 6.27 0 011.43 4.39z" />
<path d="M154.8 2.84a7.37 7.37 0 01.6 3 7.37 7.37 0 01-.6 3 7.46 7.46 0 01-3.87.84 6.49 6.49 0 01-3.69-.93 7.37 7.37 0 01-.6-3 7.37 7.37 0 01.6-3 8.09 8.09 0 013.87-.84 7.05 7.05 0 013.69.93z" fill="#fff" />
<path d="M163.08 39.22l8.76-11.82q1.32-1.8 4.8-5.7l-.18-.3a63.09 63.09 0 01-7.74.42H163a9.79 9.79 0 01-.24-2.34 15.8 15.8 0 01.42-3.3h20.4a16.31 16.31 0 011 4.26 4.1 4.1 0 01-.78 2.34L175 34.66a64.65 64.65 0 01-4.56 5.7l.18.24q3.12-.3 5.22-.3h2.58a15.35 15.35 0 006.12-.9 9.4 9.4 0 01.72 3.12q0 3.42-4.32 3.42h-18a14.27 14.27 0 01-.9-3.93 5.08 5.08 0 011.04-2.79zM215.88 31.06q0 8-4.26 12.3a13.63 13.63 0 01-18.06 0q-4.26-4.26-4.26-12.3t4.26-12.31a13.63 13.63 0 0118.06 0q4.26 4.27 4.26 12.31zm-13.29-10.08q-5.67 0-5.67 10.08t5.67 10.08q5.67 0 5.67-10.08t-5.67-10.08zM247 25.84v13.32a11 11 0 001.2 5.64 7 7 0 01-4.41 1.56q-2.43 0-3.33-1.14a5.69 5.69 0 01-.9-3.54V27.4a7.74 7.74 0 00-.72-3.87 2.78 2.78 0 00-2.58-1.17 8.62 8.62 0 00-6.3 3v20.58a20.85 20.85 0 01-3.66.3 23 23 0 01-3.78-.3v-29.7l.42-.36h2.76q3.42 0 4.08 3.6 4.38-3.84 8.73-3.84t6.42 2.82a12.17 12.17 0 012.07 7.38z" />
<path d="M57.26 10.75a7.37 7.37 0 01-.6-3 7.37 7.37 0 01.6-3 8.09 8.09 0 013.87-.84 7.05 7.05 0 013.69.84 7.37 7.37 0 01.6 3 7.37 7.37 0 01-.6 3 7.46 7.46 0 01-3.87.84 6.49 6.49 0 01-3.69-.84zM198.26 10.75a7.37 7.37 0 01-.6-3 7.37 7.37 0 01.6-3 8.09 8.09 0 013.87-.84 7.05 7.05 0 013.69.84 7.37 7.37 0 01.6 3 7.37 7.37 0 01-.6 3 7.46 7.46 0 01-3.87.84 6.49 6.49 0 01-3.69-.84z" fill="#fff" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -13,7 +13,12 @@
<template slot-scope="props"> <template slot-scope="props">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<img width="32" :src="props.option.avatar.url" v-if="props.option.avatar" alt="" /> <img
width="32"
:src="props.option.avatar.url"
v-if="props.option.avatar"
alt=""
/>
<b-icon v-else icon="account-circle" /> <b-icon v-else icon="account-circle" />
</div> </div>
<div class="media-content"> <div class="media-content">
@ -21,7 +26,9 @@
{{ props.option.name }} {{ props.option.name }}
<br /> <br />
<small>{{ `@${props.option.preferredUsername}` }}</small> <small>{{ `@${props.option.preferredUsername}` }}</small>
<small v-if="props.option.domain">{{ `@${props.option.domain}` }}</small> <small v-if="props.option.domain">{{
`@${props.option.domain}`
}}</small>
</span> </span>
<span v-else> <span v-else>
{{ `@${props.option.preferredUsername}` }} {{ `@${props.option.preferredUsername}` }}
@ -53,7 +60,9 @@ export default class ActorAutoComplete extends Vue {
selected: IPerson | null = this.defaultSelected; selected: IPerson | null = this.defaultSelected;
name: string = this.defaultSelected ? this.defaultSelected.preferredUsername : ""; name: string = this.defaultSelected
? this.defaultSelected.preferredUsername
: "";
page = 1; page = 1;

View File

@ -12,8 +12,15 @@
<p> <p>
{{ actor.name || `@${usernameWithDomain(actor)}` }} {{ actor.name || `@${usernameWithDomain(actor)}` }}
</p> </p>
<p class="has-text-grey" v-if="actor.name">@{{ usernameWithDomain(actor) }}</p> <p class="has-text-grey" v-if="actor.name">
<div v-if="full" class="summary" :class="{ limit: limit }" v-html="actor.summary" /> @{{ usernameWithDomain(actor) }}
</p>
<div
v-if="full"
class="summary"
:class="{ limit: limit }"
v-html="actor.summary"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,10 @@
<ul class="identities"> <ul class="identities">
<li v-for="identity in identities" :key="identity.id"> <li v-for="identity in identities" :key="identity.id">
<router-link <router-link
:to="{ name: 'UpdateIdentity', params: { identityName: identity.preferredUsername } }" :to="{
name: 'UpdateIdentity',
params: { identityName: identity.preferredUsername },
}"
class="media identity" class="media identity"
v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }" v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"
> >
@ -24,7 +27,10 @@
</li> </li>
</ul> </ul>
<router-link :to="{ name: 'CreateIdentity' }" class="button create-identity is-primary"> <router-link
:to="{ name: 'CreateIdentity' }"
class="button create-identity is-primary"
>
{{ $t("Create a new identity") }} {{ $t("Create a new identity") }}
</router-link> </router-link>
</section> </section>
@ -53,7 +59,7 @@ export default class Identities extends Vue {
errors: string[] = []; errors: string[] = [];
isCurrentIdentity(identity: IPerson) { isCurrentIdentity(identity: IPerson): boolean {
return identity.preferredUsername === this.currentIdentityName; return identity.preferredUsername === this.currentIdentityName;
} }
} }

View File

@ -1,94 +0,0 @@
<docs>
```vue
<participant-card :participant="{ actor: { preferredUsername: 'user1', name: 'someoneIDontLike' }, role: 'REJECTED' }" />
```
```vue
<participant-card :participant="{ actor: { preferredUsername: 'user2', name: 'someoneWhoWillWait' }, role: 'NOT_APPROVED' }" />
```
```vue
<participant-card :participant="{ actor: { preferredUsername: 'user3', name: 'a_participant' }, role: 'PARTICIPANT' }" />
```
```vue
<participant-card :participant="{ actor: { preferredUsername: 'me', name: 'myself' }, role: 'CREATOR' }" />
```
</docs>
<template>
<article class="card">
<div class="card-content">
<div class="media">
<div class="media-left" v-if="participant.actor.avatar">
<figure class="image is-48x48">
<img :src="participant.actor.avatar.url" />
</figure>
</div>
<div class="media-content">
<span ref="title">{{ actorDisplayName }}</span
><br />
<small class="has-text-grey" v-if="participant.actor.domain"
>@{{ participant.actor.preferredUsername }}@{{ participant.actor.domain }}</small
>
<small class="has-text-grey" v-else>@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
<footer class="card-footer">
<b-button
v-if="[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role)"
@click="accept(participant)"
type="is-success"
class="card-footer-item"
>{{ $t("Approve") }}</b-button
>
<b-button
v-if="participant.role === ParticipantRole.NOT_APPROVED"
@click="reject(participant)"
type="is-danger"
class="card-footer-item"
>{{ $t("Reject") }}</b-button
>
<b-button
v-if="participant.role === ParticipantRole.PARTICIPANT"
@click="exclude(participant)"
type="is-danger"
class="card-footer-item"
>{{ $t("Exclude") }}</b-button
>
<span v-if="participant.role === ParticipantRole.CREATOR" class="card-footer-item">{{
$t("Creator")
}}</span>
</footer>
</article>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IParticipant, ParticipantRole } from "../../types/participant.model";
import { IPerson, Person } from "../../types/actor";
@Component
export default class ParticipantCard extends Vue {
@Prop({ required: true }) participant!: IParticipant;
@Prop({ type: Function }) accept!: Function;
@Prop({ type: Function }) reject!: Function;
@Prop({ type: Function }) exclude!: Function;
ParticipantRole = ParticipantRole;
get actorDisplayName(): string {
const actor = new Person(this.participant.actor as IPerson);
return actor.displayName();
}
}
</script>
<style lang="scss">
.card-footer-item {
height: $control-height;
}
</style>

View File

@ -12,8 +12,9 @@
</v-popover> </v-popover>
</template> </template>
<script lang="ts"> <script lang="ts">
import { ActorType } from "@/types/enums";
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor, ActorType } from "../../types/actor"; import { IActor } from "../../types/actor";
import ActorCard from "./ActorCard.vue"; import ActorCard from "./ActorCard.vue";
@Component({ @Component({

View File

@ -16,11 +16,21 @@
checkable checkable
checkbox-position="left" checkbox-position="left"
> >
<b-table-column field="actor.id" label="ID" width="40" numeric v-slot="props">{{ <b-table-column
props.row.actor.id field="actor.id"
}}</b-table-column> label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.actor.id }}</b-table-column
>
<b-table-column field="actor.type" :label="$t('Type')" width="80" v-slot="props"> <b-table-column
field="actor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" /> <b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else /> <b-icon icon="account-circle" v-else />
</b-table-column> </b-table-column>
@ -33,26 +43,39 @@
centered centered
v-slot="props" v-slot="props"
> >
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`">{{ <span
props.row.approved ? $t("Accepted") : $t("Pending") :class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
}}</span> >{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column> </b-table-column>
<b-table-column field="actor.domain" :label="$t('Domain')" sortable> <b-table-column field="actor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props"> <template v-slot:default="props">
<a @click="toggle(props.row)" v-if="RelayMixin.isInstance(props.row.actor)">{{ <a
props.row.actor.domain @click="toggle(props.row)"
}}</a> v-if="RelayMixin.isInstance(props.row.actor)"
>{{ props.row.actor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{ <a @click="toggle(props.row)" v-else>{{
`${props.row.actor.preferredUsername}@${props.row.actor.domain}` `${props.row.actor.preferredUsername}@${props.row.actor.domain}`
}}</a> }}</a>
</template> </template>
</b-table-column> </b-table-column>
<b-table-column field="targetActor.updatedAt" :label="$t('Date')" sortable v-slot="props"> <b-table-column
<span :title="$options.filters.formatDateTimeString(props.row.updatedAt)">{{ field="targetActor.updatedAt"
formatDistanceToNow(new Date(props.row.updatedAt), { locale: $dateFnsLocale }) :label="$t('Date')"
}}</span></b-table-column sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
> >
<template slot="detail" slot-scope="props"> <template slot="detail" slot-scope="props">
@ -143,7 +166,11 @@ export default class Followers extends Mixins(RelayMixin) {
await this.$apollo.queries.relayFollowers.refetch(); await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = []; this.checkedRows = [];
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
@ -158,7 +185,11 @@ export default class Followers extends Mixins(RelayMixin) {
await this.$apollo.queries.relayFollowers.refetch(); await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = []; this.checkedRows = [];
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }

View File

@ -1,13 +1,22 @@
<template> <template>
<div> <div>
<form @submit="followRelay"> <form @submit="followRelay">
<b-field :label="$t('Add an instance')" custom-class="add-relay" horizontal> <b-field
:label="$t('Add an instance')"
custom-class="add-relay"
horizontal
>
<b-field grouped expanded size="is-large"> <b-field grouped expanded size="is-large">
<p class="control"> <p class="control">
<b-input v-model="newRelayAddress" :placeholder="$t('Ex: mobilizon.fr')" /> <b-input
v-model="newRelayAddress"
:placeholder="$t('Ex: mobilizon.fr')"
/>
</p> </p>
<p class="control"> <p class="control">
<b-button type="is-primary" native-type="submit">{{ $t("Add an instance") }}</b-button> <b-button type="is-primary" native-type="submit">{{
$t("Add an instance")
}}</b-button>
</p> </p>
</b-field> </b-field>
</b-field> </b-field>
@ -29,12 +38,25 @@
checkable checkable
checkbox-position="left" checkbox-position="left"
> >
<b-table-column field="targetActor.id" label="ID" width="40" numeric v-slot="props">{{ <b-table-column
props.row.targetActor.id field="targetActor.id"
}}</b-table-column> label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.targetActor.id }}</b-table-column
>
<b-table-column field="targetActor.type" :label="$t('Type')" width="80" v-slot="props"> <b-table-column
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.targetActor)" /> field="targetActor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon
icon="lan"
v-if="RelayMixin.isInstance(props.row.targetActor)"
/>
<b-icon icon="account-circle" v-else /> <b-icon icon="account-circle" v-else />
</b-table-column> </b-table-column>
@ -46,26 +68,39 @@
centered centered
v-slot="props" v-slot="props"
> >
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`">{{ <span
props.row.approved ? $t("Accepted") : $t("Pending") :class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
}}</span> >{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column> </b-table-column>
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable> <b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props"> <template v-slot:default="props">
<a @click="toggle(props.row)" v-if="RelayMixin.isInstance(props.row.targetActor)">{{ <a
props.row.targetActor.domain @click="toggle(props.row)"
}}</a> v-if="RelayMixin.isInstance(props.row.targetActor)"
>{{ props.row.targetActor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{ <a @click="toggle(props.row)" v-else>{{
`${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}` `${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}`
}}</a> }}</a>
</template> </template>
</b-table-column> </b-table-column>
<b-table-column field="targetActor.updatedAt" :label="$t('Date')" sortable v-slot="props"> <b-table-column
<span :title="$options.filters.formatDateTimeString(props.row.updatedAt)">{{ field="targetActor.updatedAt"
formatDistanceToNow(new Date(props.row.updatedAt), { locale: $dateFnsLocale }) :label="$t('Date')"
}}</span></b-table-column sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
> >
<template slot="detail" slot-scope="props"> <template slot="detail" slot-scope="props">
@ -103,7 +138,6 @@ import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin"; import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model"; import { IFollower } from "../../types/actor/follower.model";
import { Paginate } from "../../types/paginate";
import RelayMixin from "../../mixins/relay"; import RelayMixin from "../../mixins/relay";
@Component({ @Component({
@ -127,19 +161,25 @@ export default class Followings extends Mixins(RelayMixin) {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: ADD_RELAY, mutation: ADD_RELAY,
variables: { variables: {
address: this.newRelayAddress, address: this.newRelayAddress.trim(), // trim to fix copy and paste domain name spaces and tabs
}, },
}); });
await this.$apollo.queries.relayFollowings.refetch(); await this.$apollo.queries.relayFollowings.refetch();
this.newRelayAddress = ""; this.newRelayAddress = "";
} catch (err) { } catch (err) {
Snackbar.open({ message: err.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: err.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
async removeRelays(): Promise<void> { async removeRelays(): Promise<void> {
await this.checkedRows.forEach((row: IFollower) => { await this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(`${row.targetActor.preferredUsername}@${row.targetActor.domain}`); this.removeRelay(
`${row.targetActor.preferredUsername}@${row.targetActor.domain}`
);
}); });
} }
@ -154,7 +194,11 @@ export default class Followings extends Mixins(RelayMixin) {
await this.$apollo.queries.relayFollowings.refetch(); await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = []; this.checkedRows = [];
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
} }

View File

@ -1,19 +1,34 @@
<template> <template>
<li :class="{ reply: comment.inReplyToComment }"> <li :class="{ reply: comment.inReplyToComment }">
<article class="media" :class="{ selected: commentSelected }" :id="commentId"> <article
class="media"
:class="{ selected: commentSelected }"
:id="commentId"
>
<popover-actor-card <popover-actor-card
class="media-left" class="media-left"
:actor="comment.actor" :actor="comment.actor"
:inline="true" :inline="true"
v-if="comment.actor" v-if="comment.actor"
> >
<figure class="image is-48x48" v-if="!comment.deletedAt && comment.actor.avatar"> <figure
class="image is-48x48"
v-if="!comment.deletedAt && comment.actor.avatar"
>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> <img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure> </figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
</popover-actor-card> </popover-actor-card>
<div v-else class="media-left"> <div v-else class="media-left">
<figure class="image is-48x48" v-if="!comment.deletedAt && comment.actor.avatar"> <figure
class="image is-48x48"
v-if="!comment.deletedAt && comment.actor.avatar"
>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> <img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure> </figure>
<b-icon v-else size="is-large" icon="account-circle" /> <b-icon v-else size="is-large" icon="account-circle" />
@ -21,7 +36,9 @@
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<span class="first-line" v-if="!comment.deletedAt"> <span class="first-line" v-if="!comment.deletedAt">
<strong :class="{ organizer: commentFromOrganizer }">{{ comment.actor.name }}</strong> <strong :class="{ organizer: commentFromOrganizer }">{{
comment.actor.name
}}</strong>
<small>@{{ usernameWithDomain(comment.actor) }}</small> <small>@{{ usernameWithDomain(comment.actor) }}</small>
<a class="comment-link has-text-grey" :href="commentURL"> <a class="comment-link has-text-grey" :href="commentURL">
<small>{{ <small>{{
@ -54,10 +71,15 @@
<div class="load-replies" v-if="comment.totalReplies"> <div class="load-replies" v-if="comment.totalReplies">
<p v-if="!showReplies" @click="fetchReplies"> <p v-if="!showReplies" @click="fetchReplies">
<b-icon icon="chevron-down" /><span>{{ <b-icon icon="chevron-down" /><span>{{
$tc("View a reply", comment.totalReplies, { totalReplies: comment.totalReplies }) $tc("View a reply", comment.totalReplies, {
totalReplies: comment.totalReplies,
})
}}</span> }}</span>
</p> </p>
<p v-else-if="comment.totalReplies && showReplies" @click="showReplies = false"> <p
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
>
<b-icon icon="chevron-up" /> <b-icon icon="chevron-up" />
<span>{{ $t("Hide replies") }}</span> <span>{{ $t("Hide replies") }}</span>
</p> </p>
@ -86,14 +108,24 @@
</nav> </nav>
</div> </div>
</article> </article>
<form class="reply" @submit.prevent="replyToComment" v-if="currentActor.id" v-show="replyTo"> <form
class="reply"
@submit.prevent="replyToComment"
v-if="currentActor.id"
v-show="replyTo"
>
<article class="media reply"> <article class="media reply">
<figure class="media-left" v-if="currentActor.avatar"> <figure class="media-left" v-if="currentActor.avatar">
<p class="image is-48x48"> <p class="image is-48x48">
<img :src="currentActor.avatar.url" alt="" /> <img :src="currentActor.avatar.url" alt="" />
</p> </p>
</figure> </figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<span class="first-line"> <span class="first-line">
@ -102,7 +134,12 @@
</span> </span>
<br /> <br />
<span class="editor-line"> <span class="editor-line">
<editor class="editor" ref="commentEditor" v-model="newComment.text" mode="comment" /> <editor
class="editor"
ref="commentEditor"
v-model="newComment.text"
mode="comment"
/>
<b-button <b-button
:disabled="newComment.text.trim().length === 0" :disabled="newComment.text.trim().length === 0"
native-type="submit" native-type="submit"
@ -118,7 +155,12 @@
<div class="left"> <div class="left">
<div class="vertical-border" @click="showReplies = false" /> <div class="vertical-border" @click="showReplies = false" />
</div> </div>
<transition-group name="comment-replies" v-if="showReplies" class="comment-replies" tag="ul"> <transition-group
name="comment-replies"
v-if="showReplies"
class="comment-replies"
tag="ul"
>
<comment <comment
class="reply" class="reply"
v-for="reply in comment.replies" v-for="reply in comment.replies"
@ -137,7 +179,7 @@ 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 { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { CommentModeration } from "../../types/event-options.model"; 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 { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor"; import { IPerson, usernameWithDomain } from "../../types/actor";
@ -155,7 +197,8 @@ import PopoverActorCard from "../Account/PopoverActorCard.vue";
}, },
}, },
components: { components: {
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"), editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"), comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
PopoverActorCard, PopoverActorCard,
}, },
@ -167,7 +210,9 @@ export default class Comment extends Vue {
// Hack because Vue only exports it's own interface. // Hack because Vue only exports it's own interface.
// See https://github.com/kaorun343/vue-property-decorator/issues/257 // See https://github.com/kaorun343/vue-property-decorator/issues/257
@Ref() readonly commentEditor!: EditorComponent & { replyToComment: (comment: IComment) => void }; @Ref() readonly commentEditor!: EditorComponent & {
replyToComment: (comment: IComment) => void;
};
currentActor!: IPerson; currentActor!: IPerson;
@ -231,7 +276,9 @@ export default class Comment extends Vue {
if (!eventData) return; if (!eventData) return;
const { event } = eventData; const { event } = eventData;
const { comments } = event; const { comments } = event;
const parentCommentIndex = comments.findIndex((oldComment) => oldComment.id === parentId); const parentCommentIndex = comments.findIndex(
(oldComment) => oldComment.id === parentId
);
const parentComment = comments[parentCommentIndex]; const parentComment = comments[parentCommentIndex];
if (!parentComment) return; if (!parentComment) return;
parentComment.replies = thread; parentComment.replies = thread;
@ -288,7 +335,6 @@ export default class Comment extends Vue {
mutation: CREATE_REPORT, mutation: CREATE_REPORT,
variables: { variables: {
eventId: this.event.id, eventId: this.event.id,
reporterId: this.currentActor.id,
reportedId: this.comment.actor.id, reportedId: this.comment.actor.id,
commentsIds: [this.comment.id], commentsIds: [this.comment.id],
content, content,
@ -304,7 +350,11 @@ export default class Comment extends Vue {
duration: 5000, duration: 5000,
}); });
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
} }

View File

@ -6,9 +6,11 @@
@submit.prevent="createCommentForEvent(newComment)" @submit.prevent="createCommentForEvent(newComment)"
@keyup.ctrl.enter="createCommentForEvent(newComment)" @keyup.ctrl.enter="createCommentForEvent(newComment)"
> >
<b-notification v-if="isEventOrganiser && !areCommentsClosed" :closable="false">{{ <b-notification
$t("Comments are closed for everybody else.") v-if="isEventOrganiser && !areCommentsClosed"
}}</b-notification> :closable="false"
>{{ $t("Comments are closed for everybody else.") }}</b-notification
>
<article class="media"> <article class="media">
<figure class="media-left"> <figure class="media-left">
<identity-picker-wrapper :inline="false" v-model="newComment.actor" /> <identity-picker-wrapper :inline="false" v-model="newComment.actor" />
@ -16,11 +18,20 @@
<div class="media-content"> <div class="media-content">
<div class="field"> <div class="field">
<p class="control"> <p class="control">
<editor ref="commenteditor" mode="comment" v-model="newComment.text" /> <editor
ref="commenteditor"
mode="comment"
v-model="newComment.text"
/>
</p> </p>
</div> </div>
<div class="send-comment"> <div class="send-comment">
<b-button native-type="submit" type="is-primary">{{ $t("Post a comment") }}</b-button> <b-button
native-type="submit"
type="is-primary"
class="comment-button-submit"
>{{ $t("Post a comment") }}</b-button
>
</div> </div>
</div> </div>
</article> </article>
@ -28,8 +39,19 @@
<b-notification v-else :closable="false">{{ <b-notification v-else :closable="false">{{
$t("The organiser has chosen to close comments.") $t("The organiser has chosen to close comments.")
}}</b-notification> }}</b-notification>
<transition name="comment-empty-list" mode="out-in"> <p
<transition-group name="comment-list" v-if="comments.length" class="comment-list" tag="ul"> v-if="$apollo.queries.comments.loading"
class="loading has-text-centered"
>
{{ $t("Loading comments…") }}
</p>
<transition name="comment-empty-list" mode="out-in" v-else>
<transition-group
name="comment-list"
v-if="comments.length"
class="comment-list"
tag="ul"
>
<comment <comment
class="root-comment" class="root-comment"
:comment="comment" :comment="comment"
@ -51,7 +73,7 @@
import { Prop, Vue, Component, Watch } from "vue-property-decorator"; 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/event-options.model"; import { CommentModeration } from "@/types/enums";
import { CommentModel, IComment } from "../../types/comment.model"; import { CommentModel, IComment } from "../../types/comment.model";
import { import {
CREATE_COMMENT_FROM_EVENT, CREATE_COMMENT_FROM_EVENT,
@ -76,7 +98,9 @@ import { IEvent } from "../../types/event.model";
}; };
}, },
update(data) { update(data) {
return data.event.comments.map((comment: IComment) => new CommentModel(comment)); return data.event.comments.map(
(comment: IComment) => new CommentModel(comment)
);
}, },
skip() { skip() {
return !this.event.uuid; return !this.event.uuid;
@ -86,7 +110,8 @@ import { IEvent } from "../../types/event.model";
components: { components: {
Comment, Comment,
IdentityPickerWrapper, IdentityPickerWrapper,
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"), editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
}, },
}) })
export default class CommentTree extends Vue { export default class CommentTree extends Vue {
@ -112,9 +137,10 @@ export default class CommentTree extends Vue {
mutation: CREATE_COMMENT_FROM_EVENT, mutation: CREATE_COMMENT_FROM_EVENT,
variables: { variables: {
eventId: this.event.id, eventId: this.event.id,
actorId: comment.actor.id,
text: comment.text, text: comment.text,
inReplyToCommentId: comment.inReplyToComment ? comment.inReplyToComment.id : null, inReplyToCommentId: comment.inReplyToComment
? comment.inReplyToComment.id
: null,
}, },
update: (store, { data }) => { update: (store, { data }) => {
if (data == null) return; if (data == null) return;
@ -204,7 +230,6 @@ export default class CommentTree extends Vue {
mutation: DELETE_COMMENT, mutation: DELETE_COMMENT,
variables: { variables: {
commentId: comment.id, commentId: comment.id,
actorId: this.currentActor.id,
}, },
update: (store, { data }) => { update: (store, { data }) => {
if (data == null) return; if (data == null) return;
@ -230,7 +255,9 @@ export default class CommentTree extends Vue {
}); });
if (!localData) return; if (!localData) return;
const { thread: oldReplyList } = localData; const { thread: oldReplyList } = localData;
const replies = oldReplyList.filter((reply) => reply.id !== deletedCommentId); const replies = oldReplyList.filter(
(reply) => reply.id !== deletedCommentId
);
store.writeQuery({ store.writeQuery({
query: FETCH_THREAD_REPLIES, query: FETCH_THREAD_REPLIES,
variables: { variables: {
@ -251,7 +278,9 @@ export default class CommentTree extends Vue {
event.comments = oldComments; event.comments = oldComments;
} else { } else {
// we have deleted a thread itself // we have deleted a thread itself
event.comments = oldComments.filter((reply) => reply.id !== deletedCommentId); event.comments = oldComments.filter(
(reply) => reply.id !== deletedCommentId
);
} }
store.writeQuery({ store.writeQuery({
query: COMMENTS_THREADS, query: COMMENTS_THREADS,
@ -276,14 +305,18 @@ export default class CommentTree extends Vue {
.filter((comment) => comment.inReplyToComment == null) .filter((comment) => comment.inReplyToComment == null)
.sort((a, b) => { .sort((a, b) => {
if (a.updatedAt && b.updatedAt) { if (a.updatedAt && b.updatedAt) {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
} }
return 0; return 0;
}); });
} }
get filteredOrderedComments(): IComment[] { get filteredOrderedComments(): IComment[] {
return this.orderedComments.filter((comment) => !comment.deletedAt || comment.totalReplies > 0); return this.orderedComments.filter(
(comment) => !comment.deletedAt || comment.totalReplies > 0
);
} }
get isEventOrganiser(): boolean { get isEventOrganiser(): boolean {
@ -302,7 +335,7 @@ export default class CommentTree extends Vue {
} }
get isAbleToComment(): boolean { get isAbleToComment(): boolean {
if (this.currentActor.id) { if (this.currentActor && this.currentActor.id) {
return this.areCommentsClosed || this.isEventOrganiser; return this.areCommentsClosed || this.isEventOrganiser;
} }
return false; return false;

View File

@ -15,7 +15,10 @@
<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>
<span class="icons" v-if="!comment.deletedAt && comment.actor.id === currentActor.id"> <span
class="icons"
v-if="!comment.deletedAt && comment.actor.id === currentActor.id"
>
<b-dropdown aria-role="list"> <b-dropdown aria-role="list">
<b-icon slot="trigger" role="button" icon="dots-horizontal" /> <b-icon slot="trigger" role="button" icon="dots-horizontal" />
@ -44,8 +47,9 @@
<div class="post-infos"> <div class="post-infos">
<span :title="comment.insertedAt | formatDateTimeString"> <span :title="comment.insertedAt | formatDateTimeString">
{{ {{
formatDistanceToNow(new Date(comment.updatedAt), { locale: $dateFnsLocale }) || formatDistanceToNow(new Date(comment.updatedAt), {
$t("Right now") locale: $dateFnsLocale,
}) || $t("Right now")
}}</span }}</span
> >
</div> </div>
@ -77,7 +81,9 @@
type="is-primary" type="is-primary"
>{{ $t("Update") }}</b-button >{{ $t("Update") }}</b-button
> >
<b-button native-type="button" @click="toggleEditMode">{{ $t("Cancel") }}</b-button> <b-button native-type="button" @click="toggleEditMode">{{
$t("Cancel")
}}</b-button>
</div> </div>
</form> </form>
</div> </div>
@ -95,7 +101,8 @@ import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
currentActor: CURRENT_ACTOR_CLIENT, currentActor: CURRENT_ACTOR_CLIENT,
}, },
components: { components: {
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"), editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
}, },
}) })
export default class DiscussionComment extends Vue { export default class DiscussionComment extends Vue {

View File

@ -1,14 +1,23 @@
<template> <template>
<router-link <router-link
class="discussion-minimalist-card-wrapper" class="discussion-minimalist-card-wrapper"
:to="{ name: RouteName.DISCUSSION, params: { slug: discussion.slug, id: discussion.id } }" :to="{
name: RouteName.DISCUSSION,
params: { slug: discussion.slug, id: discussion.id },
}"
> >
<div class="media-left"> <div class="media-left">
<figure <figure
class="image is-32x32" class="image is-32x32"
v-if="discussion.lastComment.actor && discussion.lastComment.actor.avatar" v-if="
discussion.lastComment.actor && discussion.lastComment.actor.avatar
"
> >
<img class="is-rounded" :src="discussion.lastComment.actor.avatar.url" alt /> <img
class="is-rounded"
:src="discussion.lastComment.actor.avatar.url"
alt
/>
</figure> </figure>
<b-icon v-else size="is-medium" icon="account-circle" /> <b-icon v-else size="is-medium" icon="account-circle" />
</div> </div>
@ -17,15 +26,18 @@
<p class="discussion-minimalist-title">{{ discussion.title }}</p> <p class="discussion-minimalist-title">{{ discussion.title }}</p>
<span :title="actualDate | formatDateTimeString"> <span :title="actualDate | formatDateTimeString">
{{ {{
formatDistanceToNowStrict(new Date(actualDate), { locale: $dateFnsLocale }) || formatDistanceToNowStrict(new Date(actualDate), {
$t("Right now") locale: $dateFnsLocale,
}) || $t("Right now")
}}</span }}</span
> >
</div> </div>
<div class="has-text-grey" v-if="!discussion.lastComment.deletedAt"> <div class="has-text-grey" v-if="!discussion.lastComment.deletedAt">
{{ htmlTextEllipsis }} {{ htmlTextEllipsis }}
</div> </div>
<div v-else class="has-text-grey">{{ $t("[This comment has been deleted]") }}</div> <div v-else class="has-text-grey">
{{ $t("[This comment has been deleted]") }}
</div>
</div> </div>
</router-link> </router-link>
</template> </template>
@ -54,7 +66,10 @@ export default class DiscussionListItem extends Vue {
} }
get actualDate(): string | Date | undefined { get actualDate(): string | Date | undefined {
if (this.discussion.updatedAt === this.discussion.insertedAt && this.discussion.lastComment) { if (
this.discussion.updatedAt === this.discussion.insertedAt &&
this.discussion.lastComment
) {
return this.discussion.lastComment.publishedAt; return this.discussion.lastComment.publishedAt;
} }
return this.discussion.updatedAt; return this.discussion.updatedAt;
@ -83,7 +98,8 @@ export default class DiscussionListItem extends Vue {
.discussion-minimalist-title { .discussion-minimalist-title {
color: #3c376e; color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica,
Arial, serif;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
flex: 1; flex: 1;

View File

@ -117,11 +117,21 @@
<b-icon icon="format-quote-close" /> <b-icon icon="format-quote-close" />
</button> </button>
<button v-if="!isBasicMode" class="menubar__button" @click="commands.undo" type="button"> <button
v-if="!isBasicMode"
class="menubar__button"
@click="commands.undo"
type="button"
>
<b-icon icon="undo" /> <b-icon icon="undo" />
</button> </button>
<button v-if="!isBasicMode" class="menubar__button" @click="commands.redo" type="button"> <button
v-if="!isBasicMode"
class="menubar__button"
@click="commands.redo"
type="button"
>
<b-icon icon="redo" /> <b-icon icon="redo" />
</button> </button>
</div> </div>
@ -181,7 +191,9 @@
</div> </div>
</div> </div>
</template> </template>
<div v-else class="suggestion-list__item is-empty">{{ $t("No profiles found") }}</div> <div v-else class="suggestion-list__item is-empty">
{{ $t("No profiles found") }}
</div>
</div> </div>
</div> </div>
</template> </template>
@ -212,7 +224,7 @@ import { SEARCH_PERSONS } from "../graphql/search";
import { Actor, IActor, IPerson } from "../types/actor"; import { Actor, IActor, IPerson } from "../types/actor";
import Image from "./Editor/Image"; import Image from "./Editor/Image";
import MaxSize from "./Editor/MaxSize"; import MaxSize from "./Editor/MaxSize";
import { UPLOAD_PICTURE } 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 { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IComment } from "../types/comment.model"; import { IComment } from "../types/comment.model";
@ -372,7 +384,7 @@ export default class EditorComponent extends Vue {
searchText: query, searchText: query,
}, },
}); });
// TODO: TipTap doesn't handle async for onFilter, hence the following line. // TipTap doesn't handle async for onFilter, hence the following line.
this.filteredActors = result.data.searchPersons.elements; this.filteredActors = result.data.searchPersons.elements;
return this.filteredActors; return this.filteredActors;
}, },
@ -395,6 +407,7 @@ export default class EditorComponent extends Vue {
new Image(), new Image(),
new MaxSize({ maxSize: this.maxSize }), new MaxSize({ maxSize: this.maxSize }),
], ],
// eslint-disable-next-line @typescript-eslint/ban-types
onUpdate: ({ getHTML }: { getHTML: Function }) => { onUpdate: ({ getHTML }: { getHTML: Function }) => {
this.$emit("input", getHTML()); this.$emit("input", getHTML());
}, },
@ -410,6 +423,7 @@ export default class EditorComponent extends Vue {
} }
} }
// eslint-disable-next-line @typescript-eslint/ban-types
showLinkMenu(command: Function, active: boolean): Function | undefined { showLinkMenu(command: Function, active: boolean): Function | undefined {
if (!this.editor) return undefined; if (!this.editor) return undefined;
if (active) return command({ href: null }); if (active) return command({ href: null });
@ -430,7 +444,8 @@ export default class EditorComponent extends Vue {
upHandler(): void { upHandler(): void {
this.navigatedActorIndex = this.navigatedActorIndex =
(this.navigatedActorIndex + this.filteredActors.length - 1) % this.filteredActors.length; (this.navigatedActorIndex + this.filteredActors.length - 1) %
this.filteredActors.length;
} }
/** /**
@ -438,7 +453,8 @@ export default class EditorComponent extends Vue {
* if it's the last item, navigate to the first one * if it's the last item, navigate to the first one
*/ */
downHandler(): void { downHandler(): void {
this.navigatedActorIndex = (this.navigatedActorIndex + 1) % this.filteredActors.length; this.navigatedActorIndex =
(this.navigatedActorIndex + 1) % this.filteredActors.length;
} }
enterHandler(): void { enterHandler(): void {
@ -522,19 +538,22 @@ export default class EditorComponent extends Vue {
* Show a file prompt, upload picture and insert it into editor * Show a file prompt, upload picture and insert it into editor
* @param command * @param command
*/ */
// eslint-disable-next-line @typescript-eslint/ban-types
async showImagePrompt(command: Function): Promise<void> { async showImagePrompt(command: Function): Promise<void> {
const image = await listenFileUpload(); const image = await listenFileUpload();
try { try {
const { data } = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: UPLOAD_PICTURE, mutation: UPLOAD_MEDIA,
variables: { variables: {
file: image, file: image,
name: image.name, name: image.name,
actorId: this.currentActor.id,
}, },
}); });
if (data.uploadPicture && data.uploadPicture.url) { if (data.uploadMedia && data.uploadMedia.url) {
command({ src: data.uploadPicture.url }); command({
src: data.uploadMedia.url,
"data-media-id": data.uploadMedia.id,
});
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -1,6 +1,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck // @ts-nocheck
import { Node } from "tiptap"; import { Node } from "tiptap";
import { UPLOAD_PICTURE } from "@/graphql/upload"; import { UPLOAD_MEDIA } from "@/graphql/upload";
import apolloProvider from "@/vue-apollo"; import apolloProvider from "@/vue-apollo";
import ApolloClient from "apollo-client"; import ApolloClient from "apollo-client";
import { NormalizedCacheObject } from "apollo-cache-inmemory"; import { NormalizedCacheObject } from "apollo-cache-inmemory";
@ -12,6 +13,7 @@ import { EditorView } from "prosemirror-view";
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
export default class Image extends Node { export default class Image extends Node {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get name() { get name() {
return "image"; return "image";
} }
@ -27,16 +29,18 @@ export default class Image extends Node {
title: { title: {
default: null, default: null,
}, },
"data-media-id": {},
}, },
group: "inline", group: "inline",
draggable: true, draggable: true,
parseDOM: [ parseDOM: [
{ {
tag: "img[src]", tag: "img",
getAttrs: (dom: any) => ({ getAttrs: (dom: any) => ({
src: dom.getAttribute("src"), src: dom.getAttribute("src"),
title: dom.getAttribute("title"), title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"), alt: dom.getAttribute("alt"),
"data-media-id": dom.getAttribute("data-media-id"),
}), }),
}, },
], ],
@ -45,15 +49,21 @@ export default class Image extends Node {
} }
commands({ type }: { type: NodeType }): any { commands({ type }: { type: NodeType }): any {
return (attrs: { [key: string]: string }) => (state: EditorState, dispatch: DispatchFn) => { return (attrs: { [key: string]: string }) => (
state: EditorState,
dispatch: DispatchFn
) => {
const { selection }: { selection: TextSelection } = state; const { selection }: { selection: TextSelection } = state;
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos; const position = selection.$cursor
? selection.$cursor.pos
: selection.$to.pos;
const node = type.create(attrs); const node = type.create(attrs);
const transaction = state.tr.insert(position, node); const transaction = state.tr.insert(position, node);
dispatch(transaction); dispatch(transaction);
}; };
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get plugins() { get plugins() {
return [ return [
new Plugin({ new Plugin({
@ -72,7 +82,8 @@ export default class Image extends Node {
} }
const images = Array.from(realEvent.dataTransfer.files).filter( const images = Array.from(realEvent.dataTransfer.files).filter(
(file: any) => /image/i.test(file.type) && !/svg/i.test(file.type) (file: any) =>
/image/i.test(file.type) && !/svg/i.test(file.type)
); );
if (images.length === 0) { if (images.length === 0) {
@ -88,21 +99,24 @@ export default class Image extends Node {
}); });
if (!coordinates) return false; if (!coordinates) return false;
const client = apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>; const client = apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
const editorElem = document.getElementById("tiptab-editor");
const actorId = editorElem && editorElem.dataset.actorId;
try { try {
images.forEach(async (image) => { images.forEach(async (image) => {
const { data } = await client.mutate({ const { data } = await client.mutate({
mutation: UPLOAD_PICTURE, mutation: UPLOAD_MEDIA,
variables: { variables: {
actorId,
file: image, file: image,
name: image.name, name: image.name,
}, },
}); });
const node = schema.nodes.image.create({ src: data.uploadPicture.url }); const node = schema.nodes.image.create({
const transaction = view.state.tr.insert(coordinates.pos, node); src: data.uploadMedia.url,
"data-media-id": data.uploadMedia.id,
});
const transaction = view.state.tr.insert(
coordinates.pos,
node
);
view.dispatch(transaction); view.dispatch(transaction);
}); });
return true; return true;

View File

@ -1,17 +1,23 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck // @ts-nocheck
import { Extension, Plugin } from "tiptap"; import { Extension, Plugin } from "tiptap";
export default class MaxSize extends Extension { export default class MaxSize extends Extension {
// eslint-disable-next-line class-methods-use-this
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get name() { get name() {
return "maxSize"; return "maxSize";
} }
// eslint-disable-next-line class-methods-use-this
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get defaultOptions() { get defaultOptions() {
return { return {
maxSize: null, maxSize: null,
}; };
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get plugins() { get plugins() {
return [ return [
new Plugin({ new Plugin({
@ -21,7 +27,7 @@ export default class MaxSize extends Extension {
const newLength = newState.doc.content.size; const newLength = newState.doc.content.size;
if (newLength > max && newLength > oldLength) { if (newLength > max && newLength > oldLength) {
let newTr = newState.tr; const newTr = newState.tr;
newTr.insertText("", max + 1, newLength); newTr.insertText("", max + 1, newLength);
return newTr; return newTr;

View File

@ -21,9 +21,13 @@
</b-autocomplete> </b-autocomplete>
</b-field> </b-field>
<b-field v-if="isSecureContext()"> <b-field v-if="isSecureContext()">
<b-button type="is-text" v-if="!gettingLocation" icon-right="target" @click="locateMe">{{ <b-button
$t("Use my location") type="is-text"
}}</b-button> v-if="!gettingLocation"
icon-right="target"
@click="locateMe"
>{{ $t("Use my location") }}</b-button
>
<span v-else>{{ $t("Getting location") }}</span> <span v-else>{{ $t("Getting location") }}</span>
</b-field> </b-field>
<!-- <!--
@ -50,7 +54,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { debounce } from "lodash"; import { debounce, DebouncedFunc } from "lodash";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address"; import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
@ -58,7 +62,8 @@ import { IConfig } from "../../types/config.model";
@Component({ @Component({
components: { components: {
"map-leaflet": () => import(/* webpackChunkName: "map" */ "@/components/Map.vue"), "map-leaflet": () =>
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
}, },
apollo: { apollo: {
config: CONFIG, config: CONFIG,
@ -81,7 +86,8 @@ export default class AddressAutoComplete extends Vue {
private gettingLocation = false; private gettingLocation = false;
private location!: Position; // eslint-disable-next-line no-undef
private location!: GeolocationPosition;
private gettingLocationError: any; private gettingLocationError: any;
@ -89,7 +95,7 @@ export default class AddressAutoComplete extends Vue {
config!: IConfig; config!: IConfig;
fetchAsyncData!: Function; fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
// We put this in data because of issues like // We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263 // https://github.com/vuejs/vue-class-component/issues/263
@ -121,7 +127,9 @@ export default class AddressAutoComplete extends Vue {
}, },
}); });
this.addressData = result.data.searchAddress.map((address: IAddress) => new Address(address)); this.addressData = result.data.searchAddress.map(
(address: IAddress) => new Address(address)
);
this.isFetching = false; this.isFetching = false;
} }
@ -174,7 +182,9 @@ export default class AddressAutoComplete extends Vue {
}, },
}); });
this.addressData = result.data.reverseGeocode.map((address: IAddress) => new Address(address)); this.addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (this.addressData.length > 0) { if (this.addressData.length > 0) {
const defaultAddress = new Address(this.addressData[0]); const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress; this.selected = defaultAddress;
@ -197,7 +207,10 @@ export default class AddressAutoComplete extends Vue {
this.location = await AddressAutoComplete.getLocation(); this.location = await AddressAutoComplete.getLocation();
this.mapDefaultZoom = 12; this.mapDefaultZoom = 12;
this.reverseGeoCode( this.reverseGeoCode(
new LatLng(this.location.coords.latitude, this.location.coords.longitude), new LatLng(
this.location.coords.latitude,
this.location.coords.longitude
),
12 12
); );
} catch (e) { } catch (e) {
@ -207,7 +220,8 @@ export default class AddressAutoComplete extends Vue {
this.gettingLocation = false; this.gettingLocation = false;
} }
static async getLocation(): Promise<Position> { // eslint-disable-next-line no-undef
static async getLocation(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) { if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available.")); reject(new Error("Geolocation is not available."));

View File

@ -1,141 +0,0 @@
<docs>
### Datetime Picker
> We're wrapping the Buefy datepicker & an input
### Defaults
- step: 10
### Example
```vue
<DateTimePicker :value="new Date()" />
```
</docs>
<template>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">{{ label }}</label>
</div>
<div class="field-body">
<div class="field is-narrow is-grouped calendar-picker">
<b-datepicker
:day-names="localeShortWeekDayNamesProxy"
:month-names="localeMonthNamesProxy"
:first-day-of-week="parseInt($t('firstDayOfWeek'), 10)"
:min-date="minDatetime"
:max-date="maxDatetime"
v-model="dateWithTime"
:placeholder="$t('Click to select')"
:years-range="[-2, 10]"
icon="calendar"
class="is-narrow"
/>
<b-timepicker
placeholder="Type or select a time..."
icon="clock"
v-model="dateWithTime"
:min-time="minTime"
:max-time="maxTime"
size="is-small"
inline
>
</b-timepicker>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { localeMonthNames, localeShortWeekDayNames } from "@/utils/datetime";
@Component
export default class DateTimePicker extends Vue {
/**
* @model
* The DateTime value
*/
@Prop({ required: true, type: Date, default: () => new Date() }) value!: Date;
/**
* What's shown besides the picker
*/
@Prop({ required: false, type: String, default: "Datetime" }) label!: string;
/**
* The step for the time input
*/
@Prop({ required: false, type: Number, default: 1 }) step!: number;
/**
* Earliest date available for selection
*/
@Prop({ required: false, type: Date, default: null }) minDatetime!: Date;
/**
* Latest date available for selection
*/
@Prop({ required: false, type: Date, default: null }) maxDatetime!: Date;
dateWithTime: Date = this.value;
localeShortWeekDayNamesProxy = localeShortWeekDayNames();
localeMonthNamesProxy = localeMonthNames();
@Watch("value")
updateValue(): void {
this.dateWithTime = this.value;
}
@Watch("dateWithTime")
updateDateWithTimeWatcher(): void {
this.updateDateTime();
}
updateDateTime(): void {
/**
* Returns the updated date
*
* @type {Date}
*/
this.$emit("input", this.dateWithTime);
}
get minTime(): Date | null {
if (this.minDatetime && this.datesAreOnSameDay(this.dateWithTime, this.minDatetime)) {
return this.minDatetime;
}
return null;
}
get maxTime(): Date | null {
if (this.maxDatetime && this.datesAreOnSameDay(this.dateWithTime, this.maxDatetime)) {
return this.maxDatetime;
}
return null;
}
// eslint-disable-next-line class-methods-use-this
private datesAreOnSameDay(first: Date, second: Date): boolean {
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
}
}
</script>
<style lang="scss" scoped>
.timepicker {
::v-deep .dropdown-content {
padding: 0;
}
}
.calendar-picker {
::v-deep .dropdown-menu {
z-index: 200;
}
}
</style>

View File

@ -1,5 +1,8 @@
<template> <template>
<router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }"> <router-link
class="card"
:to="{ name: 'Event', params: { uuid: event.uuid } }"
>
<div class="card-image"> <div class="card-image">
<figure <figure
class="image is-16by9" class="image is-16by9"
@ -7,10 +10,19 @@
event.picture ? event.picture.url : '/img/mobilizon_default_card.png' event.picture ? event.picture.url : '/img/mobilizon_default_card.png'
}')`" }')`"
> >
<div class="tag-container" v-if="event.tags"> <div
class="tag-container"
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
>
<b-tag type="is-info" v-if="event.status === EventStatus.TENTATIVE">
{{ $t("Tentative") }}
</b-tag>
<b-tag type="is-danger" v-if="event.status === EventStatus.CANCELLED">
{{ $t("Cancelled") }}
</b-tag>
<router-link <router-link
:to="{ name: RouteName.TAG, params: { tag: tag.title } }" :to="{ name: RouteName.TAG, params: { tag: tag.title } }"
v-for="tag in event.tags.slice(0, 3)" v-for="tag in (event.tags || []).slice(0, 3)"
:key="tag.slug" :key="tag.slug"
> >
<b-tag type="is-light">{{ tag.title }}</b-tag> <b-tag type="is-light">{{ tag.title }}</b-tag>
@ -21,14 +33,18 @@
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" /> <date-calendar-icon
v-if="!mergedOptions.hideDate"
:date="event.beginsOn"
/>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="event-title">{{ event.title }}</p> <p class="event-title">{{ event.title }}</p>
<div class="event-subtitle" v-if="event.physicalAddress"> <div class="event-subtitle" v-if="event.physicalAddress">
<!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>--> <!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>-->
<span> <span>
{{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }} {{ event.physicalAddress.description }},
{{ event.physicalAddress.locality }}
</span> </span>
</div> </div>
</div> </div>
@ -77,7 +93,7 @@ import { IEvent, IEventCardOptions } from "@/types/event.model";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { Actor, Person } from "@/types/actor"; import { Actor, Person } from "@/types/actor";
import { ParticipantRole } from "../../types/participant.model"; import { EventStatus, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -92,6 +108,8 @@ export default class EventCard extends Vue {
ParticipantRole = ParticipantRole; ParticipantRole = ParticipantRole;
EventStatus = EventStatus;
RouteName = RouteName; RouteName = RouteName;
defaultOptions: IEventCardOptions = { defaultOptions: IEventCardOptions = {

View File

@ -18,7 +18,9 @@
</docs> </docs>
<template> <template>
<span v-if="!endsOn">{{ beginsOn | formatDateTimeString(showStartTime) }}</span> <span v-if="!endsOn">{{
beginsOn | formatDateTimeString(showStartTime)
}}</span>
<span v-else-if="isSameDay() && showStartTime && showEndTime"> <span v-else-if="isSameDay() && showStartTime && showEndTime">
{{ {{
$t("On {date} from {startTime} to {endTime}", { $t("On {date} from {startTime} to {endTime}", {
@ -44,7 +46,9 @@
}) })
}} }}
</span> </span>
<span v-else-if="isSameDay()">{{ $t("On {date}", { date: formatDate(beginsOn) }) }}</span> <span v-else-if="isSameDay()">{{
$t("On {date}", { date: formatDate(beginsOn) })
}}</span>
<span v-else-if="endsOn && showStartTime && showEndTime"> <span v-else-if="endsOn && showStartTime && showEndTime">
{{ {{
$t("From the {startDate} at {startTime} to the {endDate} at {endTime}", { $t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
@ -97,7 +101,9 @@ export default class EventFullDate extends Vue {
} }
isSameDay(): boolean { isSameDay(): boolean {
const sameDay = new Date(this.beginsOn).toDateString() === new Date(this.endsOn).toDateString(); const sameDay =
new Date(this.beginsOn).toDateString() ===
new Date(this.endsOn).toDateString();
return this.endsOn !== undefined && sameDay; return this.endsOn !== undefined && sameDay;
} }
} }

View File

@ -1,38 +1,49 @@
<template> <template>
<article class="box"> <article class="box">
<div class="columns"> <div class="identity-header">
<div class="content column"> <figure class="image is-24x24" v-if="participation.actor.avatar">
<img class="is-rounded" :src="participation.actor.avatar.url" alt="" />
</figure>
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card">
<div class="content">
<div class="title-wrapper"> <div class="title-wrapper">
<div class="columns"> <div class="date-component">
<div class="column is-narrow"> <date-calendar-icon :date="participation.event.beginsOn" />
<div class="date-component">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
</div>
<div class="column">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }"
>
<h3 class="title event-title-card">{{ participation.event.title }}</h3>
</router-link>
</div>
</div> </div>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<h3 class="title">{{ participation.event.title }}</h3>
</router-link>
</div> </div>
<div class="participation-actor has-text-grey"> <div class="participation-actor has-text-grey">
<span> <span>
<b-icon icon="earth" v-if="participation.event.visibility === EventVisibility.PUBLIC" /> <b-icon
icon="earth"
v-if="participation.event.visibility === EventVisibility.PUBLIC"
/>
<b-icon <b-icon
icon="link" icon="link"
v-else-if="participation.event.visibility === EventVisibility.UNLISTED" v-else-if="
participation.event.visibility === EventVisibility.UNLISTED
"
/> />
<b-icon <b-icon
icon="lock" icon="lock"
v-else-if="participation.event.visibility === EventVisibility.PRIVATE" v-else-if="
participation.event.visibility === EventVisibility.PRIVATE
"
/> />
</span> </span>
<span <span
v-if=" v-if="
participation.event.physicalAddress && participation.event.physicalAddress.locality participation.event.physicalAddress &&
participation.event.physicalAddress.locality
" "
>{{ participation.event.physicalAddress.locality }} -</span >{{ participation.event.physicalAddress.locality }} -</span
> >
@ -40,19 +51,10 @@
<i18n tag="span" path="Organized by {name}"> <i18n tag="span" path="Organized by {name}">
<popover-actor-card <popover-actor-card
slot="name" slot="name"
:actor="participation.event.organizerActor" :actor="organizerActor"
:inline="true" :inline="true"
> >
{{ participation.event.organizerActor.displayName() }} {{ organizerActor.displayName() }}
</popover-actor-card>
</i18n>
<i18n
v-if="participation.role === ParticipantRole.PARTICIPANT"
path="Going as {name}"
tag="span"
>
<popover-actor-card slot="name" :actor="participation.actor" :inline="true">
{{ participation.actor.displayName() }}
</popover-actor-card> </popover-actor-card>
</i18n> </i18n>
</span> </span>
@ -61,12 +63,15 @@
<span <span
class="participant-stats" class="participant-stats"
v-if=" v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes( ![
participation.role ParticipantRole.PARTICIPANT,
) ParticipantRole.NOT_APPROVED,
].includes(participation.role)
" "
> >
<span v-if="participation.event.options.maximumAttendeeCapacity !== 0"> <span
v-if="participation.event.options.maximumAttendeeCapacity !== 0"
>
{{ {{
$tc( $tc(
"{available}/{capacity} available places", "{available}/{capacity} available places",
@ -76,16 +81,21 @@
available: available:
participation.event.options.maximumAttendeeCapacity - participation.event.options.maximumAttendeeCapacity -
participation.event.participantStats.participant, participation.event.participantStats.participant,
capacity: participation.event.options.maximumAttendeeCapacity, capacity:
participation.event.options.maximumAttendeeCapacity,
} }
) )
}} }}
</span> </span>
<span v-else> <span v-else>
{{ {{
$tc("{count} participants", participation.event.participantStats.participant, { $tc(
count: participation.event.participantStats.participant, "{count} participants",
}) participation.event.participantStats.participant,
{
count: participation.event.participantStats.participant,
}
)
}} }}
</span> </span>
<span v-if="participation.event.participantStats.notApproved > 0"> <span v-if="participation.event.participantStats.notApproved > 0">
@ -111,80 +121,90 @@
</span> </span>
</div> </div>
</div> </div>
<div class="actions column is-narrow"> <div class="actions">
<ul> <b-dropdown aria-role="list" position="is-bottom-left">
<li <b-button slot="trigger" role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
v-if=" v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes( ![
participation.role ParticipantRole.PARTICIPANT,
) ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
@click="
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT,
params: { eventId: participation.event.uuid },
})
" "
> >
<b-button <b-icon icon="pencil" />
type="is-text" {{ $t("Edit") }}
@click=" </b-dropdown-item>
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT, <b-dropdown-item
params: { eventId: participation.event.uuid }, v-if="participation.role === ParticipantRole.CREATOR"
}) aria-role="listitem"
" @click="
icon-left="pencil" gotToWithCheck(participation, {
>{{ $t("Edit") }} name: RouteName.DUPLICATE_EVENT,
</b-button> params: { eventId: participation.event.uuid },
</li> })
<li v-if="participation.role === ParticipantRole.CREATOR">
<b-button
type="is-text"
@click="
gotToWithCheck(participation, {
name: RouteName.DUPLICATE_EVENT,
params: { eventId: participation.event.uuid },
})
"
icon-left="content-duplicate"
>
{{ $t("Duplicate") }}
</b-button>
</li>
<li
v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(
participation.role
)
" "
>
<b-icon icon="content-duplicate" />
{{ $t("Duplicate") }}
</b-dropdown-item>
<b-dropdown-item
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
@click="openDeleteEventModalWrapper" @click="openDeleteEventModalWrapper"
> >
<b-button type="is-text" icon-left="delete">{{ $t("Delete") }} </b-button> <b-icon icon="delete" />
</li> {{ $t("Delete") }}
<li </b-dropdown-item>
<b-dropdown-item
v-if=" v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes( ![
participation.role ParticipantRole.PARTICIPANT,
) ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
params: { eventId: participation.event.uuid },
})
" "
> >
<b-button <b-icon icon="account-multiple-plus" />
type="is-text" {{ $t("Manage participations") }}
@click=" </b-dropdown-item>
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS, <b-dropdown-item aria-role="listitem" has-link>
params: { eventId: participation.event.uuid }, <router-link
}) :to="{
" name: RouteName.EVENT,
icon-left="account-multiple-plus" params: { uuid: participation.event.uuid },
>{{ $t("Manage participations") }} }"
</b-button> >
</li> <b-icon icon="view-compact" />
<li> {{ $t("View event page") }}
<b-button </router-link>
tag="router-link" </b-dropdown-item>
icon-left="view-compact" </b-dropdown>
type="is-text"
:to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }"
>{{ $t("View event page") }}
</b-button>
</li>
</ul>
</div> </div>
</div> </div>
</article> </article>
@ -195,9 +215,10 @@ import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { RawLocation, Route } from "vue-router"; import { RawLocation, Route } from "vue-router";
import { IParticipant, ParticipantRole } from "../../types/participant.model"; import { EventVisibility, ParticipantRole } from "@/types/enums";
import { EventVisibility, IEventCardOptions } from "../../types/event.model"; import { IParticipant } from "../../types/participant.model";
import { IPerson } from "../../types/actor"; import { IEventCardOptions } from "../../types/event.model";
import { displayNameAndUsername, IActor, IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor"; import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import EventMixin from "../../mixins/event"; import EventMixin from "../../mixins/event";
@ -242,6 +263,8 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
displayNameAndUsername = displayNameAndUsername;
RouteName = RouteName; RouteName = RouteName;
get mergedOptions(): IEventCardOptions { get mergedOptions(): IEventCardOptions {
@ -254,11 +277,17 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
async openDeleteEventModalWrapper(): Promise<void> { async openDeleteEventModalWrapper(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
await this.openDeleteEventModal(this.participation.event, this.currentActor); await this.openDeleteEventModal(this.participation.event);
} }
async gotToWithCheck(participation: IParticipant, route: RawLocation): Promise<Route> { async gotToWithCheck(
if (participation.actor.id !== this.currentActor.id && participation.event.organizerActor) { participation: IParticipant,
route: RawLocation
): Promise<Route> {
if (
participation.actor.id !== this.currentActor.id &&
participation.event.organizerActor
) {
const organizer = participation.event.organizerActor as IPerson; const organizer = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizer); await changeIdentity(this.$apollo.provider.defaultClient, organizer);
this.$buefy.notification.open({ this.$buefy.notification.open({
@ -275,6 +304,16 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
} }
return this.$router.push(route); return this.$router.push(route);
} }
get organizerActor(): IActor | undefined {
if (
this.participation.event.attributedTo &&
this.participation.event.attributedTo.id
) {
return this.participation.event.attributedTo;
}
return this.participation.event.organizerActor;
}
} }
</script> </script>
@ -310,6 +349,34 @@ article.box {
.actions { .actions {
padding-right: 7.5px; padding-right: 7.5px;
cursor: pointer; cursor: pointer;
ul li {
margin: 0 auto;
.is-link {
cursor: pointer;
}
.button.is-text {
text-decoration: none;
::v-deep span:first-child i.mdi::before {
font-size: 24px !important;
}
::v-deep span:last-child {
padding-left: 4px;
}
&:hover {
background: #f5f5f5;
}
}
* {
font-size: 0.8rem;
color: $background-color;
}
}
} }
div.content { div.content {
@ -352,38 +419,22 @@ article.box {
} }
} }
} }
}
.actions { .identity-header {
ul li { background: $yellow-2;
margin: 0 auto; display: flex;
padding: 5px;
.is-link { figure {
cursor: pointer; padding-right: 3px;
}
.button.is-text {
text-decoration: none;
::v-deep span:first-child i.mdi::before {
font-size: 24px !important;
}
::v-deep span:last-child {
padding-left: 4px;
}
&:hover {
background: #f5f5f5;
}
}
* {
font-size: 0.8rem;
color: $background-color;
}
}
} }
} }
& > .columns {
padding: 1.25rem;
}
padding: 0;
} }
.content h3.event-title-card { .content h3.event-title-card {
line-height: 1em; line-height: 1em;

View File

@ -6,7 +6,9 @@
<div class="date-component"> <div class="date-component">
<date-calendar-icon :date="event.beginsOn" /> <date-calendar-icon :date="event.beginsOn" />
</div> </div>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"> <router-link
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<h2 class="title">{{ event.title }}</h2> <h2 class="title">{{ event.title }}</h2>
</router-link> </router-link>
</div> </div>
@ -15,17 +17,34 @@
{{ event.physicalAddress.locality }} {{ event.physicalAddress.locality }}
</span> </span>
<span v-if="event.attributedTo && options.memberofGroup"> <span v-if="event.attributedTo && options.memberofGroup">
{{ $t("Created by {name}", { name: usernameWithDomain(event.organizerActor) }) }} {{
$t("Created by {name}", {
name: usernameWithDomain(event.organizerActor),
})
}}
</span> </span>
<span v-else-if="options.memberofGroup"> <span v-else-if="options.memberofGroup">
{{ $t("Organized by {name}", { name: usernameWithDomain(event.organizerActor) }) }} {{
$t("Organized by {name}", {
name: usernameWithDomain(event.organizerActor),
})
}}
</span> </span>
</div> </div>
<div class="columns"> <div class="columns">
<span class="column is-narrow"> <span class="column is-narrow">
<b-icon icon="earth" v-if="event.visibility === EventVisibility.PUBLIC" /> <b-icon
<b-icon icon="link" v-if="event.visibility === EventVisibility.UNLISTED" /> icon="earth"
<b-icon icon="lock" v-if="event.visibility === EventVisibility.PRIVATE" /> v-if="event.visibility === EventVisibility.PUBLIC"
/>
<b-icon
icon="link"
v-if="event.visibility === EventVisibility.UNLISTED"
/>
<b-icon
icon="lock"
v-if="event.visibility === EventVisibility.PRIVATE"
/>
</span> </span>
<span class="column is-narrow participant-stats"> <span class="column is-narrow participant-stats">
<span v-if="event.options.maximumAttendeeCapacity !== 0"> <span v-if="event.options.maximumAttendeeCapacity !== 0">
@ -38,9 +57,13 @@
</span> </span>
<span v-else> <span v-else>
{{ {{
$tc("{count} participants", event.participantStats.participant, { $tc(
count: event.participantStats.participant, "{count} participants",
}) event.participantStats.participant,
{
count: event.participantStats.participant,
}
)
}} }}
</span> </span>
</span> </span>
@ -51,7 +74,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model"; import { IEventCardOptions, IEvent } from "@/types/event.model";
import { Component, Prop } from "vue-property-decorator"; import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { IPerson, usernameWithDomain } from "@/types/actor"; import { IPerson, usernameWithDomain } from "@/types/actor";
@ -59,7 +82,7 @@ import { mixins } from "vue-class-component";
import ActorMixin from "@/mixins/actor"; import ActorMixin from "@/mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import EventMixin from "@/mixins/event"; import EventMixin from "@/mixins/event";
import { ParticipantRole } from "../../types/participant.model"; import { EventVisibility, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
const defaultOptions: IEventCardOptions = { const defaultOptions: IEventCardOptions = {

View File

@ -14,10 +14,12 @@
{{ {{
$tc( $tc(
"{available}/{capacity} available places", "{available}/{capacity} available places",
event.options.maximumAttendeeCapacity - event.participantStats.participant, event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{ {
available: available:
event.options.maximumAttendeeCapacity - event.participantStats.participant, event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity, capacity: event.options.maximumAttendeeCapacity,
} }
) )
@ -42,9 +44,13 @@
" "
> >
{{ {{
$tc("{count} requests waiting", event.participantStats.notApproved, { $tc(
count: event.participantStats.notApproved, "{count} requests waiting",
}) event.participantStats.notApproved,
{
count: event.participantStats.notApproved,
}
)
}} }}
</b-button> </b-button>
</span> </span>
@ -56,7 +62,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { ParticipantRole } from "../../types/participant.model"; import { ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -88,7 +94,8 @@ export default class EventMinimalistCard extends Vue {
.event-minimalist-title { .event-minimalist-title {
color: #3c376e; color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
} }

View File

@ -33,9 +33,12 @@
<div v-else-if="queryText.length >= 3" class="is-enabled"> <div v-else-if="queryText.length >= 3" class="is-enabled">
<span>{{ $t('No results for "{queryText}"') }}</span> <span>{{ $t('No results for "{queryText}"') }}</span>
<span>{{ <span>{{
$t("You can try another search term or drag and drop the marker on the map", { $t(
queryText, "You can try another search term or drag and drop the marker on the map",
}) {
queryText,
}
)
}}</span> }}</span>
<!-- <p class="control" @click="openNewAddressModal">--> <!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>--> <!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
@ -102,7 +105,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { debounce } from "lodash"; import { debounce, DebouncedFunc } from "lodash";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address"; import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
@ -110,7 +113,8 @@ import { IConfig } from "../../types/config.model";
@Component({ @Component({
components: { components: {
"map-leaflet": () => import(/* webpackChunkName: "map" */ "@/components/Map.vue"), "map-leaflet": () =>
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
}, },
apollo: { apollo: {
config: CONFIG, config: CONFIG,
@ -133,7 +137,8 @@ export default class FullAddressAutoComplete extends Vue {
private gettingLocation = false; private gettingLocation = false;
private location!: Position; // eslint-disable-next-line no-undef
private location!: GeolocationPosition;
private gettingLocationError: any; private gettingLocationError: any;
@ -141,11 +146,11 @@ export default class FullAddressAutoComplete extends Vue {
config!: IConfig; config!: IConfig;
fetchAsyncData!: Function; fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
// We put this in data because of issues like // We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263 // https://github.com/vuejs/vue-class-component/issues/263
data() { data(): Record<string, unknown> {
return { return {
fetchAsyncData: debounce(this.asyncData, 200), fetchAsyncData: debounce(this.asyncData, 200),
}; };
@ -173,7 +178,9 @@ export default class FullAddressAutoComplete extends Vue {
}, },
}); });
this.addressData = result.data.searchAddress.map((address: IAddress) => new Address(address)); this.addressData = result.data.searchAddress.map(
(address: IAddress) => new Address(address)
);
this.isFetching = false; this.isFetching = false;
} }
@ -224,7 +231,9 @@ export default class FullAddressAutoComplete extends Vue {
}, },
}); });
this.addressData = result.data.reverseGeocode.map((address: IAddress) => new Address(address)); this.addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (this.addressData.length > 0) { if (this.addressData.length > 0) {
const defaultAddress = new Address(this.addressData[0]); const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress; this.selected = defaultAddress;
@ -248,7 +257,10 @@ export default class FullAddressAutoComplete extends Vue {
this.location = await FullAddressAutoComplete.getLocation(); this.location = await FullAddressAutoComplete.getLocation();
this.mapDefaultZoom = 12; this.mapDefaultZoom = 12;
this.reverseGeoCode( this.reverseGeoCode(
new LatLng(this.location.coords.latitude, this.location.coords.longitude), new LatLng(
this.location.coords.latitude,
this.location.coords.longitude
),
12 12
); );
} catch (e) { } catch (e) {
@ -266,7 +278,8 @@ export default class FullAddressAutoComplete extends Vue {
return window.isSecureContext; return window.isSecureContext;
} }
static async getLocation(): Promise<Position> { // eslint-disable-next-line no-undef
static async getLocation(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) { if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available.")); reject(new Error("Geolocation is not available."));

View File

@ -10,9 +10,18 @@
> >
<div class="media"> <div class="media">
<figure class="image is-48x48" v-if="availableActor.avatar"> <figure class="image is-48x48" v-if="availableActor.avatar">
<img class="media-left is-rounded" :src="availableActor.avatar.url" alt="" /> <img
class="media-left is-rounded"
:src="availableActor.avatar.url"
alt=""
/>
</figure> </figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content"> <div class="media-content">
<h3>{{ availableActor.name }}</h3> <h3>{{ availableActor.name }}</h3>
<small>{{ `@${availableActor.preferredUsername}` }}</small> <small>{{ `@${availableActor.preferredUsername}` }}</small>
@ -23,9 +32,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IMember, IPerson, MemberRole, IActor, Actor } from "@/types/actor"; import { IPerson, IActor, Actor } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor"; import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
@Component({ @Component({
apollo: { apollo: {
@ -59,16 +70,21 @@ export default class OrganizerPicker extends Vue {
get actualMemberships(): IMember[] { get actualMemberships(): IMember[] {
if (this.restrictModeratorLevel) { if (this.restrictModeratorLevel) {
return this.groupMemberships.elements.filter((membership: IMember) => return this.groupMemberships.elements.filter((membership: IMember) =>
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR, MemberRole.CREATOR].includes( [
membership.role MemberRole.ADMINISTRATOR,
) MemberRole.MODERATOR,
MemberRole.CREATOR,
].includes(membership.role)
); );
} }
return this.groupMemberships.elements; return this.groupMemberships.elements;
} }
get actualAvailableActors(): IActor[] { get actualAvailableActors(): IActor[] {
return [this.identity, ...this.actualMemberships.map((member) => member.parent)]; return [
this.identity,
...this.actualMemberships.map((member) => member.parent),
];
} }
@Watch("currentActor") @Watch("currentActor")

View File

@ -1,7 +1,11 @@
<template> <template>
<div class="organizer-picker"> <div class="organizer-picker">
<!-- If we have a current actor (inline) --> <!-- If we have a current actor (inline) -->
<div v-if="inline && currentActor.id" class="inline box" @click="isComponentModalActive = true"> <div
v-if="inline && currentActor.id"
class="inline box"
@click="isComponentModalActive = true"
>
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48" v-if="currentActor.avatar"> <figure class="image is-48x48" v-if="currentActor.avatar">
@ -15,7 +19,9 @@
</div> </div>
<div class="media-content" v-if="currentActor.name"> <div class="media-content" v-if="currentActor.name">
<p class="is-4">{{ currentActor.name }}</p> <p class="is-4">{{ currentActor.name }}</p>
<p class="is-6 has-text-grey">{{ `@${currentActor.preferredUsername}` }}</p> <p class="is-6 has-text-grey">
{{ `@${currentActor.preferredUsername}` }}
</p>
</div> </div>
<div class="media-content" v-else> <div class="media-content" v-else>
{{ `@${currentActor.preferredUsername}` }} {{ `@${currentActor.preferredUsername}` }}
@ -26,7 +32,11 @@
</div> </div>
</div> </div>
<!-- If we have a current actor --> <!-- If we have a current actor -->
<span v-else-if="currentActor.id" class="block" @click="isComponentModalActive = true"> <span
v-else-if="currentActor.id"
class="block"
@click="isComponentModalActive = true"
>
<img <img
class="image is-48x48" class="image is-48x48"
v-if="currentActor.avatar" v-if="currentActor.avatar"
@ -40,13 +50,19 @@
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar"> <figure class="image is-48x48" v-if="identity.avatar">
<img class="image is-rounded" :src="identity.avatar.url" :alt="identity.avatar.alt" /> <img
class="image is-rounded"
:src="identity.avatar.url"
:alt="identity.avatar.alt"
/>
</figure> </figure>
<b-icon v-else size="is-large" icon="account-circle" /> <b-icon v-else size="is-large" icon="account-circle" />
</div> </div>
<div class="media-content" v-if="identity.name"> <div class="media-content" v-if="identity.name">
<p class="is-4">{{ identity.name }}</p> <p class="is-4">{{ identity.name }}</p>
<p class="is-6 has-text-grey">{{ `@${identity.preferredUsername}` }}</p> <p class="is-6 has-text-grey">
{{ `@${identity.preferredUsername}` }}
</p>
</div> </div>
<div class="media-content" v-else> <div class="media-content" v-else>
{{ `@${identity.preferredUsername}` }} {{ `@${identity.preferredUsername}` }}
@ -74,7 +90,11 @@
<div class="column"> <div class="column">
<div v-if="actorMembersForCurrentActor.length > 0"> <div v-if="actorMembersForCurrentActor.length > 0">
<p>{{ $t("Add a contact") }}</p> <p>{{ $t("Add a contact") }}</p>
<p class="field" v-for="actor in actorMembersForCurrentActor" :key="actor.id"> <p
class="field"
v-for="actor in actorMembersForCurrentActor"
:key="actor.id"
>
<b-checkbox v-model="actualContacts" :native-value="actor.id"> <b-checkbox v-model="actualContacts" :native-value="actor.id">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
@ -89,7 +109,9 @@
</div> </div>
<div class="media-content" v-if="actor.name"> <div class="media-content" v-if="actor.name">
<p class="is-4">{{ actor.name }}</p> <p class="is-4">{{ actor.name }}</p>
<p class="is-6 has-text-grey">{{ `@${actor.preferredUsername}` }}</p> <p class="is-6 has-text-grey">
{{ `@${actor.preferredUsername}` }}
</p>
</div> </div>
<div class="media-content" v-else> <div class="media-content" v-else>
{{ `@${actor.preferredUsername}` }} {{ `@${actor.preferredUsername}` }}
@ -115,7 +137,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IActor, IGroup, IMember, IPerson } from "../../types/actor"; import { IMember } from "@/types/actor/member.model";
import { IActor, IGroup, IPerson } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue"; import OrganizerPicker from "./OrganizerPicker.vue";
import { PERSON_MEMBERSHIPS_WITH_MEMBERS } from "../../graphql/actor"; import { PERSON_MEMBERSHIPS_WITH_MEMBERS } from "../../graphql/actor";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";
@ -150,7 +173,8 @@ export default class OrganizerPickerWrapper extends Vue {
groupMemberships: Paginate<IMember> = { elements: [], total: 0 }; groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
@Prop({ type: Array, required: false, default: () => [] }) contacts!: IActor[]; @Prop({ type: Array, required: false, default: () => [] })
contacts!: IActor[];
actualContacts: (string | undefined)[] = this.contacts.map(({ id }) => id); actualContacts: (string | undefined)[] = this.contacts.map(({ id }) => id);
@ -171,7 +195,9 @@ export default class OrganizerPickerWrapper extends Vue {
pickActor(): void { pickActor(): void {
this.$emit( this.$emit(
"update:contacts", "update:contacts",
this.actorMembersForCurrentActor.filter(({ id }) => this.actualContacts.includes(id)) this.actorMembersForCurrentActor.filter(({ id }) =>
this.actualContacts.includes(id)
)
); );
this.$emit("input", this.currentActor); this.$emit("input", this.currentActor);
this.isComponentModalActive = false; this.isComponentModalActive = false;
@ -182,7 +208,9 @@ export default class OrganizerPickerWrapper extends Vue {
({ parent: { id } }) => id === this.currentActor.id ({ parent: { id } }) => id === this.currentActor.id
); );
if (currentMembership) { if (currentMembership) {
return currentMembership.parent.members.elements.map(({ actor }) => actor); return currentMembership.parent.members.elements.map(
({ actor }: { actor: IActor }) => actor
);
} }
return []; return [];
} }

View File

@ -47,8 +47,16 @@ A button to set your participation
> >
</b-dropdown> </b-dropdown>
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED"> <div
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled"> v-else-if="
participation && participation.role === ParticipantRole.NOT_APPROVED
"
>
<b-dropdown
aria-role="list"
position="is-bottom-left"
class="dropdown-disabled"
>
<button class="button is-success is-large" type="button" slot="trigger"> <button class="button is-success is-large" type="button" slot="trigger">
<b-icon icon="timer-sand-empty" /> <b-icon icon="timer-sand-empty" />
<template> <template>
@ -74,9 +82,17 @@ A button to set your participation
<small>{{ $t("Waiting for organization team approval.") }}</small> <small>{{ $t("Waiting for organization team approval.") }}</small>
</div> </div>
<div v-else-if="participation && participation.role === ParticipantRole.REJECTED"> <div
v-else-if="
participation && participation.role === ParticipantRole.REJECTED
"
>
<span> <span>
{{ $t("Unfortunately, your participation request was rejected by the organizers.") }} {{
$t(
"Unfortunately, your participation request was rejected by the organizers."
)
}}
</span> </span>
</div> </div>
@ -92,7 +108,11 @@ A button to set your participation
<b-icon icon="menu-down" /> <b-icon icon="menu-down" />
</button> </button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)"> <b-dropdown-item
:value="true"
aria-role="listitem"
@click="joinEvent(currentActor)"
>
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-32x32" v-if="currentActor.avatar"> <figure class="image is-32x32" v-if="currentActor.avatar">
@ -103,7 +123,8 @@ A button to set your participation
<span> <span>
{{ {{
$t("as {identity}", { $t("as {identity}", {
identity: currentActor.name || `@${currentActor.preferredUsername}`, identity:
currentActor.name || `@${currentActor.preferredUsername}`,
}) })
}} }}
</span> </span>
@ -121,7 +142,10 @@ A button to set your participation
</b-dropdown> </b-dropdown>
<b-button <b-button
tag="router-link" tag="router-link"
:to="{ name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT, params: { uuid: event.uuid } }" :to="{
name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT,
params: { uuid: event.uuid },
}"
v-else-if="!participation && hasAnonymousParticipationMethods" v-else-if="!participation && hasAnonymousParticipationMethods"
type="is-primary" type="is-primary"
size="is-large" size="is-large"
@ -130,7 +154,10 @@ A button to set your participation
> >
<b-button <b-button
tag="router-link" tag="router-link"
:to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT, params: { uuid: event.uuid } }" :to="{
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
params: { uuid: event.uuid },
}"
v-else-if="!currentActor.id" v-else-if="!currentActor.id"
type="is-primary" type="is-primary"
size="is-large" size="is-large"
@ -142,8 +169,9 @@ A button to set your participation
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IParticipant, ParticipantRole } from "../../types/participant.model"; import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import { EventJoinOptions, IEvent } from "../../types/event.model"; import { IParticipant } from "../../types/participant.model";
import { IEvent } from "../../types/event.model";
import { IPerson, Person } from "../../types/actor"; import { IPerson, Person } from "../../types/actor";
import { CURRENT_ACTOR_CLIENT, IDENTITIES } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT, IDENTITIES } from "../../graphql/actor";
import { CURRENT_USER_CLIENT } from "../../graphql/user"; import { CURRENT_USER_CLIENT } from "../../graphql/user";
@ -161,7 +189,9 @@ import RouteName from "../../router/name";
identities: { identities: {
query: IDENTITIES, query: IDENTITIES,
update: ({ identities }) => update: ({ identities }) =>
identities ? identities.map((identity: IPerson) => new Person(identity)) : [], identities
? identities.map((identity: IPerson) => new Person(identity))
: [],
skip() { skip() {
return this.currentUser.isLoggedIn === false; return this.currentUser.isLoggedIn === false;
}, },

View File

@ -59,7 +59,12 @@
<a :href="linkedInShareUrl" target="_blank" rel="nofollow noopener" <a :href="linkedInShareUrl" target="_blank" rel="nofollow noopener"
><b-icon icon="linkedin" size="is-large" type="is-primary" ><b-icon icon="linkedin" size="is-large" type="is-primary"
/></a> /></a>
<a :href="diasporaShareUrl" class="diaspora" target="_blank" rel="nofollow noopener"> <a
:href="diasporaShareUrl"
class="diaspora"
target="_blank"
rel="nofollow noopener"
>
<span data-v-5e15e80a="" class="icon has-text-primary is-large"> <span data-v-5e15e80a="" class="icon has-text-primary is-large">
<DiasporaLogo alt="diaspora-logo" /> <DiasporaLogo alt="diaspora-logo" />
</span> </span>
@ -67,7 +72,6 @@
<a :href="emailShareUrl" target="_blank" rel="nofollow noopener" <a :href="emailShareUrl" target="_blank" rel="nofollow noopener"
><b-icon icon="email" size="is-large" type="is-primary" ><b-icon icon="email" size="is-large" type="is-primary"
/></a> /></a>
<!-- TODO: mailto: links are not used anymore, we should provide a popup to redact a message instead -->
</div> </div>
</div> </div>
</section> </section>
@ -76,7 +80,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator"; import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import { IEvent, EventVisibility, EventStatus } from "../../types/event.model"; import { EventStatus, EventVisibility } from "@/types/enums";
import { IEvent } from "../../types/event.model";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import DiasporaLogo from "../../assets/diaspora-icon.svg?inline"; import DiasporaLogo from "../../assets/diaspora-icon.svg?inline";
@ -88,7 +94,8 @@ import DiasporaLogo from "../../assets/diaspora-icon.svg?inline";
export default class ShareEventModal extends Vue { export default class ShareEventModal extends Vue {
@Prop({ type: Object, required: true }) event!: IEvent; @Prop({ type: Object, required: true }) event!: IEvent;
@Prop({ type: Boolean, required: false, default: true }) eventCapacityOK!: boolean; @Prop({ type: Boolean, required: false, default: true })
eventCapacityOK!: boolean;
@Ref("eventURLInput") readonly eventURLInput!: any; @Ref("eventURLInput") readonly eventURLInput!: any;
@ -99,13 +106,15 @@ export default class ShareEventModal extends Vue {
showCopiedTooltip = false; showCopiedTooltip = false;
get twitterShareUrl(): string { get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${ return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
this.event.title this.event.url
}`; )}&text=${this.event.title}`;
} }
get facebookShareUrl(): string { get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.event.url)}`; return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
this.event.url
)}`;
} }
get linkedInShareUrl(): string { get linkedInShareUrl(): string {
@ -124,7 +133,7 @@ export default class ShareEventModal extends Vue {
)}&url=${encodeURIComponent(this.event.url)}`; )}&url=${encodeURIComponent(this.event.url)}`;
} }
copyURL() { copyURL(): void {
this.eventURLInput.$refs.input.select(); this.eventURLInput.$refs.input.select();
document.execCommand("copy"); document.execCommand("copy");
this.showCopiedTooltip = true; this.showCopiedTooltip = true;

View File

@ -4,7 +4,9 @@
{{ $t("Add some tags") }} {{ $t("Add some tags") }}
<b-tooltip <b-tooltip
type="is-dark" type="is-dark"
:label="$t('You can add tags by hitting the Enter key or by adding a comma')" :label="
$t('You can add tags by hitting the Enter key or by adding a comma')
"
> >
<b-icon size="is-small" icon="help-circle-outline"></b-icon> <b-icon size="is-small" icon="help-circle-outline"></b-icon>
</b-tooltip> </b-tooltip>
@ -40,7 +42,6 @@ import { ITag } from "../../types/tag.model";
if (typeof tag !== "string") { if (typeof tag !== "string") {
return tag; return tag;
} }
// @ts-ignore
return { title: tag, slug: tag } as ITag; return { title: tag, slug: tag } as ITag;
}); });
this.$emit("input", tagEntities); this.$emit("input", tagEntities);
@ -57,14 +58,14 @@ export default class TagInput extends Vue {
filteredTags: ITag[] = []; filteredTags: ITag[] = [];
getFilteredTags(text: string) { getFilteredTags(text: string): void {
this.filteredTags = differenceBy(this.data, this.value, "id").filter( this.filteredTags = differenceBy(this.data, this.value, "id").filter(
(option) => get(option, this.path).toString().toLowerCase().indexOf(text.toLowerCase()) >= 0 (option) =>
get(option, this.path)
.toString()
.toLowerCase()
.indexOf(text.toLowerCase()) >= 0
); );
} }
static isTag(x: any): x is ITag {
return x.slug !== undefined;
}
} }
</script> </script>

View File

@ -1,9 +1,38 @@
<template> <template>
<footer class="footer" ref="footer"> <footer class="footer" ref="footer">
<img :src="`/img/pics/footer_${random}.jpg`" alt="" /> <picture>
<source
:srcset="`/img/pics/footer_${random}-1024w.webp 1x, /img/pics/footer_${random}-1920w.webp 2x`"
type="image/webp"
/>
<source
:srcset="`/img/pics/footer_${random}-1024w.jpg 1x, /img/pics/footer_${random}-1920w.jpg 2x`"
type="image/jpeg"
/>
<img
:src="`/img/pics/footer_${random}-1024w.jpg`"
alt=""
width="5234"
height="2189"
loading="lazy"
/>
</picture>
<ul> <ul>
<li> <li>
<router-link :to="{ name: RouteName.ABOUT }">{{ $t("About") }}</router-link> <b-select
v-if="$i18n"
v-model="locale"
:placeholder="$t('Select a language')"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</b-select>
</li>
<li>
<router-link :to="{ name: RouteName.ABOUT }">{{
$t("About")
}}</router-link>
</li> </li>
<li> <li>
<a href='https://www.chapril.org/Mentions-legales.html' > <a href='https://www.chapril.org/Mentions-legales.html' >
@ -14,7 +43,10 @@
<router-link :to="{ name: RouteName.TERMS }">{{ $t("Terms") }}</router-link> <router-link :to="{ name: RouteName.TERMS }">{{ $t("Terms") }}</router-link>
</li> </li>
<li> <li>
<a hreflang="en" href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"> <a
hreflang="en"
href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"
>
{{ $t("License") }} {{ $t("License") }}
</a> </a>
</li> </li>
@ -24,7 +56,9 @@
tag="span" tag="span"
path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}." path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
> >
<a slot="mobilizon" href="https://joinmobilizon.org">{{ $t("Mobilizon") }}</a> <a slot="mobilizon" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
<span slot="date">{{ new Date().getFullYear() }}</span> <span slot="date">{{ new Date().getFullYear() }}</span>
<a href="https://joinmobilizon.org/hall-of-fame" slot="contributors">{{ <a href="https://joinmobilizon.org/hall-of-fame" slot="contributors">{{
$t("more than 1360 contributors") $t("more than 1360 contributors")
@ -34,17 +68,40 @@
</footer> </footer>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import { saveLocaleData } from "@/utils/auth";
import { loadLanguageAsync } from "@/utils/i18n";
import RouteName from "../router/name"; import RouteName from "../router/name";
import langs from "../i18n/langs.json";
@Component @Component
export default class Footer extends Vue { export default class Footer extends Vue {
RouteName = RouteName; RouteName = RouteName;
locale: string | null = this.$i18n.locale;
langs: Record<string, string> = langs;
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
get random(): number { get random(): number {
return Math.floor(Math.random() * 4) + 1; return Math.floor(Math.random() * 4) + 1;
} }
@Watch("locale")
// eslint-disable-next-line class-methods-use-this
async updateLocale(locale: string): Promise<void> {
if (locale) {
await loadLanguageAsync(locale);
saveLocaleData(locale);
}
}
@Watch("$i18n.locale", { deep: true })
updateLocaleFromI18n(locale: string): void {
if (locale) {
this.locale = locale;
}
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -88,5 +145,10 @@ footer.footer {
text-decoration: underline; text-decoration: underline;
text-decoration-color: $secondary; text-decoration-color: $secondary;
} }
::v-deep span.select select {
background: $background-color;
color: $white;
}
} }
</style> </style>

View File

@ -17,7 +17,9 @@
> >
<h3>{{ group.name }}</h3> <h3>{{ group.name }}</h3>
<p class="is-6 has-text-grey"> <p class="is-6 has-text-grey">
<span v-if="group.domain">{{ `@${group.preferredUsername}@${group.domain}` }}</span> <span v-if="group.domain">{{
`@${group.preferredUsername}@${group.domain}`
}}</span>
<span v-else>{{ `@${group.preferredUsername}` }}</span> <span v-else>{{ `@${group.preferredUsername}` }}</span>
</p> </p>
</router-link> </router-link>

View File

@ -1,5 +1,11 @@
<template> <template>
<div class="card"> <div class="card">
<div class="identity-header">
<figure class="image is-24x24" v-if="member.actor.avatar">
<img class="is-rounded" :src="member.actor.avatar.url" alt="" />
</figure>
{{ displayNameAndUsername(member.actor) }}
</div>
<div class="card-content"> <div class="card-content">
<div> <div>
<div class="media"> <div class="media">
@ -13,7 +19,9 @@
<router-link <router-link
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) }, params: {
preferredUsername: usernameWithDomain(member.parent),
},
}" }"
> >
<h3>{{ member.parent.name }}</h3> <h3>{{ member.parent.name }}</h3>
@ -23,12 +31,16 @@
}}</span> }}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span> <span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
<b-taglist> <b-taglist>
<b-tag type="is-info" v-if="member.role === MemberRole.ADMINISTRATOR">{{ <b-tag
$t("Administrator") type="is-info"
}}</b-tag> v-if="member.role === MemberRole.ADMINISTRATOR"
<b-tag type="is-info" v-else-if="member.role === MemberRole.MODERATOR">{{ >{{ $t("Administrator") }}</b-tag
$t("Moderator") >
}}</b-tag> <b-tag
type="is-info"
v-else-if="member.role === MemberRole.MODERATOR"
>{{ $t("Moderator") }}</b-tag
>
</b-taglist> </b-taglist>
</p> </p>
</router-link> </router-link>
@ -54,7 +66,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IMember, MemberRole, usernameWithDomain } from "@/types/actor"; import { displayNameAndUsername, usernameWithDomain } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component @Component
@ -65,20 +79,34 @@ export default class GroupMemberCard extends Vue {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
displayNameAndUsername = displayNameAndUsername;
MemberRole = MemberRole; MemberRole = MemberRole;
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card-content { .card {
display: flex; .card-content {
align-items: center; display: flex;
align-items: center;
& > div:first-child { & > div:first-child {
flex: 1; flex: 1;
}
& > div:last-child {
cursor: pointer;
}
} }
& > div:last-child { .identity-header {
cursor: pointer; background: $yellow-2;
display: flex;
padding: 5px;
figure {
padding-right: 3px;
}
} }
} }
</style> </style>

View File

@ -8,7 +8,9 @@
<a <a
class="list-item" class="list-item"
v-for="groupMembership in actualMemberships" v-for="groupMembership in actualMemberships"
:class="{ 'is-active': groupMembership.parent.id === currentGroup.id }" :class="{
'is-active': groupMembership.parent.id === currentGroup.id,
}"
@click="changeCurrentGroup(groupMembership.parent)" @click="changeCurrentGroup(groupMembership.parent)"
:key="groupMembership.id" :key="groupMembership.id"
> >
@ -19,14 +21,25 @@
:src="groupMembership.parent.avatar.url" :src="groupMembership.parent.avatar.url"
alt="" alt=""
/> />
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content"> <div class="media-content">
<h3>@{{ groupMembership.parent.name }}</h3> <h3>@{{ groupMembership.parent.name }}</h3>
<small>{{ `@${groupMembership.parent.preferredUsername}` }}</small> <small>{{
`@${groupMembership.parent.preferredUsername}`
}}</small>
</div> </div>
</div> </div>
</a> </a>
<a class="list-item" @click="changeCurrentGroup(new Group())" v-if="currentGroup.id"> <a
class="list-item"
@click="changeCurrentGroup(new Group())"
v-if="currentGroup.id"
>
<h3>{{ $t("Unset group") }}</h3> <h3>{{ $t("Unset group") }}</h3>
</a> </a>
</div> </div>
@ -36,9 +49,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember, IPerson, Group, MemberRole } from "@/types/actor"; import { IGroup, IPerson, Group } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor"; import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
@Component({ @Component({
apollo: { apollo: {
@ -77,9 +92,11 @@ export default class GroupPicker extends Vue {
get actualMemberships(): IMember[] { get actualMemberships(): IMember[] {
if (this.restrictModeratorLevel) { if (this.restrictModeratorLevel) {
return this.groupMemberships.elements.filter((membership: IMember) => return this.groupMemberships.elements.filter((membership: IMember) =>
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR, MemberRole.CREATOR].includes( [
membership.role MemberRole.ADMINISTRATOR,
) MemberRole.MODERATOR,
MemberRole.CREATOR,
].includes(membership.role)
); );
} }
return this.groupMemberships.elements; return this.groupMemberships.elements;

View File

@ -10,7 +10,11 @@
{{ $t("The event will show the group as organizer.") }} {{ $t("The event will show the group as organizer.") }}
</p> </p>
</div> </div>
<div v-if="inline && currentGroup.id" class="inline box" @click="isComponentModalActive = true"> <div
v-if="inline && currentGroup.id"
class="inline box"
@click="isComponentModalActive = true"
>
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48" v-if="currentGroup.avatar"> <figure class="image is-48x48" v-if="currentGroup.avatar">
@ -24,7 +28,9 @@
</div> </div>
<div class="media-content" v-if="currentGroup.name"> <div class="media-content" v-if="currentGroup.name">
<p class="is-4">{{ currentGroup.name }}</p> <p class="is-4">{{ currentGroup.name }}</p>
<p class="is-6 has-text-grey">{{ `@${currentGroup.preferredUsername}` }}</p> <p class="is-6 has-text-grey">
{{ `@${currentGroup.preferredUsername}` }}
</p>
</div> </div>
<div class="media-content" v-else> <div class="media-content" v-else>
{{ `@${currentGroup.preferredUsername}` }} {{ `@${currentGroup.preferredUsername}` }}
@ -34,7 +40,11 @@
</b-button> </b-button>
</div> </div>
</div> </div>
<span v-else-if="currentGroup.id" class="block" @click="isComponentModalActive = true"> <span
v-else-if="currentGroup.id"
class="block"
@click="isComponentModalActive = true"
>
<img <img
class="image is-48x48" class="image is-48x48"
v-if="currentGroup.avatar" v-if="currentGroup.avatar"
@ -44,7 +54,9 @@
<b-icon v-else size="is-large" icon="account-circle" /> <b-icon v-else size="is-large" icon="account-circle" />
</span> </span>
<div v-if="groupMemberships.total === 0" class="box"> <div v-if="groupMemberships.total === 0" class="box">
<p class="is-4">{{ $t("This identity is not a member of any group.") }}</p> <p class="is-4">
{{ $t("This identity is not a member of any group.") }}
</p>
<p class="is-6 is-size-6 has-text-grey"> <p class="is-6 is-size-6 has-text-grey">
{{ $t("You need to create the group before you create an event.") }} {{ $t("You need to create the group before you create an event.") }}
</p> </p>
@ -61,7 +73,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IGroup, IMember, IPerson } from "../../types/actor"; import { IMember } from "@/types/actor/member.model";
import { IGroup, IPerson } from "../../types/actor";
import GroupPicker from "./GroupPicker.vue"; import GroupPicker from "./GroupPicker.vue";
import { PERSON_MEMBERSHIPS } from "../../graphql/actor"; import { PERSON_MEMBERSHIPS } from "../../graphql/actor";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";

View File

@ -26,7 +26,8 @@ export default class GroupSection extends Vue {
@Prop({ required: true, type: String }) icon!: string; @Prop({ required: true, type: String }) icon!: string;
@Prop({ required: false, type: Boolean, default: true }) privateSection!: boolean; @Prop({ required: false, type: Boolean, default: true })
privateSection!: boolean;
@Prop({ required: true, type: Object }) route!: Route; @Prop({ required: true, type: Object }) route!: Route;
} }
@ -76,7 +77,8 @@ div.group-section-title {
::v-deep span { ::v-deep span {
display: inline; display: inline;
padding: 3px 8px; padding: 3px 8px;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-weight: 500; font-weight: 500;
font-size: 30px; font-size: 30px;
flex: 1; flex: 1;

View File

@ -2,7 +2,10 @@
<div class="media"> <div class="media">
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<i18n tag="p" path="You have been invited by {invitedBy} to the following group:"> <i18n
tag="p"
path="You have been invited by {invitedBy} to the following group:"
>
<b slot="invitedBy">{{ member.invitedBy.name }}</b> <b slot="invitedBy">{{ member.invitedBy.name }}</b>
</i18n> </i18n>
</div> </div>
@ -20,15 +23,21 @@
<router-link <router-link
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) }, params: {
preferredUsername: usernameWithDomain(member.parent),
},
}" }"
> >
<h3>{{ member.parent.name }}</h3> <h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey"> <p class="is-6 has-text-grey">
<span v-if="member.parent.domain"> <span v-if="member.parent.domain">
{{ `@${member.parent.preferredUsername}@${member.parent.domain}` }} {{
`@${member.parent.preferredUsername}@${member.parent.domain}`
}}
</span> </span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span> <span v-else>{{
`@${member.parent.preferredUsername}`
}}</span>
</p> </p>
</router-link> </router-link>
</div> </div>
@ -54,7 +63,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IMember, usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component @Component

View File

@ -11,10 +11,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member"; import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
import { IMember } from "@/types/actor";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import InvitationCard from "@/components/Group/InvitationCard.vue"; import InvitationCard from "@/components/Group/InvitationCard.vue";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor"; import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
@Component({ @Component({
components: { components: {
@ -26,13 +26,15 @@ export default class Invitations extends Vue {
async acceptInvitation(id: string): Promise<void> { async acceptInvitation(id: string): Promise<void> {
try { try {
const { data } = await this.$apollo.mutate<{ acceptInvitation: IMember }>({ const { data } = await this.$apollo.mutate<{ acceptInvitation: IMember }>(
mutation: ACCEPT_INVITATION, {
variables: { mutation: ACCEPT_INVITATION,
id, variables: {
}, id,
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }], },
}); refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }],
}
);
if (data) { if (data) {
this.$emit("accept-invitation", data.acceptInvitation); this.$emit("accept-invitation", data.acceptInvitation);
} }
@ -46,13 +48,15 @@ export default class Invitations extends Vue {
async rejectInvitation(id: string): Promise<void> { async rejectInvitation(id: string): Promise<void> {
try { try {
const { data } = await this.$apollo.mutate<{ rejectInvitation: IMember }>({ const { data } = await this.$apollo.mutate<{ rejectInvitation: IMember }>(
mutation: REJECT_INVITATION, {
variables: { mutation: REJECT_INVITATION,
id, variables: {
}, id,
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }], },
}); refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }],
}
);
if (data) { if (data) {
this.$emit("reject-invitation", data.rejectInvitation); this.$emit("reject-invitation", data.rejectInvitation);
} }

View File

@ -25,6 +25,8 @@ export default class JoinGroupWithAccount extends Vue {
}`; }`;
} }
sentence = this.$t("We will redirect you to your instance in order to interact with this group"); sentence = this.$t(
"We will redirect you to your instance in order to interact with this group"
);
} }
</script> </script>

View File

@ -8,11 +8,7 @@ import { Component, Prop, Vue } from "vue-property-decorator";
// @ts-ignore // @ts-ignore
import MobilizonLogo from "../assets/logo_chapril_mobilizon.png"; import MobilizonLogo from "../assets/logo_chapril_mobilizon.png";
@Component({ @Component
components: {
MobilizonLogo,
},
})
export default class Logo extends Vue { export default class Logo extends Vue {
@Prop({ type: Boolean, required: false, default: false }) invert!: boolean; @Prop({ type: Boolean, required: false, default: false }) invert!: boolean;
} }

View File

@ -8,7 +8,11 @@
@click="clickMap" @click="clickMap"
@update:zoom="updateZoom" @update:zoom="updateZoom"
> >
<l-tile-layer :url="config.maps.tiles.endpoint" :attribution="attribution"> </l-tile-layer> <l-tile-layer
:url="config.maps.tiles.endpoint"
:attribution="attribution"
>
</l-tile-layer>
<v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" /> <v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" />
<l-marker <l-marker
:lat-lng="[lat, lon]" :lat-lng="[lat, lon]"
@ -17,7 +21,9 @@
:draggable="!readOnly" :draggable="!readOnly"
> >
<l-popup v-if="popupMultiLine"> <l-popup v-if="popupMultiLine">
<span v-for="line in popupMultiLine" :key="line">{{ line }}<br /></span> <span v-for="line in popupMultiLine" :key="line"
>{{ line }}<br
/></span>
</l-popup> </l-popup>
</l-marker> </l-marker>
</l-map> </l-map>
@ -51,12 +57,15 @@ export default class Map extends Vue {
@Prop({ type: String, required: true }) coords!: string; @Prop({ type: String, required: true }) coords!: string;
@Prop({ type: Object, required: false }) marker!: { text: string | string[]; icon: string }; @Prop({ type: Object, required: false }) marker!: {
text: string | string[];
icon: string;
};
@Prop({ type: Object, required: false }) options!: object; @Prop({ type: Object, required: false }) options!: Record<string, unknown>;
@Prop({ type: Function, required: false }) @Prop({ type: Function, required: false })
updateDraggableMarkerCallback!: Function; updateDraggableMarkerCallback!: (latlng: LatLng, zoom: number) => void;
defaultOptions: { defaultOptions: {
zoom: number; zoom: number;
@ -86,45 +95,48 @@ export default class Map extends Vue {
} }
/* eslint-enable */ /* eslint-enable */
openPopup(event: LeafletEvent) { openPopup(event: LeafletEvent): void {
this.$nextTick(() => { this.$nextTick(() => {
event.target.openPopup(); event.target.openPopup();
}); });
} }
get mergedOptions(): object { get mergedOptions(): Record<string, unknown> {
return { ...this.defaultOptions, ...this.options }; return { ...this.defaultOptions, ...this.options };
} }
get lat() { get lat(): number {
return this.$props.coords.split(";")[1]; return this.$props.coords.split(";")[1];
} }
get lon() { get lon(): number {
return this.$props.coords.split(";")[0]; return this.$props.coords.split(";")[0];
} }
get popupMultiLine() { get popupMultiLine(): Array<string> {
if (Array.isArray(this.marker.text)) { if (Array.isArray(this.marker.text)) {
return this.marker.text; return this.marker.text;
} }
return [this.marker.text]; return [this.marker.text];
} }
clickMap(event: LeafletMouseEvent) { clickMap(event: LeafletMouseEvent): void {
this.updateDraggableMarkerPosition(event.latlng); this.updateDraggableMarkerPosition(event.latlng);
} }
updateDraggableMarkerPosition(e: LatLng) { updateDraggableMarkerPosition(e: LatLng): void {
this.updateDraggableMarkerCallback(e, this.zoom); this.updateDraggableMarkerCallback(e, this.zoom);
} }
updateZoom(zoom: number) { updateZoom(zoom: number): void {
this.zoom = zoom; this.zoom = zoom;
} }
get attribution() { get attribution(): string {
return this.config.maps.tiles.attribution || this.$t("© The OpenStreetMap Contributors"); return (
this.config.maps.tiles.attribution ||
(this.$t("© The OpenStreetMap Contributors") as string)
);
} }
} }
</script> </script>

View File

@ -17,13 +17,16 @@ import { Component, Prop, Vue } from "vue-property-decorator";
@Component({ @Component({
beforeDestroy() { beforeDestroy() {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
this.parentContainer.removeLayer(this); this.parentContainer.removeLayer(this);
}, },
}) })
export default class Vue2LeafletLocateControl extends Vue { export default class Vue2LeafletLocateControl extends Vue {
@Prop({ type: Object, default: () => ({}) }) options!: object; @Prop({ type: Object, default: () => ({}) }) options!: Record<
string,
unknown
>;
@Prop({ type: Boolean, default: true }) visible = true; @Prop({ type: Boolean, default: true }) visible = true;
@ -33,7 +36,7 @@ export default class Vue2LeafletLocateControl extends Vue {
parentContainer: any; parentContainer: any;
mounted() { mounted(): void {
this.mapObject = L.control.locate(this.options); this.mapObject = L.control.locate(this.options);
DomEvent.on(this.mapObject, this.$listeners as any); DomEvent.on(this.mapObject, this.$listeners as any);
propsBinder(this, this.mapObject, this.$props); propsBinder(this, this.mapObject, this.$props);
@ -42,7 +45,7 @@ export default class Vue2LeafletLocateControl extends Vue {
this.mapObject.addTo(this.parentContainer.mapObject, !this.visible); this.mapObject.addTo(this.parentContainer.mapObject, !this.visible);
} }
public locate() { public locate(): void {
this.mapObject.start(); this.mapObject.start();
} }
} }

View File

@ -1,9 +1,18 @@
<template> <template>
<b-navbar type="is-secondary" wrapper-class="container" :active.sync="mobileNavbarActive"> <b-navbar
type="is-secondary"
wrapper-class="container"
:active.sync="mobileNavbarActive"
>
<template slot="brand"> <template slot="brand">
<b-navbar-item tag="router-link" :to="{ name: RouteName.HOME }"> <b-navbar-item tag="router-link"
<img src="/img/long_logo_chapril_mobilizon.png" alt="logo mobilizon" style="width: 5em" /> :aria-label="$t('Home')"
:to="{ name: RouteName.HOME }">
<img
src="/img/long_logo_chapril_mobilizon.png"
alt="logo mobilizon"
style="width: 5em"
/>
</b-navbar-item> </b-navbar-item>
</template> </template>
<template slot="start"> <template slot="start">
@ -21,9 +30,12 @@
</b-navbar-item> </b-navbar-item>
<b-navbar-item tag="span" v-if="config && config.features.eventCreation"> <b-navbar-item tag="span" v-if="config && config.features.eventCreation">
<b-button tag="router-link" :to="{ name: RouteName.CREATE_EVENT }" type="is-primary" <b-button
>{{ $t("Create") }} tag="router-link"
</b-button> :to="{ name: RouteName.CREATE_EVENT }"
type="is-primary"
>{{ $t("Create") }}</b-button
>
</b-navbar-item> </b-navbar-item>
</template> </template>
<template slot="end"> <template slot="end">
@ -38,9 +50,17 @@
</b-navbar-item> </b-navbar-item>
<b-navbar-dropdown v-if="currentActor.id && currentUser.isLoggedIn" right> <b-navbar-dropdown v-if="currentActor.id && currentUser.isLoggedIn" right>
<template slot="label" v-if="currentActor" class="navbar-dropdown-profile"> <template
slot="label"
v-if="currentActor"
class="navbar-dropdown-profile"
>
<figure class="image is-32x32" v-if="currentActor.avatar"> <figure class="image is-32x32" v-if="currentActor.avatar">
<img class="is-rounded" alt="avatarUrl" :src="currentActor.avatar.url" /> <img
class="is-rounded"
alt="avatarUrl"
:src="currentActor.avatar.url"
/>
</figure> </figure>
<b-icon v-else icon="account-circle" /> <b-icon v-else icon="account-circle" />
</template> </template>
@ -73,9 +93,11 @@
<hr class="navbar-divider" /> <hr class="navbar-divider" />
</b-navbar-item> </b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.UPDATE_IDENTITY }" <b-navbar-item
>{{ $t("My account") }} tag="router-link"
</b-navbar-item> :to="{ name: RouteName.UPDATE_IDENTITY }"
>{{ $t("My account") }}</b-navbar-item
>
<!-- <b-navbar-item tag="router-link" :to="{ name: RouteName.CREATE_GROUP }">--> <!-- <b-navbar-item tag="router-link" :to="{ name: RouteName.CREATE_GROUP }">-->
<!-- {{ $t('Create group') }}--> <!-- {{ $t('Create group') }}-->
@ -103,9 +125,11 @@
<strong>{{ $t("Sign up") }}</strong> <strong>{{ $t("Sign up") }}</strong>
</router-link> </router-link>
<router-link class="button is-light" :to="{ name: RouteName.LOGIN }" <router-link
>{{ $t("Log in") }} class="button is-light"
</router-link> :to="{ name: RouteName.LOGIN }"
>{{ $t("Log in") }}</router-link
>
</div> </div>
</b-navbar-item> </b-navbar-item>
</template> </template>
@ -117,13 +141,18 @@ import { Component, Vue, Watch } from "vue-property-decorator";
import Logo from "@/components/Logo.vue"; import Logo from "@/components/Logo.vue";
import { GraphQLError } from "graphql"; import { GraphQLError } from "graphql";
import { loadLanguageAsync } from "@/utils/i18n"; import { loadLanguageAsync } from "@/utils/i18n";
import { ICurrentUserRole } from "@/types/enums";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user"; import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import { changeIdentity, logout } from "../utils/auth"; import { changeIdentity, logout } from "../utils/auth";
import { CURRENT_ACTOR_CLIENT, IDENTITIES, UPDATE_DEFAULT_ACTOR } from "../graphql/actor"; import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
UPDATE_DEFAULT_ACTOR,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor"; import { IPerson, Person } from "../types/actor";
import { CONFIG } from "../graphql/config"; import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model"; import { IConfig } from "../types/config.model";
import { ICurrentUser, ICurrentUserRole, IUser } from "../types/current-user.model"; import { ICurrentUser, IUser } from "../types/current-user.model";
import SearchField from "./SearchField.vue"; import SearchField from "./SearchField.vue";
import RouteName from "../router/name"; import RouteName from "../router/name";
@ -138,7 +167,9 @@ import RouteName from "../router/name";
identities: { identities: {
query: IDENTITIES, query: IDENTITIES,
update: ({ identities }) => update: ({ identities }) =>
identities ? identities.map((identity: IPerson) => new Person(identity)) : [], identities
? identities.map((identity: IPerson) => new Person(identity))
: [],
skip() { skip() {
return this.currentUser.isLoggedIn === false; return this.currentUser.isLoggedIn === false;
}, },
@ -209,7 +240,8 @@ export default class NavBar extends Vue {
async handleErrors(errors: GraphQLError[]): Promise<void> { async handleErrors(errors: GraphQLError[]): Promise<void> {
if ( if (
errors.length > 0 && errors.length > 0 &&
errors[0].message === "You need to be logged-in to view your list of identities" errors[0].message ===
"You need to be logged-in to view your list of identities"
) { ) {
await this.logout(); await this.logout();
} }

View File

@ -1,9 +1,14 @@
<template> <template>
<section class="section container"> <section class="section container">
<h1 class="title" v-if="loading">{{ $t("Your participation request is being validated") }}</h1> <h1 class="title" v-if="loading">
{{ $t("Your participation request is being validated") }}
</h1>
<div v-else> <div v-else>
<div v-if="failed"> <div v-if="failed">
<b-message :title="$t('Error while validating participation request')" type="is-danger"> <b-message
:title="$t('Error while validating participation request')"
type="is-danger"
>
{{ {{
$t( $t(
"Either the participation request has already been validated, either the validation token is incorrect." "Either the participation request has already been validated, either the validation token is incorrect."
@ -12,9 +17,16 @@
</b-message> </b-message>
</div> </div>
<div v-else> <div v-else>
<h1 class="title">{{ $t("Your participation request has been validated") }}</h1> <h1 class="title">
<p class="content" v-if="participation.event.joinOptions == EventJoinOptions.RESTRICTED"> {{ $t("Your participation request has been validated") }}
{{ $t("Your participation still has to be approved by the organisers.") }} </h1>
<p
class="content"
v-if="participation.event.joinOptions == EventJoinOptions.RESTRICTED"
>
{{
$t("Your participation still has to be approved by the organisers.")
}}
</p> </p>
<div class="columns has-text-centered"> <div class="columns has-text-centered">
<div class="column"> <div class="column">
@ -38,9 +50,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { confirmLocalAnonymousParticipation } from "@/services/AnonymousParticipationStorage"; import { confirmLocalAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import { EventJoinOptions } from "@/types/enums";
import { IParticipant } from "../../types/participant.model"; import { IParticipant } from "../../types/participant.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { EventJoinOptions } from "../../types/event.model";
import { CONFIRM_PARTICIPATION } from "../../graphql/event"; import { CONFIRM_PARTICIPATION } from "../../graphql/event";
@Component @Component

View File

@ -0,0 +1,240 @@
<template>
<div>
<div
class="event-participation has-text-right"
v-if="isEventNotAlreadyPassed"
>
<participation-button
v-if="shouldShowParticipationButton"
:participation="participation"
:event="event"
:current-actor="currentActor"
@join-event="(actor) => $emit('join-event', actor)"
@join-modal="$emit('join-modal')"
@join-event-with-confirmation="
(actor) => $emit('join-event-with-confirmation', actor)
"
@confirm-leave="$emit('confirm-leave')"
/>
<b-button
type="is-text"
v-if="!actorIsParticipant && anonymousParticipation !== null"
@click="$emit('cancel-anonymous-participation')"
>{{ $t("Cancel anonymous participation") }}</b-button
>
<small v-if="!actorIsParticipant && anonymousParticipation">
{{ $t("You are participating in this event anonymously") }}
<b-tooltip :label="$t('Click for more information')">
<span
class="is-clickable"
@click="isAnonymousParticipationModalOpen = true"
>
<b-icon size="is-small" icon="information-outline" />
</span>
</b-tooltip>
</small>
<small
v-else-if="!actorIsParticipant && anonymousParticipation === false"
>
{{
$t(
"You are participating in this event anonymously but didn't confirm participation"
)
}}
<b-tooltip
:label="
$t(
'This information is saved only on your computer. Click for details'
)
"
>
<router-link :to="{ name: RouteName.TERMS }">
<b-icon size="is-small" icon="help-circle-outline" />
</router-link>
</b-tooltip>
</small>
</div>
<div v-else>
<button class="button is-primary" type="button" slot="trigger" disabled>
<template>
<span>{{ $t("Event already passed") }}</span>
</template>
<b-icon icon="menu-down" />
</button>
</div>
<b-modal
:active.sync="isAnonymousParticipationModalOpen"
has-modal-card
ref="anonymous-participation-modal"
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
{{ $t("About anonymous participation") }}
</p>
</header>
<section class="modal-card-body">
<b-notification
type="is-primary"
:closable="false"
v-if="event.joinOptions === EventJoinOptions.RESTRICTED"
>
{{
$t(
"As the event organizer has chosen to manually validate participation requests, your participation will be really confirmed only once you receive an email stating it's being accepted."
)
}}
</b-notification>
<p>
{{
$t(
"Your participation status is saved only on this device and will be deleted one month after the event's passed."
)
}}
</p>
<p v-if="isSecureContext">
{{
$t(
"You may clear all participation information for this device with the buttons below."
)
}}
</p>
<div class="buttons" v-if="isSecureContext">
<b-button
type="is-danger is-outlined"
@click="clearEventParticipationData"
>
{{ $t("Clear participation data for this event") }}
</b-button>
<b-button type="is-danger" @click="clearAllParticipationData">
{{ $t("Clear participation data for all events") }}
</b-button>
</div>
</section>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { EventJoinOptions, EventStatus, ParticipantRole } from "@/types/enums";
import { IParticipant } from "@/types/participant.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "@/router/name";
import { IEvent } from "@/types/event.model";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { IPerson } from "@/types/actor";
import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config";
import {
removeAllAnonymousParticipations,
removeAnonymousParticipation,
} from "@/services/AnonymousParticipationStorage";
import ParticipationButton from "../Event/ParticipationButton.vue";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
config: CONFIG,
},
components: {
ParticipationButton,
},
})
export default class ParticipationSection extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) event!: IEvent;
@Prop({ required: true, default: null }) anonymousParticipation!:
| boolean
| null;
currentActor!: IPerson;
config!: IConfig;
RouteName = RouteName;
EventJoinOptions = EventJoinOptions;
isAnonymousParticipationModalOpen = false;
get actorIsParticipant(): boolean {
if (this.actorIsOrganizer) return true;
return (
this.participation &&
this.participation.role === ParticipantRole.PARTICIPANT
);
}
get actorIsOrganizer(): boolean {
return (
this.participation && this.participation.role === ParticipantRole.CREATOR
);
}
get shouldShowParticipationButton(): boolean {
// If we have an anonymous participation, don't show the participation button
if (
this.config &&
this.config.anonymous.participation.allowed &&
this.anonymousParticipation
) {
return false;
}
// So that people can cancel their participation
if (this.actorIsParticipant) return true;
// You can participate to draft or cancelled events
if (this.event.draft || this.event.status === EventStatus.CANCELLED)
return false;
// Organizer can't participate
if (this.actorIsOrganizer) return false;
// If capacity is OK
if (this.eventCapacityOK) return true;
// Else
return false;
}
get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return (
this.event.options.maximumAttendeeCapacity >
this.event.participantStats.participant
);
}
get isEventNotAlreadyPassed(): boolean {
return new Date(this.endDate) > new Date();
}
get endDate(): Date {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn
? this.event.endsOn
: this.event.beginsOn;
}
// eslint-disable-next-line class-methods-use-this
get isSecureContext(): boolean {
return window.isSecureContext;
}
async clearEventParticipationData(): Promise<void> {
await removeAnonymousParticipation(this.event.uuid);
window.location.reload();
}
// eslint-disable-next-line class-methods-use-this
clearAllParticipationData(): void {
removeAllAnonymousParticipations();
window.location.reload();
}
}
</script>

View File

@ -1,5 +1,9 @@
<template> <template>
<redirect-with-account :uri="uri" :pathAfterLogin="`/events/${uuid}`" :sentence="sentence" /> <redirect-with-account
:uri="uri"
:pathAfterLogin="`/events/${uuid}`"
:sentence="sentence"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
@ -21,6 +25,8 @@ export default class ParticipationWithAccount extends Vue {
}`; }`;
} }
sentence = this.$t("We will redirect you to your instance in order to interact with this event"); sentence = this.$t(
"We will redirect you to your instance in order to interact with this event"
);
} }
</script> </script>

View File

@ -33,7 +33,13 @@
) )
}} }}
</p> </p>
<p v-else>{{ $t("If you want, you may send a message to the event organizer here.") }}</p> <p v-else>
{{
$t(
"If you want, you may send a message to the event organizer here."
)
}}
</p>
<b-field :label="$t('Message')"> <b-field :label="$t('Message')">
<b-input <b-input
type="textarea" type="textarea"
@ -54,42 +60,93 @@
</p> </p>
</b-checkbox> </b-checkbox>
</b-field> </b-field>
<b-button :disabled="sendingForm" type="is-primary" native-type="submit">{{ <b-button
$t("Send email") :disabled="sendingForm"
}}</b-button> type="is-primary"
native-type="submit"
>{{ $t("Send email") }}</b-button
>
<div class="has-text-centered"> <div class="has-text-centered">
<b-button native-type="button" tag="a" type="is-text" @click="$router.go(-1)">{{ <b-button
$t("Back to previous page") native-type="button"
}}</b-button> tag="a"
type="is-text"
@click="$router.go(-1)"
>{{ $t("Back to previous page") }}</b-button
>
</div> </div>
</form> </form>
<div v-else> <div v-else>
<h1 class="title">{{ $t("Request for participation confirmation sent") }}</h1> <h1 class="title">
<p class="content">{{ $t("Check your inbox (and your junk mail folder).") }}</p> {{ $t("Request for participation confirmation sent") }}
<p class="content">{{ $t("You may now close this window.") }}</p> </h1>
<p class="content">
<span>{{
$t("Check your inbox (and your junk mail folder).")
}}</span>
<span
class="details"
v-if="event.joinOptions === EventJoinOptions.RESTRICTED"
>
{{
$t(
"Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation."
)
}} </span
><span class="details" v-else>{{
$t(
"Your participation will be validated once you click the confirmation link into the email."
)
}}</span>
</p>
<b-message type="is-warning" v-if="error">{{ error }}</b-message>
<p class="content">
<i18n path="You may now close this window, or {return_to_event}.">
<router-link
slot="return_to_event"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>{{ $t("return to the event's page") }}</router-link
>
</i18n>
</p>
</div> </div>
</div> </div>
</div> </div>
<b-message type="is-danger" v-else-if="!$apollo.loading"
>{{
$t(
"Unable to load event for participation. The error details are provided below:"
)
}}
<details>
<pre>{{ error }}</pre>
</details>
</b-message>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { EventModel, IEvent, EventJoinOptions } from "@/types/event.model"; import { EventModel, IEvent } from "@/types/event.model";
import { FETCH_EVENT, JOIN_EVENT } from "@/graphql/event"; import { FETCH_EVENT_BASIC, JOIN_EVENT } from "@/graphql/event";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config"; import { CONFIG } from "@/graphql/config";
import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage"; import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import { IParticipant, ParticipantRole } from "../../types/participant.model"; import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import RouteName from "@/router/name";
import { IParticipant } from "../../types/participant.model";
@Component({ @Component({
apollo: { apollo: {
event: { event: {
query: FETCH_EVENT, query: FETCH_EVENT_BASIC,
variables() { variables() {
return { return {
uuid: this.uuid, uuid: this.uuid,
}; };
}, },
error(e) {
this.error = e;
},
skip() { skip() {
return !this.uuid; return !this.uuid;
}, },
@ -101,7 +158,11 @@ import { IParticipant, ParticipantRole } from "../../types/participant.model";
export default class ParticipationWithoutAccount extends Vue { export default class ParticipationWithoutAccount extends Vue {
@Prop({ type: String, required: true }) uuid!: string; @Prop({ type: String, required: true }) uuid!: string;
anonymousParticipation: { email: string; message: string; saveParticipation: boolean } = { anonymousParticipation: {
email: string;
message: string;
saveParticipation: boolean;
} = {
email: "", email: "",
message: "", message: "",
saveParticipation: true, saveParticipation: true,
@ -119,6 +180,8 @@ export default class ParticipationWithoutAccount extends Vue {
EventJoinOptions = EventJoinOptions; EventJoinOptions = EventJoinOptions;
RouteName = RouteName;
async joinEvent(): Promise<void> { async joinEvent(): Promise<void> {
this.error = false; this.error = false;
this.sendingForm = true; this.sendingForm = true;
@ -134,21 +197,27 @@ export default class ParticipationWithoutAccount extends Vue {
}, },
update: (store, { data: updateData }) => { update: (store, { data: updateData }) => {
if (updateData == null) { if (updateData == null) {
console.error("Cannot update event participant cache, because of data null value."); console.error(
"Cannot update event participant cache, because of data null value."
);
return; return;
} }
const cachedData = store.readQuery<{ event: IEvent }>({ const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT, query: FETCH_EVENT_BASIC,
variables: { uuid: this.event.uuid }, variables: { uuid: this.event.uuid },
}); });
if (cachedData == null) { if (cachedData == null) {
console.error("Cannot update event participant cache, because of cached null value."); console.error(
"Cannot update event participant cache, because of cached null value."
);
return; return;
} }
const { event } = cachedData; const { event } = cachedData;
if (event === null) { if (event === null) {
console.error("Cannot update event participant cache, because of null value."); console.error(
"Cannot update event participant cache, because of null value."
);
return; return;
} }
@ -158,16 +227,15 @@ export default class ParticipationWithoutAccount extends Vue {
event.participantStats.going += 1; event.participantStats.going += 1;
event.participantStats.participant += 1; event.participantStats.participant += 1;
} }
console.log("just before writequery");
store.writeQuery({ store.writeQuery({
query: FETCH_EVENT, query: FETCH_EVENT_BASIC,
variables: { uuid: this.event.uuid }, variables: { uuid: this.event.uuid },
data: { event }, data: { event },
}); });
}, },
}); });
console.log("finished with store", data); this.formSent = true;
if ( if (
data && data &&
data.joinEvent.metadata.cancellationToken && data.joinEvent.metadata.cancellationToken &&
@ -177,13 +245,28 @@ export default class ParticipationWithoutAccount extends Vue {
this.event, this.event,
data.joinEvent.metadata.cancellationToken data.joinEvent.metadata.cancellationToken
); );
console.log("done with crypto stuff");
} }
} catch (e) { } catch (e) {
this.error = e.message; if (
["TextEncoder is not defined", "crypto.subtle is undefined"].includes(
e.message
)
) {
this.error = this.$t(
"Unable to save your participation in this browser."
) as string;
} else if (e.graphQLErrors && e.graphQLErrors.length > 0) {
this.error = e.graphQLErrors[0].message;
} else if (e.networkError) {
this.error = e.networkError.message;
}
} }
this.sendingForm = false; this.sendingForm = false;
this.formSent = true;
} }
} }
</script> </script>
<style lang="scss" scoped>
section.container.section {
background: $white;
}
</style>

View File

@ -2,22 +2,34 @@
<section class="section container hero"> <section class="section container hero">
<div class="hero-body" v-if="event"> <div class="hero-body" v-if="event">
<div class="container"> <div class="container">
<subtitle>{{ $t("You wish to participate to the following event") }}</subtitle> <subtitle>{{
$t("You wish to participate to the following event")
}}</subtitle>
<EventListViewCard v-if="event" :event="event" /> <EventListViewCard v-if="event" :event="event" />
<div class="columns has-text-centered"> <div class="columns has-text-centered">
<div class="column"> <div class="column">
<router-link :to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT }"> <router-link
:to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT }"
>
<figure class="image is-128x128"> <figure class="image is-128x128">
<img src="../../assets/undraw_profile.svg" alt="Profile illustration" /> <img
src="../../assets/undraw_profile.svg"
alt="Profile illustration"
/>
</figure> </figure>
<b-button type="is-primary">{{ $t("I have a Mobilizon account") }}</b-button> <b-button type="is-primary">{{
$t("I have a Mobilizon account")
}}</b-button>
</router-link> </router-link>
<p> <p>
<small> <small>
{{ {{
$t("Either on the {instance} instance or on another instance.", { $t(
instance: host, "Either on the {instance} instance or on another instance.",
}) {
instance: host,
}
)
}} }}
</small> </small>
<b-tooltip <b-tooltip
@ -32,25 +44,41 @@
</b-tooltip> </b-tooltip>
</p> </p>
</div> </div>
<vertical-divider :content="$t('Or')" v-if="anonymousParticipationAllowed" /> <vertical-divider
:content="$t('Or')"
v-if="anonymousParticipationAllowed"
/>
<div <div
class="column" class="column"
v-if="anonymousParticipationAllowed && hasAnonymousEmailParticipationMethod" v-if="
anonymousParticipationAllowed &&
hasAnonymousEmailParticipationMethod
"
> >
<router-link <router-link
:to="{ name: RouteName.EVENT_PARTICIPATE_WITHOUT_ACCOUNT }" :to="{ name: RouteName.EVENT_PARTICIPATE_WITHOUT_ACCOUNT }"
v-if="event.local" v-if="event.local"
> >
<figure class="image is-128x128"> <figure class="image is-128x128">
<img src="../../assets/undraw_mail_2.svg" alt="Privacy illustration" /> <img
src="../../assets/undraw_mail_2.svg"
alt="Privacy illustration"
/>
</figure> </figure>
<b-button type="is-primary">{{ $t("I don't have a Mobilizon account") }}</b-button> <b-button type="is-primary">{{
$t("I don't have a Mobilizon account")
}}</b-button>
</router-link> </router-link>
<a :href="`${event.url}/participate/without-account`" v-else> <a :href="`${event.url}/participate/without-account`" v-else>
<figure class="image is-128x128"> <figure class="image is-128x128">
<img src="../../assets/undraw_mail_2.svg" alt="Privacy illustration" /> <img
src="../../assets/undraw_mail_2.svg"
alt="Privacy illustration"
/>
</figure> </figure>
<b-button type="is-primary">{{ $t("I don't have a Mobilizon account") }}</b-button> <b-button type="is-primary">{{
$t("I don't have a Mobilizon account")
}}</b-button>
</a> </a>
<p> <p>
<small>{{ $t("Participate using your email address") }}</small> <small>{{ $t("Participate using your email address") }}</small>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="root"> <div class="root">
<figure class="image" v-if="actualImageSrc"> <figure class="image" v-if="imageSrc">
<img :src="actualImageSrc" /> <img :src="imageSrc" />
</figure> </figure>
<figure class="image is-128x128" v-else> <figure class="image is-128x128" v-else>
<div class="image-placeholder"> <div class="image-placeholder">
@ -9,12 +9,19 @@
</div> </div>
</figure> </figure>
<b-upload @input="onFileChanged" :accept="accept"> <div class="action-buttons">
<a class="button is-primary"> <b-field class="file is-primary">
<b-icon icon="upload"></b-icon> <b-upload @input="onFileChanged" :accept="accept" class="file-label">
<span>{{ $t("Click to upload") }}</span> <span class="file-cta">
</a> <b-icon class="file-icon" icon="upload" />
</b-upload> <span>{{ $t("Click to upload") }}</span>
</span>
</b-upload>
</b-field>
<b-button type="is-text" v-if="imageSrc" @click="removeOrClearPicture">
{{ $t("Clear") }}
</b-button>
</div>
</div> </div>
</template> </template>
@ -45,18 +52,28 @@ figure.image {
color: #eee; color: #eee;
} }
} }
.action-buttons {
display: flex;
flex-direction: column;
}
</style> </style>
<script lang="ts"> <script lang="ts">
import { IMedia } from "@/types/media.model";
import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator";
@Component @Component
export default class PictureUpload extends Vue { export default class PictureUpload extends Vue {
@Model("change", { type: File }) readonly pictureFile!: File; @Model("change", { type: File }) readonly pictureFile!: File;
@Prop({ type: String, required: false }) defaultImageSrc!: string; @Prop({ type: Object, required: false }) defaultImage!: IMedia;
@Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" }) @Prop({
type: String,
required: false,
default: "image/gif,image/png,image/jpeg,image/webp",
})
accept!: string; accept!: string;
@Prop({ @Prop({
@ -70,10 +87,14 @@ export default class PictureUpload extends Vue {
}) })
textFallback!: string; textFallback!: string;
imageSrc: string | null = null; imageSrc: string | null = this.defaultImage ? this.defaultImage.url : null;
file!: File | null;
mounted(): void { mounted(): void {
this.updatePreview(this.pictureFile); if (this.pictureFile) {
this.updatePreview(this.pictureFile);
}
} }
@Watch("pictureFile") @Watch("pictureFile")
@ -81,13 +102,23 @@ export default class PictureUpload extends Vue {
this.updatePreview(val); this.updatePreview(val);
} }
onFileChanged(file: File): void { @Watch("defaultImage")
onDefaultImageChange(defaultImage: IMedia): void {
this.imageSrc = defaultImage ? defaultImage.url : null;
}
onFileChanged(file: File | null): void {
this.$emit("change", file); this.$emit("change", file);
this.updatePreview(file); this.updatePreview(file);
this.file = file;
} }
private updatePreview(file?: File) { async removeOrClearPicture(): Promise<void> {
this.onFileChanged(null);
}
private updatePreview(file?: File | null) {
if (file) { if (file) {
this.imageSrc = URL.createObjectURL(file); this.imageSrc = URL.createObjectURL(file);
return; return;
@ -95,9 +126,5 @@ export default class PictureUpload extends Vue {
this.imageSrc = null; this.imageSrc = null;
} }
get actualImageSrc(): string | null {
return this.imageSrc || this.defaultImageSrc;
}
} }
</script> </script>

View File

@ -14,26 +14,46 @@
<div class="media-content"> <div class="media-content">
<p class="post-minimalist-title">{{ post.title }}</p> <p class="post-minimalist-title">{{ post.title }}</p>
<div class="metadata"> <div class="metadata">
<b-tag type="is-warning" size="is-small" v-if="post.draft">{{ $t("Draft") }}</b-tag> <b-tag type="is-warning" size="is-small" v-if="post.draft">{{
$t("Draft")
}}</b-tag>
<small <small
v-if="post.visibility === PostVisibility.PUBLIC && isCurrentActorMember" v-if="
post.visibility === PostVisibility.PUBLIC &&
isCurrentActorMember
"
class="has-text-grey" class="has-text-grey"
> >
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small <b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
> >
<small v-else-if="post.visibility === PostVisibility.UNLISTED" class="has-text-grey"> <small
<b-icon icon="link" size="is-small" />{{ $t("Accessible through link") }}</small v-else-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey"
>
<b-icon icon="link" size="is-small" />{{
$t("Accessible through link")
}}</small
>
<small
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey"
> >
<small v-else-if="post.visibility === PostVisibility.PRIVATE" class="has-text-grey">
<b-icon icon="lock" size="is-small" />{{ <b-icon icon="lock" size="is-small" />{{
$t("Accessible only to members", { group: post.attributedTo.name }) $t("Accessible only to members", {
group: post.attributedTo.name,
})
}}</small }}</small
> >
<small class="has-text-grey">{{ <small class="has-text-grey">{{
$options.filters.formatDateTimeString(new Date(post.insertedAt), false) $options.filters.formatDateTimeString(
new Date(post.insertedAt),
false
)
}}</small> }}</small>
<small class="has-text-grey" v-if="isCurrentActorMember">{{ <small class="has-text-grey" v-if="isCurrentActorMember">{{
$t("Created by {username}", { username: `@${usernameWithDomain(post.author)}` }) $t("Created by {username}", {
username: `@${usernameWithDomain(post.author)}`,
})
}}</small> }}</small>
</div> </div>
</div> </div>
@ -43,15 +63,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { usernameWithDomain } from "@/types/actor"; import { usernameWithDomain } from "@/types/actor";
import { PostVisibility } from "@/types/enums";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { IPost, PostVisibility } from "../../types/post.model"; import { IPost } from "../../types/post.model";
@Component @Component
export default class PostElementItem extends Vue { export default class PostElementItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost; @Prop({ required: true, type: Object }) post!: IPost;
@Prop({ required: false, type: Boolean, default: false }) isCurrentActorMember!: boolean; @Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
RouteName = RouteName; RouteName = RouteName;
@ -74,7 +96,8 @@ export default class PostElementItem extends Vue {
.post-minimalist-title { .post-minimalist-title {
color: #3c376e; color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
overflow: hidden; overflow: hidden;

View File

@ -43,7 +43,8 @@ export default class PostListItem extends Vue {
.post-minimalist-title { .post-minimalist-title {
color: #3c376e; color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
overflow: hidden; overflow: hidden;

View File

@ -22,10 +22,18 @@
<div class="content columns"> <div class="content columns">
<div class="column is-one-quarter-desktop"> <div class="column is-one-quarter-desktop">
<span v-if="report.reporter.type === ActorType.APPLICATION"> <span v-if="report.reporter.type === ActorType.APPLICATION">
{{ $t("Reported by someone on {domain}", { domain: report.reporter.domain }) }} {{
$t("Reported by someone on {domain}", {
domain: report.reporter.domain,
})
}}
</span> </span>
<span v-else> <span v-else>
{{ $t("Reported by {reporter}", { reporter: report.reporter.preferredUsername }) }} {{
$t("Reported by {reporter}", {
reporter: report.reporter.preferredUsername,
})
}}
</span> </span>
</div> </div>
<div class="column" v-if="report.content" v-html="report.content" /> <div class="column" v-if="report.content" v-html="report.content" />
@ -36,7 +44,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IReport } from "@/types/report.model"; import { IReport } from "@/types/report.model";
import { ActorType } from "@/types/actor"; import { ActorType } from "@/types/enums";
@Component @Component
export default class ReportCard extends Vue { export default class ReportCard extends Vue {

View File

@ -4,7 +4,10 @@
<p class="modal-card-title">{{ title }}</p> <p class="modal-card-title">{{ title }}</p>
</header> </header>
<section class="modal-card-body is-flex" :class="{ 'is-titleless': !title }"> <section
class="modal-card-body is-flex"
:class="{ 'is-titleless': !title }"
>
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<b-icon icon="alert" type="is-warning" size="is-large" /> <b-icon icon="alert" type="is-warning" size="is-large" />
@ -16,7 +19,12 @@
<figure class="image is-48x48" v-if="comment.actor.avatar"> <figure class="image is-48x48" v-if="comment.actor.avatar">
<img :src="comment.actor.avatar.url" alt="Image" /> <img :src="comment.actor.avatar.url" alt="Image" />
</figure> </figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
</div> </div>
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
@ -82,7 +90,10 @@ import { IComment } from "../../types/comment.model";
}, },
}) })
export default class ReportModal extends Vue { export default class ReportModal extends Vue {
@Prop({ type: Function }) onConfirm!: Function; @Prop({ type: Function }) onConfirm!: (
content: string,
forward: boolean
) => void;
@Prop({ type: String }) title!: string; @Prop({ type: String }) title!: string;

View File

@ -14,7 +14,9 @@
</div> </div>
<div class="body"> <div class="body">
<h3>{{ resource.title }}</h3> <h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span> <span class="host" v-if="inline">{{
resource.updatedAt | formatDateTimeString
}}</span>
</div> </div>
<draggable <draggable
v-if="!inline" v-if="!inline"
@ -93,21 +95,27 @@ export default class FolderItem extends Mixins(ResourceMixin) {
async moveResource(resource: IResource): Promise<IResource | undefined> { async moveResource(resource: IResource): Promise<IResource | undefined> {
try { try {
const { data } = await this.$apollo.mutate<{ updateResource: IResource }>({ const { data } = await this.$apollo.mutate<{ updateResource: IResource }>(
mutation: UPDATE_RESOURCE, {
variables: { mutation: UPDATE_RESOURCE,
id: resource.id, variables: {
path: `${this.resource.path}/${resource.title}`, id: resource.id,
parentId: this.resource.id, path: `${this.resource.path}/${resource.title}`,
}, parentId: this.resource.id,
}); },
}
);
if (!data) { if (!data) {
console.error("Error while updating resource"); console.error("Error while updating resource");
return undefined; return undefined;
} }
return data.updateResource; return data.updateResource;
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
return undefined; return undefined;
} }
} }

View File

@ -17,7 +17,7 @@
</b-dropdown> </b-dropdown>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
@Component @Component
export default class ResourceDropdown extends Vue {} export default class ResourceDropdown extends Vue {}

View File

@ -2,7 +2,12 @@
<div class="resource-wrapper"> <div class="resource-wrapper">
<a :href="resource.resourceUrl" target="_blank"> <a :href="resource.resourceUrl" target="_blank">
<div class="preview"> <div class="preview">
<div v-if="resource.type && Object.keys(mapServiceTypeToIcon).includes(resource.type)"> <div
v-if="
resource.type &&
Object.keys(mapServiceTypeToIcon).includes(resource.type)
"
>
<b-icon :icon="mapServiceTypeToIcon[resource.type]" size="is-large" /> <b-icon :icon="mapServiceTypeToIcon[resource.type]" size="is-large" />
</div> </div>
<div <div
@ -21,7 +26,9 @@
:src="resource.metadata.faviconUrl" :src="resource.metadata.faviconUrl"
/> />
<h3>{{ resource.title }}</h3> <h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span> <span class="host" v-if="inline">{{
resource.updatedAt | formatDateTimeString
}}</span>
<span class="host" v-else>{{ urlHostname }}</span> <span class="host" v-else>{{ urlHostname }}</span>
</div> </div>
</a> </a>

View File

@ -2,9 +2,15 @@
<div v-if="resource"> <div v-if="resource">
<article class="panel is-primary"> <article class="panel is-primary">
<p class="panel-heading"> <p class="panel-heading">
{{ $t('Move "{resourceName}"', { resourceName: initialResource.title }) }} {{
$t('Move "{resourceName}"', { resourceName: initialResource.title })
}}
</p> </p>
<a class="panel-block clickable" @click="resource = resource.parent" v-if="resource.parent"> <a
class="panel-block clickable"
@click="resource = resource.parent"
v-if="resource.parent"
>
<span class="panel-icon"> <span class="panel-icon">
<b-icon icon="chevron-up" size="is-small" /> <b-icon icon="chevron-up" size="is-small" />
</span> </span>
@ -23,12 +29,19 @@
<a <a
class="panel-block" class="panel-block"
v-for="element in resource.children.elements" v-for="element in resource.children.elements"
:class="{ clickable: element.type === 'folder' && element.id !== initialResource.id }" :class="{
clickable:
element.type === 'folder' && element.id !== initialResource.id,
}"
:key="element.id" :key="element.id"
@click="goDown(element)" @click="goDown(element)"
> >
<span class="panel-icon"> <span class="panel-icon">
<b-icon icon="folder" size="is-small" v-if="element.type === 'folder'" /> <b-icon
icon="folder"
size="is-small"
v-if="element.type === 'folder'"
/>
<b-icon icon="link" size="is-small" v-else /> <b-icon icon="link" size="is-small" v-else />
</span> </span>
{{ element.title }} {{ element.title }}
@ -44,10 +57,17 @@
{{ $t("No resources in this folder") }} {{ $t("No resources in this folder") }}
</p> </p>
</article> </article>
<b-button type="is-primary" @click="updateResource" :disabled="moveDisabled">{{ <b-button
$t("Move resource to {folder}", { folder: resource.title }) type="is-primary"
@click="updateResource"
:disabled="moveDisabled"
>{{
$t("Move resource to {folder}", { folder: resource.title })
}}</b-button
>
<b-button type="is-text" @click="$emit('close-move-modal')">{{
$t("Cancel")
}}</b-button> }}</b-button>
<b-button type="is-text" @click="$emit('closeMoveModal')">{{ $t("Cancel") }}</b-button>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -81,31 +101,34 @@ export default class ResourceSelector extends Vue {
resource: IResource | undefined = this.initialResource.parent; resource: IResource | undefined = this.initialResource.parent;
goDown(element: IResource) { goDown(element: IResource): void {
if (element.type === "folder" && element.id !== this.initialResource.id) { if (element.type === "folder" && element.id !== this.initialResource.id) {
this.resource = element; this.resource = element;
} }
} }
updateResource() { updateResource(): void {
this.$emit( this.$emit(
"updateResource", "update-resource",
{ {
id: this.initialResource.id, id: this.initialResource.id,
title: this.initialResource.title, title: this.initialResource.title,
parent: this.resource && this.resource.path === "/" ? null : this.resource, parent:
this.resource && this.resource.path === "/" ? null : this.resource,
path: this.initialResource.path, path: this.initialResource.path,
}, },
this.initialResource.parent this.initialResource.parent
); );
} }
get moveDisabled() { get moveDisabled(): boolean | undefined {
return ( return (
(this.initialResource.parent && (this.initialResource.parent &&
this.resource && this.resource &&
this.initialResource.parent.path === this.resource.path) || this.initialResource.parent.path === this.resource.path) ||
(this.initialResource.parent == undefined && this.resource && this.resource.path === "/") (this.initialResource.parent === undefined &&
this.resource &&
this.resource.path === "/")
); );
} }
} }

View File

@ -23,9 +23,9 @@ export default class SearchField extends Vue {
search = ""; search = "";
enter() { async enter(): Promise<void> {
this.$emit("navbar-search"); this.$emit("navbar-search");
this.$router.push({ await this.$router.push({
name: RouteName.SEARCH, name: RouteName.SEARCH,
query: { term: this.search }, query: { term: this.search },
}); });

View File

@ -15,16 +15,27 @@
</p> </p>
</div> </div>
<div class="field"> <div class="field">
<b-checkbox v-model="notificationOnDay" @input="updateSetting({ notificationOnDay })"> <b-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong> <strong>{{ $t("Notification on the day of the event") }}</strong>
<p> <p>
{{ {{
$t("We'll use your timezone settings to send a recap of the morning of the event.") $t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}} }}
</p> </p>
</b-checkbox> </b-checkbox>
</div> </div>
<p>{{ $t("To activate more notifications, head over to the notification settings.") }}</p> <p>
{{
$t(
"To activate more notifications, head over to the notification settings."
)
}}
</p>
</section> </section>
</div> </div>
</template> </template>
@ -50,7 +61,11 @@ export default class NotificationsOnboarding extends mixins(Onboarding) {
try { try {
this.doUpdateSetting(variables); this.doUpdateSetting(variables);
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
} }

View File

@ -16,7 +16,7 @@ export default class SettingMenuItem extends Vue {
@Prop({ required: true, type: Object }) to!: Route; @Prop({ required: true, type: Object }) to!: Route;
get isActive() { get isActive(): boolean {
if (!this.to) return false; if (!this.to) return false;
if (this.to.name === this.$route.name) { if (this.to.name === this.$route.name) {
if (this.to.params) { if (this.to.params) {

View File

@ -20,11 +20,12 @@ export default class SettingMenuSection extends Vue {
@Prop({ required: true, type: Object }) to!: Route; @Prop({ required: true, type: Object }) to!: Route;
get sectionActive() { get sectionActive(): boolean {
if (this.$slots.default) { if (this.$slots.default) {
return this.$slots.default.some( return this.$slots.default.some(
({ ({
componentOptions: { componentOptions: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
propsData: { to }, propsData: { to },
}, },

View File

@ -1,18 +1,27 @@
<template> <template>
<aside> <aside>
<ul> <ul>
<SettingMenuSection :title="$t('Account')" :to="{ name: RouteName.ACCOUNT_SETTINGS }"> <SettingMenuSection
:title="$t('Account')"
:to="{ name: RouteName.ACCOUNT_SETTINGS }"
>
<SettingMenuItem <SettingMenuItem
:title="this.$t('General')" :title="this.$t('General')"
:to="{ name: RouteName.ACCOUNT_SETTINGS_GENERAL }" :to="{ name: RouteName.ACCOUNT_SETTINGS_GENERAL }"
/> />
<SettingMenuItem :title="$t('Preferences')" :to="{ name: RouteName.PREFERENCES }" /> <SettingMenuItem
:title="$t('Preferences')"
:to="{ name: RouteName.PREFERENCES }"
/>
<SettingMenuItem <SettingMenuItem
:title="this.$t('Email notifications')" :title="this.$t('Email notifications')"
:to="{ name: RouteName.NOTIFICATIONS }" :to="{ name: RouteName.NOTIFICATIONS }"
/> />
</SettingMenuSection> </SettingMenuSection>
<SettingMenuSection :title="$t('Profiles')" :to="{ name: RouteName.IDENTITIES }"> <SettingMenuSection
:title="$t('Profiles')"
:to="{ name: RouteName.IDENTITIES }"
>
<SettingMenuItem <SettingMenuItem
v-for="profile in identities" v-for="profile in identities"
:key="profile.preferredUsername" :key="profile.preferredUsername"
@ -22,7 +31,10 @@
params: { identityName: profile.preferredUsername }, params: { identityName: profile.preferredUsername },
}" }"
/> />
<SettingMenuItem :title="$t('New profile')" :to="{ name: RouteName.CREATE_IDENTITY }" /> <SettingMenuItem
:title="$t('New profile')"
:to="{ name: RouteName.CREATE_IDENTITY }"
/>
</SettingMenuSection> </SettingMenuSection>
<SettingMenuSection <SettingMenuSection
v-if=" v-if="
@ -33,35 +45,54 @@
:title="$t('Moderation')" :title="$t('Moderation')"
:to="{ name: RouteName.MODERATION }" :to="{ name: RouteName.MODERATION }"
> >
<SettingMenuItem :title="$t('Reports')" :to="{ name: RouteName.REPORTS }" /> <SettingMenuItem
<SettingMenuItem :title="$t('Moderation log')" :to="{ name: RouteName.REPORT_LOGS }" /> :title="$t('Reports')"
:to="{ name: RouteName.REPORTS }"
/>
<SettingMenuItem
:title="$t('Moderation log')"
:to="{ name: RouteName.REPORT_LOGS }"
/>
<SettingMenuItem :title="$t('Users')" :to="{ name: RouteName.USERS }" /> <SettingMenuItem :title="$t('Users')" :to="{ name: RouteName.USERS }" />
<SettingMenuItem :title="$t('Profiles')" :to="{ name: RouteName.PROFILES }" /> <SettingMenuItem
<SettingMenuItem :title="$t('Groups')" :to="{ name: RouteName.ADMIN_GROUPS }" /> :title="$t('Profiles')"
:to="{ name: RouteName.PROFILES }"
/>
<SettingMenuItem
:title="$t('Groups')"
:to="{ name: RouteName.ADMIN_GROUPS }"
/>
</SettingMenuSection> </SettingMenuSection>
<SettingMenuSection <SettingMenuSection
v-if="this.currentUser.role == ICurrentUserRole.ADMINISTRATOR" v-if="this.currentUser.role == ICurrentUserRole.ADMINISTRATOR"
:title="$t('Admin')" :title="$t('Admin')"
:to="{ name: RouteName.ADMIN }" :to="{ name: RouteName.ADMIN }"
> >
<SettingMenuItem :title="$t('Dashboard')" :to="{ name: RouteName.ADMIN_DASHBOARD }" /> <SettingMenuItem
:title="$t('Dashboard')"
:to="{ name: RouteName.ADMIN_DASHBOARD }"
/>
<SettingMenuItem <SettingMenuItem
:title="$t('Instance settings')" :title="$t('Instance settings')"
:to="{ name: RouteName.ADMIN_SETTINGS }" :to="{ name: RouteName.ADMIN_SETTINGS }"
/> />
<SettingMenuItem :title="$t('Federation')" :to="{ name: RouteName.RELAYS }" /> <SettingMenuItem
:title="$t('Federation')"
:to="{ name: RouteName.RELAYS }"
/>
</SettingMenuSection> </SettingMenuSection>
</ul> </ul>
</aside> </aside>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { ICurrentUserRole } from "@/types/enums";
import SettingMenuSection from "./SettingMenuSection.vue"; import SettingMenuSection from "./SettingMenuSection.vue";
import SettingMenuItem from "./SettingMenuItem.vue"; import SettingMenuItem from "./SettingMenuItem.vue";
import { IDENTITIES } from "../../graphql/actor"; import { IDENTITIES } from "../../graphql/actor";
import { IPerson, Person } from "../../types/actor"; import { IPerson, Person } from "../../types/actor";
import { CURRENT_USER_CLIENT } from "../../graphql/user"; import { CURRENT_USER_CLIENT } from "../../graphql/user";
import { ICurrentUser, ICurrentUserRole } from "../../types/current-user.model"; import { ICurrentUser } from "../../types/current-user.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -70,7 +101,8 @@ import RouteName from "../../router/name";
apollo: { apollo: {
identities: { identities: {
query: IDENTITIES, query: IDENTITIES,
update: (data) => data.identities.map((identity: IPerson) => new Person(identity)), update: (data) =>
data.identities.map((identity: IPerson) => new Person(identity)),
}, },
currentUser: CURRENT_USER_CLIENT, currentUser: CURRENT_USER_CLIENT,
}, },

View File

@ -39,7 +39,10 @@
timezone, timezone,
}) })
}} }}
<b-message type="is-danger" v-if="!$apollo.loading && !supportedTimezone"> <b-message
type="is-danger"
v-if="!$apollo.loading && !supportedTimezone"
>
{{ $t("Your timezone {timezone} isn't supported.", { timezone }) }} {{ $t("Your timezone {timezone} isn't supported.", { timezone }) }}
</b-message> </b-message>
</p> </p>

View File

@ -2,9 +2,10 @@
<div class="card" v-if="todo"> <div class="card" v-if="todo">
<div class="card-content"> <div class="card-content">
<b-checkbox v-model="status" /> <b-checkbox v-model="status" />
<router-link :to="{ name: RouteName.TODO, params: { todoId: todo.id } }">{{ <router-link
todo.title :to="{ name: RouteName.TODO, params: { todoId: todo.id } }"
}}</router-link> >{{ todo.title }}</router-link
>
<span class="details has-text-grey"> <span class="details has-text-grey">
<span v-if="todo.dueDate" class="due_date"> <span v-if="todo.dueDate" class="due_date">
<b-icon icon="calendar" /> <b-icon icon="calendar" />
@ -13,7 +14,9 @@
<span v-if="todo.assignedTo" class="assigned_to"> <span v-if="todo.assignedTo" class="assigned_to">
<b-icon icon="account" /> <b-icon icon="account" />
{{ `@${todo.assignedTo.preferredUsername}` }} {{ `@${todo.assignedTo.preferredUsername}` }}
<span v-if="todo.assignedTo.domain">{{ `@${todo.assignedTo.domain}` }}</span> <span v-if="todo.assignedTo.domain">{{
`@${todo.assignedTo.domain}`
}}</span>
</span> </span>
</span> </span>
</div> </div>
@ -53,7 +56,11 @@ export default class Todo extends Vue {
}); });
this.editMode = false; this.editMode = false;
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
} }

View File

@ -18,7 +18,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { debounce } from "lodash"; import { debounce, DebouncedFunc } from "lodash";
import { SnackbarProgrammatic as Snackbar } from "buefy"; import { SnackbarProgrammatic as Snackbar } from "buefy";
import { ITodo } from "../../types/todos"; import { ITodo } from "../../types/todos";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -36,7 +36,9 @@ export default class Todo extends Vue {
editMode = false; editMode = false;
debounceUpdateTodo!: Function; debounceUpdateTodo!: DebouncedFunc<
(obj: Record<string, unknown>) => Promise<void>
>;
// We put this in data because of issues like // We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263 // https://github.com/vuejs/vue-class-component/issues/263
@ -89,7 +91,11 @@ export default class Todo extends Vue {
}); });
this.editMode = false; this.editMode = false;
} catch (e) { } catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" }); Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
} }
} }
} }

View File

@ -7,7 +7,11 @@
<b-icon :icon="oauthProvider.id" /> <b-icon :icon="oauthProvider.id" />
<span>{{ SELECTED_PROVIDERS[oauthProvider.id] }}</span></a <span>{{ SELECTED_PROVIDERS[oauthProvider.id] }}</span></a
> >
<a class="button is-light" :href="`/auth/${oauthProvider.id}`" v-else-if="isProviderSelected"> <a
class="button is-light"
:href="`/auth/${oauthProvider.id}`"
v-else-if="isProviderSelected"
>
<b-icon icon="lock" /> <b-icon icon="lock" />
<span>{{ oauthProvider.label }}</span> <span>{{ oauthProvider.label }}</span>
</a> </a>

View File

@ -20,7 +20,9 @@
</div> </div>
<vertical-divider :content="$t('Or')" /> <vertical-divider :content="$t('Or')" />
<div class="column"> <div class="column">
<subtitle>{{ $t("I have an account on another Mobilizon instance.") }}</subtitle> <subtitle>{{
$t("I have an account on another Mobilizon instance.")
}}</subtitle>
<p>{{ $t("Other software may also support this.") }}</p> <p>{{ $t("Other software may also support this.") }}</p>
<p>{{ sentence }}</p> <p>{{ sentence }}</p>
<form @submit.prevent="redirectToInstance"> <form @submit.prevent="redirectToInstance">
@ -34,7 +36,9 @@
:placeholder="$t('profile@instance')" :placeholder="$t('profile@instance')"
></b-input> ></b-input>
<p class="control"> <p class="control">
<button class="button is-primary" type="submit">{{ $t("Go") }}</button> <button class="button is-primary" type="submit">
{{ $t("Go") }}
</button>
</p> </p>
</b-field> </b-field>
</b-field> </b-field>
@ -54,7 +58,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import VerticalDivider from "@/components/Utils/VerticalDivider.vue"; import VerticalDivider from "@/components/Utils/VerticalDivider.vue";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import { LoginErrorCode } from "@/types/login-error-code.model"; import { LoginErrorCode } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -80,14 +84,22 @@ export default class RedirectWithAccount extends Vue {
async redirectToInstance(): Promise<void> { async redirectToInstance(): Promise<void> {
const [, host] = this.remoteActorAddress.split("@", 2); const [, host] = this.remoteActorAddress.split("@", 2);
const remoteInteractionURI = await this.webFingerFetch(host, this.remoteActorAddress); const remoteInteractionURI = await this.webFingerFetch(
host,
this.remoteActorAddress
);
window.open(remoteInteractionURI); window.open(remoteInteractionURI);
} }
private async webFingerFetch(hostname: string, identity: string): Promise<string> { private async webFingerFetch(
hostname: string,
identity: string
): Promise<string> {
const scheme = process.env.NODE_ENV === "production" ? "https" : "http"; const scheme = process.env.NODE_ENV === "production" ? "https" : "http";
const data = await ( const data = await (
await fetch(`${scheme}://${hostname}/.well-known/webfinger?resource=acct:${identity}`) await fetch(
`${scheme}://${hostname}/.well-known/webfinger?resource=acct:${identity}`
)
).json(); ).json();
if (data && Array.isArray(data.links)) { if (data && Array.isArray(data.links)) {
const link: { template: string } = data.links.find( const link: { template: string } = data.links.find(

View File

@ -14,7 +14,10 @@ function formatDateString(value: string): string {
} }
function formatTimeString(value: string): string { function formatTimeString(value: string): string {
return parseDateTime(value).toLocaleTimeString(undefined, { hour: "numeric", minute: "numeric" }); return parseDateTime(value).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "numeric",
});
} }
function formatDateTimeString(value: string, showTime = true): string { function formatDateTimeString(value: string, showTime = true): string {

View File

@ -1,7 +1,12 @@
import nl2br from "@/filters/utils"; import nl2br from "@/filters/utils";
import { formatDateString, formatTimeString, formatDateTimeString } from "./datetime"; import {
formatDateString,
formatTimeString,
formatDateTimeString,
} from "./datetime";
export default { export default {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
install(vue: any): void { install(vue: any): void {
vue.filter("formatDateString", formatDateString); vue.filter("formatDateString", formatDateString);
vue.filter("formatTimeString", formatTimeString); vue.filter("formatTimeString", formatTimeString);

View File

@ -10,6 +10,7 @@ export const FETCH_PERSON = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
mediaSize
avatar { avatar {
id id
name name
@ -51,6 +52,7 @@ export const GET_PERSON = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
mediaSize
avatar { avatar {
id id
name name
@ -63,7 +65,10 @@ export const GET_PERSON = gql`
feedTokens { feedTokens {
token token
} }
organizedEvents(page: $organizedEventsPage, limit: $organizedEventsLimit) { organizedEvents(
page: $organizedEventsPage
limit: $organizedEventsLimit
) {
total total
elements { elements {
id id
@ -206,6 +211,18 @@ export const LOGGED_USER_PARTICIPATIONS = gql`
url url
} }
} }
attributedTo {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
participantStats { participantStats {
going going
notApproved notApproved
@ -215,6 +232,11 @@ export const LOGGED_USER_PARTICIPATIONS = gql`
maximumAttendeeCapacity maximumAttendeeCapacity
remainingAttendeeCapacity remainingAttendeeCapacity
} }
tags {
id
slug
title
}
} }
id id
role role
@ -282,6 +304,16 @@ export const LOGGED_USER_MEMBERSHIPS = gql`
elements { elements {
id id
role role
actor {
id
avatar {
id
url
}
preferredUsername
name
domain
}
parent { parent {
id id
preferredUsername preferredUsername
@ -419,7 +451,7 @@ export const CREATE_PERSON = gql`
$preferredUsername: String! $preferredUsername: String!
$name: String! $name: String!
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
) { ) {
createPerson( createPerson(
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
@ -440,7 +472,12 @@ export const CREATE_PERSON = gql`
`; `;
export const UPDATE_PERSON = gql` export const UPDATE_PERSON = gql`
mutation UpdatePerson($id: ID!, $name: String, $summary: String, $avatar: PictureInput) { mutation UpdatePerson(
$id: ID!
$name: String
$summary: String
$avatar: MediaInput
) {
updatePerson(id: $id, name: $name, summary: $summary, avatar: $avatar) { updatePerson(id: $id, name: $name, summary: $summary, avatar: $avatar) {
id id
preferredUsername preferredUsername
@ -467,7 +504,12 @@ export const DELETE_PERSON = gql`
* Prefer CREATE_PERSON when creating another identity * Prefer CREATE_PERSON when creating another identity
*/ */
export const REGISTER_PERSON = gql` export const REGISTER_PERSON = gql`
mutation($preferredUsername: String!, $name: String!, $summary: String!, $email: String!) { mutation(
$preferredUsername: String!
$name: String!
$summary: String!
$email: String!
) {
registerPerson( registerPerson(
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
name: $name name: $name

View File

@ -70,13 +70,11 @@ export const COMMENTS_THREADS = gql`
export const CREATE_COMMENT_FROM_EVENT = gql` export const CREATE_COMMENT_FROM_EVENT = gql`
mutation CreateCommentFromEvent( mutation CreateCommentFromEvent(
$eventId: ID! $eventId: ID!
$actorId: ID!
$text: String! $text: String!
$inReplyToCommentId: ID $inReplyToCommentId: ID
) { ) {
createComment( createComment(
eventId: $eventId eventId: $eventId
actorId: $actorId
text: $text text: $text
inReplyToCommentId: $inReplyToCommentId inReplyToCommentId: $inReplyToCommentId
) { ) {

View File

@ -51,6 +51,9 @@ export const CONFIG = gql`
endpoint endpoint
attribution attribution
} }
routing {
type
}
} }
geocoding { geocoding {
provider provider

View File

@ -84,8 +84,8 @@ export const DISCUSSION_FIELDS_FRAGMENT = gql`
`; `;
export const CREATE_DISCUSSION = gql` export const CREATE_DISCUSSION = gql`
mutation createDiscussion($title: String!, $creatorId: ID!, $actorId: ID!, $text: String!) { mutation createDiscussion($title: String!, $actorId: ID!, $text: String!) {
createDiscussion(title: $title, text: $text, creatorId: $creatorId, actorId: $actorId) { createDiscussion(title: $title, text: $text, actorId: $actorId) {
...DiscussionFields ...DiscussionFields
} }
} }

View File

@ -176,57 +176,73 @@ export const FETCH_EVENT = gql`
} }
`; `;
export const FETCH_EVENT_BASIC = gql`
query($uuid: UUID!) {
event(uuid: $uuid) {
id
uuid
joinOptions
participantStats {
going
notApproved
notConfirmed
participant
}
}
}
`;
export const FETCH_EVENTS = gql` export const FETCH_EVENTS = gql`
query { query {
events { events {
id, total
uuid, elements {
url,
local,
title,
description,
beginsOn,
endsOn,
status,
visibility,
picture {
id id
uuid
url url
}, local
publishAt, title
# online_address, description
# phone_address, beginsOn
physicalAddress { endsOn
id, status
description, visibility
locality picture {
},
organizerActor {
id,
avatar {
id id
url url
}, }
preferredUsername, publishAt
domain, # online_address,
name, # phone_address,
}, physicalAddress {
# attributedTo { id
# avatar { description
# id locality
# url }
# }, organizerActor {
# preferredUsername, id
# name, avatar {
# }, id
category, url
participants { }
${participantsQuery} preferredUsername
}, domain
tags { name
slug, }
title # attributedTo {
}, # avatar {
# id
# url
# },
# preferredUsername,
# name,
# },
category
tags {
slug
title
}
}
} }
} }
`; `;
@ -244,7 +260,7 @@ export const CREATE_EVENT = gql`
$joinOptions: EventJoinOptions, $joinOptions: EventJoinOptions,
$draft: Boolean, $draft: Boolean,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: MediaInput,
$onlineAddress: String, $onlineAddress: String,
$phoneAddress: String, $phoneAddress: String,
$category: String, $category: String,
@ -355,7 +371,7 @@ export const EDIT_EVENT = gql`
$joinOptions: EventJoinOptions, $joinOptions: EventJoinOptions,
$draft: Boolean, $draft: Boolean,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: MediaInput,
$onlineAddress: String, $onlineAddress: String,
$phoneAddress: String, $phoneAddress: String,
$organizerActorId: ID, $organizerActorId: ID,
@ -498,8 +514,8 @@ export const CONFIRM_PARTICIPATION = gql`
`; `;
export const UPDATE_PARTICIPANT = gql` export const UPDATE_PARTICIPANT = gql`
mutation AcceptParticipant($id: ID!, $moderatorActorId: ID!, $role: ParticipantRoleEnum!) { mutation UpdateParticipant($id: ID!, $role: ParticipantRoleEnum!) {
updateParticipation(id: $id, moderatorActorId: $moderatorActorId, role: $role) { updateParticipation(id: $id, role: $role) {
role role
id id
} }
@ -507,20 +523,20 @@ export const UPDATE_PARTICIPANT = gql`
`; `;
export const DELETE_EVENT = gql` export const DELETE_EVENT = gql`
mutation DeleteEvent($eventId: ID!, $actorId: ID!) { mutation DeleteEvent($eventId: ID!) {
deleteEvent(eventId: $eventId, actorId: $actorId) { deleteEvent(eventId: $eventId) {
id id
} }
} }
`; `;
export const PARTICIPANTS = gql` export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String, $actorId: ID!) { query($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
event(uuid: $uuid) { event(uuid: $uuid) {
id, id,
uuid, uuid,
title, title,
participants(page: $page, limit: $limit, roles: $roles, actorId: $actorId) { participants(page: $page, limit: $limit, roles: $roles) {
${participantsQuery} ${participantsQuery}
}, },
participantStats { participantStats {
@ -606,3 +622,54 @@ export const GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED = gql`
} }
} }
`; `;
export const FETCH_GROUP_EVENTS = gql`
query(
$name: String!
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
) {
group(preferredUsername: $name) {
id
preferredUsername
domain
name
organizedEvents(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
) {
elements {
id
uuid
title
beginsOn
draft
options {
maximumAttendeeCapacity
}
participantStats {
participant
notApproved
}
attributedTo {
id
preferredUsername
name
domain
}
organizerActor {
id
preferredUsername
name
domain
}
}
total
}
}
}
`;

View File

@ -95,6 +95,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
uuid uuid
title title
beginsOn beginsOn
draft
options { options {
maximumAttendeeCapacity maximumAttendeeCapacity
} }
@ -212,6 +213,7 @@ export const GET_GROUP = gql`
$organisedEventslimit: Int $organisedEventslimit: Int
) { ) {
getGroup(id: $id) { getGroup(id: $id) {
mediaSize
...GroupFullFields ...GroupFullFields
} }
} }
@ -223,15 +225,13 @@ export const GET_GROUP = gql`
export const CREATE_GROUP = gql` export const CREATE_GROUP = gql`
mutation CreateGroup( mutation CreateGroup(
$creatorActorId: ID!
$preferredUsername: String! $preferredUsername: String!
$name: String! $name: String!
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
$banner: PictureInput $banner: MediaInput
) { ) {
createGroup( createGroup(
creatorActorId: $creatorActorId
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
name: $name name: $name
summary: $summary summary: $summary
@ -260,8 +260,8 @@ export const UPDATE_GROUP = gql`
$id: ID! $id: ID!
$name: String $name: String
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
$banner: PictureInput $banner: MediaInput
$visibility: GroupVisibility $visibility: GroupVisibility
$openness: Openness $openness: Openness
$physicalAddress: AddressInput $physicalAddress: AddressInput

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