merge with version 1.0.3 for osm

This commit is contained in:
tykayn 2020-12-30 18:52:32 +01:00 committed by Baptiste Lemoine
commit 2415b116a1
529 changed files with 28733 additions and 15995 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

@ -1,10 +1,101 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. 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
@ -163,20 +254,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Special operations ### Special operations
* We added `application/ld+json` as acceptable MIME type for ActivityPub requests, so you'll need to recompile the `mime` library we use before recompiling Mobilizon: - We added `application/ld+json` as acceptable MIME type for ActivityPub requests, so you'll need to recompile the `mime` library we use before recompiling Mobilizon:
```
MIX_ENV=prod mix deps.clean mime --build
```
* The [nginx configuration](https://framagit.org/framasoft/mobilizon/-/blob/master/support/nginx/mobilizon.conf) has been changed with improvements and support for custom error pages. ```
MIX_ENV=prod mix deps.clean mime --build
```
* The cmake dependency has been added (see [our documentation](https://docs.joinmobilizon.org/administration/dependencies/#basic-tools)) - The [nginx configuration](https://framagit.org/framasoft/mobilizon/-/blob/master/support/nginx/mobilizon.conf) has been changed with improvements and support for custom error pages.
- The cmake dependency has been added (see [our documentation](https://docs.joinmobilizon.org/administration/dependencies/#basic-tools))
### Added ### Added
- Possibility to login using LDAP - Possibility to login using LDAP
- Possibility to login using OAuth providers - Possibility to login using OAuth providers
- Enabled group features in production mode - Enabled group features in production mode
- including posts (that can be public, unlisted, or restricted to your group members) - including posts (that can be public, unlisted, or restricted to your group members)
- resources (collections of links, with folders, accessible to your group members) - resources (collections of links, with folders, accessible to your group members)
- discussions (group private and organized chats) - discussions (group private and organized chats)
@ -200,11 +292,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### Security
- Fix group settings being accessible and editable by non-group-admins (thx @pigpig for reporting this responsibly) - Fix group settings being accessible and editable by non-group-admins (thx @pigpig for reporting this responsibly)
- Fix events being editable by profiles without permissions (thx @pigpig for reporting this responsibly) - Fix events being editable by profiles without permissions (thx @pigpig for reporting this responsibly)
## [1.0.0-beta.3] - 2020-06-24 ## [1.0.0-beta.3] - 2020-06-24
### Special operations ### Special operations
Config has moved from `.env` files to a more traditional way to handle things in the Elixir world, with `.exs` files. Config has moved from `.env` files to a more traditional way to handle things in the Elixir world, with `.exs` files.
To migrate existing configuration, you can simply run `mix mobilizon.instance gen` and fill in the adequate values previously in `.env` files (you don't need to perform the operations to create the database). To migrate existing configuration, you can simply run `mix mobilizon.instance gen` and fill in the adequate values previously in `.env` files (you don't need to perform the operations to create the database).
@ -214,6 +307,7 @@ A minimal file template [is available](https://framagit.org/framasoft/mobilizon/
Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service). Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service).
### Added ### Added
- Possibility to participate to an event without an account (confirmation through email required) - Possibility to participate to an event without an account (confirmation through email required)
- Possibility to participate to a remote event (being redirected by providing federated identity) - Possibility to participate to a remote event (being redirected by providing federated identity)
- Possibility to add a note as a participant when event participation is manually validated (required when participating without an account) - Possibility to add a note as a participant when event participation is manually validated (required when participating without an account)
@ -230,6 +324,7 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
- Allow user to change language - Allow user to change language
### Changed ### Changed
- Configuration handling (see above) - Configuration handling (see above)
- Improved a bit color theme - Improved a bit color theme
- Signature validation also now checks if `Date` header has acceptable values - Signature validation also now checks if `Date` header has acceptable values
@ -240,6 +335,7 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
- Improved public event page - Improved public event page
### Fixed ### Fixed
- Fixed URL search - Fixed URL search
- Fixed content accessed through URL search being public - Fixed content accessed through URL search being public
- Fix event links in some emails - Fix event links in some emails
@ -247,17 +343,21 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
## [1.0.0-beta.2] - 2019-12-18 ## [1.0.0-beta.2] - 2019-12-18
### Special operations ### Special operations
These two operations couldn't be handled during migrations. These two operations couldn't be handled during migrations.
They are optional, but you won't be able to search or get participant stats on existing events if they are not executed. They are optional, but you won't be able to search or get participant stats on existing events if they are not executed.
These commands will be removed in Mobilizon 1.0.0-beta.3. These commands will be removed in Mobilizon 1.0.0-beta.3.
In order to populate search index for existing events, you need to run the following command (with prod environment): In order to populate search index for existing events, you need to run the following command (with prod environment):
* `mix mobilizon.setup_search`
- `mix mobilizon.setup_search`
In order to move participant stats to the event table for existing events, you need to run the following command (with prod environment): In order to move participant stats to the event table for existing events, you need to run the following command (with prod environment):
* `mix mobilizon.move_participant_stats`
- `mix mobilizon.move_participant_stats`
### Added ### Added
- Federation is active - Federation is active
- Added an interface for admins to view and manage instance followers and followings - Added an interface for admins to view and manage instance followers and followings
- Ability to comment below events - Ability to comment below events
@ -282,6 +382,7 @@ In order to move participant stats to the event table for existing events, you n
- Upgraded frontend and backend dependencies - Upgraded frontend and backend dependencies
### Changed ### Changed
- Move participant stats to event table **(read special instructions above)** - Move participant stats to event table **(read special instructions above)**
- Limit length (20 characters) and number (10) of tags allowed - Limit length (20 characters) and number (10) of tags allowed
- Added some backend changes and validation for field length - Added some backend changes and validation for field length
@ -295,6 +396,7 @@ In order to move participant stats to the event table for existing events, you n
- Also consider the PeerTube `CommentsEnabled` property to know if you can reply to an event - Also consider the PeerTube `CommentsEnabled` property to know if you can reply to an event
### Fixed ### Fixed
- Fix event URL validation and check if hostname is correct before showing it - Fix event URL validation and check if hostname is correct before showing it
- Fix participations stats on the MyEvents page - Fix participations stats on the MyEvents page
- Fix event description lists margin - Fix event description lists margin
@ -324,8 +426,11 @@ In order to move participant stats to the event table for existing events, you n
- Fixed event HTML representation when `GET` request has no `Accept` header - Fixed event HTML representation when `GET` request has no `Accept` header
### Security ### Security
- Sanitize event title to avoid XSS - Sanitize event title to avoid XSS
## [1.0.0-beta.1] - 2019-10-15 ## [1.0.0-beta.1] - 2019-10-15
### Added ### Added
- Initial release - Initial release

View File

@ -20,7 +20,7 @@ Mobilizon is your federated organization and mobilization platform. Gather peopl
Mobilizon is a tool designed to create platforms for managing communities and events. Its purpose is to help as many people as possible to free themselves from Facebook groups and events, from Meetup, etc. Mobilizon is a tool designed to create platforms for managing communities and events. Its purpose is to help as many people as possible to free themselves from Facebook groups and events, from Meetup, etc.
The Mobilizon software is under a Free licence, so anyone can host a Mobilizon server, called an instance. These instances may federate with each other, so any person with an account on *ExampleMeet* will be able to register to an event created on *SpecimenEvent*. The Mobilizon software is under a Free licence, so anyone can host a Mobilizon server, called an instance. These instances may federate with each other, so any person with an account on _ExampleMeet_ will be able to register to an event created on _SpecimenEvent_.
## ✨ Features ## ✨ Features
@ -33,7 +33,7 @@ You will have the power to create multiple identities from the same account, lik
### 📅 Events and groups ### 📅 Events and groups
Create your events and make sure they will appeal to everybody. Create your events and make sure they will appeal to everybody.
Privacy settings and participants roles are supported. Privacy settings and participants roles are supported.
There's no lock-in, you can interact with the event without registration. There's no lock-in, you can interact with the event without registration.
@ -46,23 +46,26 @@ We appreciate any contribution to Mobilizon. Check our [CONTRIBUTING](CONTRIBUTI
## Links ## Links
### Learn more ### Learn more
* 🌐 Official website: [https://joinmobilizon.org](https://joinmobilizon.org)
* 🔢 Pick an instance [https://mobilizon.org](https://mobilizon.org) - 🌐 Official website: [https://joinmobilizon.org](https://joinmobilizon.org)
* 💻 Source: [https://framagit.org/framasoft/mobilizon](https://framagit.org/framasoft/mobilizon) - 🔢 Pick an instance [https://mobilizon.org](https://mobilizon.org)
* 📜 Documentation [https://docs.joinmobilizon.org](https://docs.joinmobilizon.org) - 💻 Source: [https://framagit.org/framasoft/mobilizon](https://framagit.org/framasoft/mobilizon)
- 📜 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)
* 🗣️ Forum: [https://framacolibri.org/c/mobilizon](https://framacolibri.org/c/mobilizon) - 💬 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)
### Follow ### Follow
* 🐘 Mastodon: [https://framapiaf.org/@mobilizon](https://framapiaf.org/@mobilizon)
* 🐦 Twitter [https://twitter.com/@joinmobilizon](https://twitter.com/@joinmobilizon) - 🐘 Mastodon: [https://framapiaf.org/@mobilizon](https://framapiaf.org/@mobilizon)
- 🐦 Twitter [https://twitter.com/@joinmobilizon](https://twitter.com/@joinmobilizon)
Note: Most federation code comes from [Pleroma](https://pleroma.social), which is `Copyright © 2017-2018 Pleroma Authors - AGPL-3.0`. Note: Most federation code comes from [Pleroma](https://pleroma.social), which is `Copyright © 2017-2018 Pleroma Authors - AGPL-3.0`.
## ❤️ Supports of our crowdfunding ## ❤️ Supports of our crowdfunding
--- ---
We have run [a crowdfunding campaign](https://framablog.org/2019/05/14/mobilizon-lets-finance-a-software-to-free-our-events-from-facebook/) to pave the road to the version 1.0.0 of Mobilizon. Thanks to everyone who pitched in and shared the news around! The list of [everyone who donated is available here](https://joinmobilizon.org/hall-of-fame). We have run [a crowdfunding campaign](https://framablog.org/2019/05/14/mobilizon-lets-finance-a-software-to-free-our-events-from-facebook/) to pave the road to the version 1.0.0 of Mobilizon. Thanks to everyone who pitched in and shared the news around! The list of [everyone who donated is available here](https://joinmobilizon.org/hall-of-fame).

View File

@ -5,15 +5,15 @@ Framasoft, the Mobilizon maintainer team and community take all security bugs in
### Goals ### Goals
* Mobilizon users can understand the distinctions between public data and private data/metadata on Mobilizon. - Mobilizon users can understand the distinctions between public data and private data/metadata on Mobilizon.
* Users always know where their private data/metadata resides, who has access to it, and are able to access, export, and delete it. - Users always know where their private data/metadata resides, who has access to it, and are able to access, export, and delete it.
* Protect private user data/metadata, not just from hackers but also (as much as is possible) from other users, instance admins, community moderators, and external applications. - Protect private user data/metadata, not just from hackers but also (as much as is possible) from other users, instance admins, community moderators, and external applications.
* Secure from malicious creation, alteration or deletion of public data. - Secure from malicious creation, alteration or deletion of public data.
* GDPR compliance. - GDPR compliance.
Framasoft is both a developer of open-source/free/libre self-hosted software, and a service provider with users in the European Union. As a result, we are putting user privacy, data sovereignty, and GDPR compliance into our security plans, including asking both the Framasoft community and outside hackers to review our approaches and implementations. Framasoft is both a developer of open-source/free/libre self-hosted software, and a service provider with users in the European Union. As a result, we are putting user privacy, data sovereignty, and GDPR compliance into our security plans, including asking both the Framasoft community and outside hackers to review our approaches and implementations.
@ -21,11 +21,11 @@ Framasoft is both a developer of open-source/free/libre self-hosted software, an
[Mobilizon](https://joinmobilizon.org) will be challenging to keep secure, as it is: [Mobilizon](https://joinmobilizon.org) will be challenging to keep secure, as it is:
* open source, both back-end and front-end - open source, both back-end and front-end
* self-hosted by diverse organisations and individuals - self-hosted by diverse organisations and individuals
* federated (data is transmitted between different hosted instances) - federated (data is transmitted between different hosted instances)
This means there are more attack surfaces compared to typical proprietary, centralised platforms, but also means that hackers and even users can review every part of Mobilizon and make sure that it works as expected. This should result in more secure software, and higher trust in the application and its ecosystem. This means there are more attack surfaces compared to typical proprietary, centralised platforms, but also means that hackers and even users can review every part of Mobilizon and make sure that it works as expected. This should result in more secure software, and higher trust in the application and its ecosystem.
@ -33,14 +33,14 @@ This means there are more attack surfaces compared to typical proprietary, centr
We are committed to working with security researchers to verify, reproduce, and respond to legitimate reported vulnerabilities. You can help us by following these simple guidelines: We are committed to working with security researchers to verify, reproduce, and respond to legitimate reported vulnerabilities. You can help us by following these simple guidelines:
* Alert us about the vulnerability as soon as you become aware of it by emailing the lead maintainer at tcit+mobilizon@framasoft.org. - Alert us about the vulnerability as soon as you become aware of it by emailing the lead maintainer at tcit+mobilizon@framasoft.org.
* Provide details needed to reproduce and validate the vulnerability and a Proof of Concept (PoC) as soon as possible - Provide details needed to reproduce and validate the vulnerability and a Proof of Concept (PoC) as soon as possible
* Act in good faith to avoid privacy violations, destruction of data, and interruption or degradation of services - Act in good faith to avoid privacy violations, destruction of data, and interruption or degradation of services
* Do not access or modify users private data, without explicit permission of the owner. Only interact with your own accounts or test accounts for security research purposes; - Do not access or modify users private data, without explicit permission of the owner. Only interact with your own accounts or test accounts for security research purposes;
* Contact Framasoft or a maintainer of the Mobilizon project (or the instance admin) immediately if you do inadvertently encounter user data. Do not view, alter, save, store, transfer, or otherwise access the data, and immediately purge any local information upon reporting the vulnerability; - Contact Framasoft or a maintainer of the Mobilizon project (or the instance admin) immediately if you do inadvertently encounter user data. Do not view, alter, save, store, transfer, or otherwise access the data, and immediately purge any local information upon reporting the vulnerability;
* The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. - The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
* Give us time to confirm, determine the affected versions and prepare fixes to correct the issue before disclosing it to other parties (if after waiting a reasonable amount of time, we are clearly unable or unwilling to do anything about it, please do hold us accountable!) - Give us time to confirm, determine the affected versions and prepare fixes to correct the issue before disclosing it to other parties (if after waiting a reasonable amount of time, we are clearly unable or unwilling to do anything about it, please do hold us accountable!)
* Please test against a local instance of the software, and refrain from running any Denial of Service or automated testing tools against Framasoft's (and our partners') infrastructure - Please test against a local instance of the software, and refrain from running any Denial of Service or automated testing tools against Framasoft's (and our partners') infrastructure
Note : Please report security bugs in third-party modules to the person or team maintaining the module. Note : Please report security bugs in third-party modules to the person or team maintaining the module.

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,4 +1,4 @@
version: '3' version: "3"
services: services:
postgres: postgres:

View File

@ -1,4 +1,4 @@
version: '3' version: "3"
services: services:
postgres: postgres:
@ -15,7 +15,7 @@ services:
restart: unless-stopped restart: unless-stopped
build: . build: .
volumes: volumes:
- '.:/app' - ".:/app"
ports: ports:
- "4000:4000" - "4000:4000"
depends_on: depends_on:

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

@ -16,7 +16,6 @@ a.out,
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }
.section { .section {
padding: 1rem 1% 4rem; padding: 1rem 1% 4rem;
} }

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,30 +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="date-component"> <div class="date-component">
<date-calendar-icon :date="participation.event.beginsOn" /> <date-calendar-icon :date="participation.event.beginsOn" />
</div> </div>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }"> <router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<h3 class="title">{{ participation.event.title }}</h3> <h3 class="title">{{ participation.event.title }}</h3>
</router-link> </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
> >
@ -32,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>
@ -53,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",
@ -68,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">
@ -103,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") }}</b-button name: RouteName.DUPLICATE_EVENT,
> 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>
@ -187,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";
@ -234,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 {
@ -246,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({
@ -267,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>
@ -293,74 +340,71 @@ article.box {
line-height: 1.75em; line-height: 1.75em;
} }
} }
div.content {
.list-card {
display: flex;
align-items: center;
.actions {
padding-right: 7.5px;
cursor: pointer;
}
div.content {
flex: 1;
padding: 5px;
.participation-actor span,
.participant-stats span {
padding: 0 5px;
button {
height: auto;
padding-top: 0;
}
}
div.title-wrapper {
display: flex;
align-items: center;
div.date-component {
flex: 0;
margin-right: 16px;
}
a {
text-decoration: none;
}
.title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 400;
line-height: 1em;
font-size: 1.6em;
padding-bottom: 5px;
margin: auto 0;
}
}
}
}
.identity-header {
background: $yellow-2;
display: flex;
padding: 5px; padding: 5px;
.participation-actor span, figure {
.participant-stats span { padding-right: 3px;
padding: 0 5px;
button {
height: auto;
padding-top: 0;
}
}
div.title-wrapper {
display: flex;
align-items: center;
div.date-component {
flex: 0;
margin-right: 16px;
}
a {
text-decoration: none;
}
.title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 400;
line-height: 1em;
font-size: 1.6em;
padding-bottom: 5px;
margin: auto 0;
}
} }
} }
.actions { & > .columns {
ul li { padding: 1.25rem;
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;
}
}
} }
padding: 0;
} }
</style> </style>

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,22 +1,53 @@
<template> <template>
<footer class="footer" ref="footer"> <footer class="footer" ref="footer">
<!-- <img :src="`/img/pics/footer_${random}.jpg`" alt="" />--> <!-- <img :src="`/img/pics/footer_${random}.jpg`" alt="" />-->
<a href="https://openstreetmap.fr"> <a href="https://openstreetmap.fr">
<h1 class="title is-1"> <h1 class="title is-1">OpenStreetMap.fr</h1>
OpenStreetMap.fr
</h1>
</a> </a>
<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>
<li> <li>
<router-link :to="{ name: RouteName.TERMS }">{{ $t("Terms") }}</router-link> <router-link :to="{ name: RouteName.ABOUT }">{{
$t("About")
}}</router-link>
</li> </li>
<li> <li>
<a hreflang="en" href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"> <router-link :to="{ name: RouteName.TERMS }">{{
$t("Terms")
}}</router-link>
</li>
<li>
<a
hreflang="en"
href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"
>
{{ $t("License") }} {{ $t("License") }}
</a> </a>
</li> </li>
@ -26,33 +57,57 @@
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")
}}</a> }}</a>
</i18n> </i18n>
</div> </div>
</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>
@import "~bulma/sass/utilities/mixins.sass"; @import "~bulma/sass/utilities/mixins.sass";
footer.footer { footer.footer {
color: white; color: white;
background: $primary; background: $primary;
@ -95,5 +150,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

@ -1,18 +1,36 @@
<template> <template>
<MobilizonLogo /> <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>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import MobilizonLogo from "../assets/mobilizon_logo.svg?inline";
@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,7 +1,15 @@
<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 }" :aria-label="$t('Home')"> <b-navbar-item
tag="router-link"
:to="{ name: RouteName.HOME }"
:aria-label="$t('Home')"
>
<logo /> <logo />
</b-navbar-item> </b-navbar-item>
</template> </template>
@ -19,9 +27,12 @@
>{{ $t("My groups") }} >{{ $t("My groups") }}
</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">
@ -30,9 +41,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>
@ -65,9 +84,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') }}-->
@ -95,9 +116,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>
@ -109,13 +132,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";
@ -130,7 +158,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;
}, },
@ -201,7 +231,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

@ -21,7 +21,8 @@ h2 {
display: inline; display: inline;
padding: 3px 8px; padding: 3px 8px;
color: #3a384c; color: #3a384c;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-weight: 400; font-weight: 400;
font-size: 32px; font-size: 32px;
} }

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

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