Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
- Added possibility to follow groups and be notified from new upcoming events - Export list of participants to CSV, `PDF` and `ODS` - Allow to set timezone for an event. The timezone is automatically defined from the address if one is defined. If the event timezone is different than the user's current one, a toggle is shown to switch between the two. - Added initial support for Right To Left languages (such as arabic) and [BiDi](https://en.wikipedia.org/wiki/Bidirectional_text) - Group followers and members get an notification email by default when a group publishes a new event (subject to activity notification settings) - Group admins can now approve or deny new memberships - Build releases in `arm` and `arm64` format in addition to `amd64` - Build Docker images in `arm` and `arm64` format in addition to `amd64` - Added possibility to indicate the event is fully online - Added possibility to search only for online events - Added possibility to search only in past events - Detect event, comments and posts languages automatically. Allows setting language - Allow to change an user's password through the users.modify mix task - Add instance setting so that only the admin can create groups - Add instance setting so that only groups can create events - Added JSON-LD metadata about the event in emails - Added a quick link to email notification settings at the bottom of emails - Allow to access Mobilizon with a specific language directly by using `https://instance.tld/lang` where `lang` is a language supported by Mobilizon - Added organizer actor name (profile or group) in the icalendar export - Add initial support for federation with Gancio - Multiple UI improvements, including post, event and participation cards, discussions and emails. The « My Events » page was also redesigned to allow showing events from your groups. - Various accessibility improvements - Event update notification is send to participants ~30 minutes after the event update, so that successive edits are throttled. - Event, post and comments titles and content now have expose their detected language in HTML, for improved screen reader experience - Delete current actor ID as well from local storage when unlogging - Show a default text for instance contact in default terms text when no instance contact is set - Only show locatecontrol button in leaflet map when we can do geolocation - Disable push column in notification settings when push is not available - Show actual language instead of language code in Users admin view - Empty old & new passwords fields when successful password change - Don't link to the group page from admin when actor is suspended - Warn participants when the event organizer is suspended (and therefore the event cancelled) - Improve metadata on public page - Make sure some event action pages (participate remotely or without an account) don't get indexed by search engines - Only send `Tombstone` element in `Delete` activities, not the whole previous deleted element. - Make sure `Delete` activity are send correctly to everyone - Only add address and tags to event icalendar export if they exist - `master` branch has been renamed to `main` - Mention following groups on the registration page - Add missing group name to activity notifications - Warn while registering and logging when the email contains uppercase characters - Improve json-ld metadata on event live streams - Add "eventAttendanceMode" to JSON-ld schema.org event representation - Improve sending pending participation notifications - Add "formerType" and "delete" attributes on Tombstones ActivityPub objects representation - Improve MyEvents page description text - Support for Elixir < 1.12 and OTP < 22 - Fix tags autocomplete - Fix config onboarding after LDAP initial connexion - Fix events pagination on tags page - Fixed deduplicated files from orphan media being deleted as well - Fix deleting own account - Fix search returning user profiles instead of only groups - Fix federating geo coordinates - Fix an issue with group activity items when moving resources - Fix an issue with Identity Picker - Fix an issue with TagInput - Fix an issue when leaving a group - Fix admin settings edition - Fix an issue when showing public page of suspended group - Removed non existing page (`/about/mobilizon`) from sitemap - Fix action logs containing group suspension events - Fixed group physical address not exposed to ActivityPub - Release front-end files are no longer in duplicate - Only show datetime timezone toggle on event if the timezone offset is different from our own - Fix error when determining audience for Discussion when deleting a comment - Fix a couple of accessibility issues - Limit to acceptable tags when pasting raw HTML into comment fields on front-end - Fixed group map display - Fixed updating group physical address - Allow group members to access group drafts - Improve group refreshment workflow - Fixed date signature generation for federation - Fixed an issue when duplicating a group event from another profile - Fixed event metadata not saved on eventcreation - Use a different pagination parameter for searched events and featured events on search page - Fixed creating group activities when creating events with some fields - Move release package at correct path for CI upload - Fixed event contacts that were not exposed and fetched over federation - Don't sign fetch when fetching actor for a given signature - Some various HTTP signatures issues - Fixed actor AP representation of avatar - Handle errors when fetching actor pictures - Fixed sending group events to followers on Mastodon - Fixed actors avatars and banners being deleted if the same file was also an orphan media - Fix spacing in organizer picker - Increase number of close events and follow group events - Fix accessing user profile in admin section - Set initial values for some EventMetadata elements, fixing submitting them right away with no value - Avoid giving an error page if the apollo futureParticipations query is undefined - Fixed path to exports in production - Fixed padding below truncated title of event cards - Fixed exports that weren't enabled in Docker - Fixed error page when event end date is null - Fixed event language not being allowed to be null - Fixed private messages sent as event replies from Mastodon that were shown publically as public comments. They are now discarded. - Czech - Gaelic - German - Hungarian - Indonesian - Norwegian Nynorsk - Occitan - Persian - Portuguese (Brazil) - Russian - Slovenian - Spanish -----BEGIN PGP SIGNATURE----- iQIzBAABCAAdFiEExMITpfxOHHCvHn8FoGG53eDKB3MFAmGcqEMACgkQoGG53eDK B3NlaA/8DoAi7WtC+//xJn8tRSP04PuTRDi2+9/D9a6gjTsiPXDZdb7LtEHuCg1H PXsHNQOTafUM/T7b7XTGmKOST3Sz2kw4eKJArCyF+NuvCwiO/Iw/v1wev2Mo8vvC eDBbMkfiCnMHcytqheeM9gvGuRyIOfgQ9uPk54snfa9a+SGELR5XDRKhwBlGAs6i nkUPbOB72oCou79HZ6CjyLTG6CoWUVsheuvAEhYw52e5JlWSJb9yOUdnOYUV1sr6 OzLct996Z3IOQX4ToaQ+Re99tOaEyqO98aHsv+Wbz128sku0WrfseKn9zi3PL6cF LYjtZ9+0dwdNi3MfgKoEoWJaMlN3+6WUw/blcVP+6b6Ibn5YV/HkVacke/rGoAry oiEjP4HFKnvT83dTBn+LRcU6MY3MrZsarjUACjcKIwpTiylw9gaqA0i7dPBdW35p Q4c1gIh1Q/aE5OKCxXGLrg6s1SNZ754cAyEVo85UnF8Iu4wiaY++ImvyG8xIoOWf vQuya3LcDT8Gj9KY/LWMrVT8LJ2ij5t8oRMFLIHLlfiWbq05m6QaBIWr5sLjLO/N w2N1//ZHNM3sJNl0bZgc9g4lhcdj52VhOcHLot4fdcg5RTektCK0ky5uTD4WJ3GP keihAR8ZtFLaiAQ+XN6ng3IhrgQcpIdJBLlHzgFBx2rTBy8a0Tw= =jyrf -----END PGP SIGNATURE----- Merge tag '2.0.0' into chapril Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well. - Added possibility to follow groups and be notified from new upcoming events - Export list of participants to CSV, `PDF` and `ODS` - Allow to set timezone for an event. The timezone is automatically defined from the address if one is defined. If the event timezone is different than the user's current one, a toggle is shown to switch between the two. - Added initial support for Right To Left languages (such as arabic) and [BiDi](https://en.wikipedia.org/wiki/Bidirectional_text) - Group followers and members get an notification email by default when a group publishes a new event (subject to activity notification settings) - Group admins can now approve or deny new memberships - Build releases in `arm` and `arm64` format in addition to `amd64` - Build Docker images in `arm` and `arm64` format in addition to `amd64` - Added possibility to indicate the event is fully online - Added possibility to search only for online events - Added possibility to search only in past events - Detect event, comments and posts languages automatically. Allows setting language - Allow to change an user's password through the users.modify mix task - Add instance setting so that only the admin can create groups - Add instance setting so that only groups can create events - Added JSON-LD metadata about the event in emails - Added a quick link to email notification settings at the bottom of emails - Allow to access Mobilizon with a specific language directly by using `https://instance.tld/lang` where `lang` is a language supported by Mobilizon - Added organizer actor name (profile or group) in the icalendar export - Add initial support for federation with Gancio - Multiple UI improvements, including post, event and participation cards, discussions and emails. The « My Events » page was also redesigned to allow showing events from your groups. - Various accessibility improvements - Event update notification is send to participants ~30 minutes after the event update, so that successive edits are throttled. - Event, post and comments titles and content now have expose their detected language in HTML, for improved screen reader experience - Delete current actor ID as well from local storage when unlogging - Show a default text for instance contact in default terms text when no instance contact is set - Only show locatecontrol button in leaflet map when we can do geolocation - Disable push column in notification settings when push is not available - Show actual language instead of language code in Users admin view - Empty old & new passwords fields when successful password change - Don't link to the group page from admin when actor is suspended - Warn participants when the event organizer is suspended (and therefore the event cancelled) - Improve metadata on public page - Make sure some event action pages (participate remotely or without an account) don't get indexed by search engines - Only send `Tombstone` element in `Delete` activities, not the whole previous deleted element. - Make sure `Delete` activity are send correctly to everyone - Only add address and tags to event icalendar export if they exist - `master` branch has been renamed to `main` - Mention following groups on the registration page - Add missing group name to activity notifications - Warn while registering and logging when the email contains uppercase characters - Improve json-ld metadata on event live streams - Add "eventAttendanceMode" to JSON-ld schema.org event representation - Improve sending pending participation notifications - Add "formerType" and "delete" attributes on Tombstones ActivityPub objects representation - Improve MyEvents page description text - Support for Elixir < 1.12 and OTP < 22 - Fix tags autocomplete - Fix config onboarding after LDAP initial connexion - Fix events pagination on tags page - Fixed deduplicated files from orphan media being deleted as well - Fix deleting own account - Fix search returning user profiles instead of only groups - Fix federating geo coordinates - Fix an issue with group activity items when moving resources - Fix an issue with Identity Picker - Fix an issue with TagInput - Fix an issue when leaving a group - Fix admin settings edition - Fix an issue when showing public page of suspended group - Removed non existing page (`/about/mobilizon`) from sitemap - Fix action logs containing group suspension events - Fixed group physical address not exposed to ActivityPub - Release front-end files are no longer in duplicate - Only show datetime timezone toggle on event if the timezone offset is different from our own - Fix error when determining audience for Discussion when deleting a comment - Fix a couple of accessibility issues - Limit to acceptable tags when pasting raw HTML into comment fields on front-end - Fixed group map display - Fixed updating group physical address - Allow group members to access group drafts - Improve group refreshment workflow - Fixed date signature generation for federation - Fixed an issue when duplicating a group event from another profile - Fixed event metadata not saved on eventcreation - Use a different pagination parameter for searched events and featured events on search page - Fixed creating group activities when creating events with some fields - Move release package at correct path for CI upload - Fixed event contacts that were not exposed and fetched over federation - Don't sign fetch when fetching actor for a given signature - Some various HTTP signatures issues - Fixed actor AP representation of avatar - Handle errors when fetching actor pictures - Fixed sending group events to followers on Mastodon - Fixed actors avatars and banners being deleted if the same file was also an orphan media - Fix spacing in organizer picker - Increase number of close events and follow group events - Fix accessing user profile in admin section - Set initial values for some EventMetadata elements, fixing submitting them right away with no value - Avoid giving an error page if the apollo futureParticipations query is undefined - Fixed path to exports in production - Fixed padding below truncated title of event cards - Fixed exports that weren't enabled in Docker - Fixed error page when event end date is null - Fixed event language not being allowed to be null - Fixed private messages sent as event replies from Mastodon that were shown publically as public comments. They are now discarded. - Czech - Gaelic - German - Hungarian - Indonesian - Norwegian Nynorsk - Occitan - Persian - Portuguese (Brazil) - Russian - Slovenian - Spanish
This commit is contained in:
commit
fa372387aa
15
.doctor.exs
Normal file
15
.doctor.exs
Normal file
@ -0,0 +1,15 @@
|
||||
%Doctor.Config{
|
||||
exception_moduledoc_required: true,
|
||||
failed: false,
|
||||
ignore_modules: [Mobilizon.Web, Mobilizon.GraphQL.Schema, Mobilizon.Service.Activity.Renderer, Mobilizon.Service.Workers.Helper],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 100,
|
||||
min_module_spec_coverage: 50,
|
||||
min_overall_doc_coverage: 100,
|
||||
min_overall_spec_coverage: 90,
|
||||
moduledoc_required: true,
|
||||
raise: false,
|
||||
reporter: Doctor.Reporters.Full,
|
||||
struct_type_spec_required: true,
|
||||
umbrella: false
|
||||
}
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -27,15 +27,19 @@ priv/data/*
|
||||
priv/errors/*
|
||||
!priv/errors/.gitkeep
|
||||
priv/cert/
|
||||
priv/python/__pycache__/
|
||||
.vscode/
|
||||
cover/
|
||||
site/
|
||||
test/fixtures/image_tmp.jpg
|
||||
test/fixtures/picture_tmp.png
|
||||
test/fixtures/DSCN0010_tmp.jpg
|
||||
test/uploads/
|
||||
uploads/*
|
||||
release/
|
||||
!uploads/.gitkeep
|
||||
!uploads/exports/.gitkeep
|
||||
!uploads/exports/**/.gitkeep
|
||||
.idea
|
||||
*.mo
|
||||
*.po~
|
||||
@ -43,4 +47,5 @@ release/
|
||||
docker/production/.env
|
||||
test-junit-report.xml
|
||||
js/junit.xml
|
||||
.env
|
||||
.env
|
||||
demo/
|
||||
|
153
.gitlab-ci.yml
153
.gitlab-ci.yml
@ -28,6 +28,10 @@ variables:
|
||||
# Release elements
|
||||
PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${CI_PROJECT_NAME}"
|
||||
ARCH: "amd64"
|
||||
EXPORT_FORMATS: "csv,ods,pdf"
|
||||
APP_VERSION: "${CI_COMMIT_REF_NAME}"
|
||||
APP_ASSET: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_${ARCH}.tar.gz"
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
|
||||
cache:
|
||||
key: "${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
|
||||
@ -100,23 +104,6 @@ deps:
|
||||
needs:
|
||||
- install
|
||||
|
||||
exunit-1.11:
|
||||
stage: test
|
||||
image: tcitworld/mobilizon-ci:legacy
|
||||
services:
|
||||
- name: postgis/postgis:11-3.0
|
||||
alias: postgres
|
||||
variables:
|
||||
MIX_ENV: test
|
||||
before_script:
|
||||
- mix deps.clean --all
|
||||
- mix deps.get
|
||||
- mix ecto.create
|
||||
- mix ecto.migrate
|
||||
script:
|
||||
- mix coveralls
|
||||
allow_failure: true
|
||||
|
||||
exunit:
|
||||
stage: test
|
||||
services:
|
||||
@ -125,7 +112,7 @@ exunit:
|
||||
variables:
|
||||
MIX_ENV: test
|
||||
before_script:
|
||||
- mix deps.get
|
||||
- mix deps.get && mix tz_world.update
|
||||
- mix ecto.create
|
||||
- mix ecto.migrate
|
||||
script:
|
||||
@ -185,7 +172,7 @@ pages:
|
||||
# #- yarn run --cwd "js" styleguide:build
|
||||
# #- mv js/styleguide public/frontend
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "master"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
artifacts:
|
||||
expire_in: 1 hour
|
||||
paths:
|
||||
@ -193,24 +180,50 @@ pages:
|
||||
|
||||
.docker: &docker
|
||||
stage: docker
|
||||
image: docker:stable
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
DOCKER_HOST: tcp://docker:2376
|
||||
DOCKER_TLS_VERIFY: 1
|
||||
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
|
||||
DOCKER_DRIVER: overlay2
|
||||
services:
|
||||
- docker:stable-dind
|
||||
cache: {}
|
||||
image:
|
||||
name: gcr.io/kaniko-project/executor:debug
|
||||
entrypoint: [""]
|
||||
before_script:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$CI_REGISTRY_AUTH\",\"email\":\"$CI_REGISTRY_EMAIL\"}}}" > /kaniko/.docker/config.json
|
||||
# Install buildx
|
||||
- wget https://github.com/docker/buildx/releases/download/v0.6.3/buildx-v0.6.3.linux-amd64
|
||||
- mkdir -p ~/.docker/cli-plugins/
|
||||
- mv buildx-v0.6.3.linux-amd64 ~/.docker/cli-plugins/docker-buildx
|
||||
- chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
# Create env
|
||||
- docker context create tls-environment
|
||||
- docker buildx create --use tls-environment
|
||||
# Install qemu/binfmt
|
||||
- docker pull tonistiigi/binfmt:latest
|
||||
- docker run --rm --privileged tonistiigi/binfmt:latest --install all
|
||||
# Login to DockerHub
|
||||
- mkdir -p ~/.docker
|
||||
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$CI_REGISTRY_AUTH\",\"email\":\"$CI_REGISTRY_EMAIL\"}}}" > ~/.docker/config.json
|
||||
script:
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/docker/production/Dockerfile --destination $DOCKER_IMAGE_NAME --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP
|
||||
- >
|
||||
docker buildx build
|
||||
--push
|
||||
--platform linux/amd64,linux/arm64,linux/arm
|
||||
-t $DOCKER_IMAGE_NAME
|
||||
-f docker/production/Dockerfile .
|
||||
tags:
|
||||
- "privileged"
|
||||
timeout: 3 hours
|
||||
|
||||
build-docker-master:
|
||||
build-docker-main:
|
||||
<<: *docker
|
||||
rules:
|
||||
- if: '$CI_PROJECT_NAMESPACE != "framasoft"'
|
||||
when: never
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
variables:
|
||||
DOCKER_IMAGE_NAME: framasoft/mobilizon:master
|
||||
DOCKER_IMAGE_NAME: framasoft/mobilizon:main
|
||||
|
||||
build-docker-tag:
|
||||
<<: *docker
|
||||
@ -221,6 +234,7 @@ build-docker-tag:
|
||||
variables:
|
||||
DOCKER_IMAGE_NAME: framasoft/mobilizon:$CI_COMMIT_TAG
|
||||
|
||||
# Packaging app for amd64
|
||||
package-app:
|
||||
stage: package
|
||||
variables: &release-variables
|
||||
@ -228,16 +242,21 @@ package-app:
|
||||
script: &release-script
|
||||
- mix local.hex --force
|
||||
- mix local.rebar --force
|
||||
- mix deps.get
|
||||
- mix phx.digest
|
||||
- mix deps.get --only-prod
|
||||
- mix compile
|
||||
- mix phx.digest.clean --all && \
|
||||
- mix release --path release/mobilizon
|
||||
- cd release/mobilizon && ln -s lib/mobilizon-*/priv priv
|
||||
- cd release/mobilizon && ln -s lib/mobilizon-*/priv priv && cd ../../
|
||||
- du -sh release/
|
||||
- 'echo "Artifact: ${APP_ASSET}"'
|
||||
- tar czf ${APP_ASSET} -C release mobilizon
|
||||
- du -sh ${APP_ASSET}
|
||||
only:
|
||||
- tags@framasoft/mobilizon
|
||||
artifacts:
|
||||
expire_in: 30 days
|
||||
paths:
|
||||
- release
|
||||
- ${APP_ASSET}
|
||||
|
||||
package-app-dev:
|
||||
stage: package
|
||||
@ -248,20 +267,63 @@ package-app-dev:
|
||||
artifacts:
|
||||
expire_in: 2 days
|
||||
paths:
|
||||
- release
|
||||
- ${APP_ASSET}
|
||||
|
||||
# Packaging app for multi-arch
|
||||
multi-arch-release:
|
||||
stage: package
|
||||
image: docker:stable
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
DOCKER_HOST: tcp://docker:2376
|
||||
DOCKER_TLS_VERIFY: 1
|
||||
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
|
||||
DOCKER_DRIVER: overlay2
|
||||
APP_ASSET: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_${ARCH}.tar.gz"
|
||||
services:
|
||||
- docker:stable-dind
|
||||
cache: {}
|
||||
before_script:
|
||||
# Install buildx
|
||||
- wget https://github.com/docker/buildx/releases/download/v0.6.3/buildx-v0.6.3.linux-amd64
|
||||
- mkdir -p ~/.docker/cli-plugins/
|
||||
- mv buildx-v0.6.3.linux-amd64 ~/.docker/cli-plugins/docker-buildx
|
||||
- chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
# Create env
|
||||
- docker context create tls-environment
|
||||
- docker buildx create --use tls-environment
|
||||
# Install qemu/binfmt
|
||||
- docker pull tonistiigi/binfmt:latest
|
||||
- docker run --rm --privileged tonistiigi/binfmt:latest --install all
|
||||
script:
|
||||
- docker buildx build --platform linux/${ARCH} --output type=local,dest=releases --build-arg APP_ASSET=${APP_ASSET} -f docker/multiarch/Dockerfile .
|
||||
- ls -alh releases/mobilizon/
|
||||
- du -sh releases/mobilizon/${APP_ASSET}
|
||||
- mv releases/mobilizon/${APP_ASSET} .
|
||||
tags:
|
||||
- "privileged"
|
||||
artifacts:
|
||||
expire_in: 30 days
|
||||
paths:
|
||||
- ${APP_ASSET}
|
||||
parallel:
|
||||
matrix:
|
||||
- ARCH: ["arm", "arm64"]
|
||||
rules:
|
||||
- if: '$CI_PROJECT_NAMESPACE != "framasoft"'
|
||||
when: never
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: $CI_COMMIT_TAG
|
||||
timeout: 3h
|
||||
|
||||
# Release
|
||||
release-upload:
|
||||
stage: upload
|
||||
image: framasoft/yakforms-assets-deploy:latest
|
||||
variables:
|
||||
APP_ASSET: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_${ARCH}.tar.gz"
|
||||
rules: *tag-rules
|
||||
script:
|
||||
- APP_VERSION="${CI_COMMIT_TAG}"
|
||||
- APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz"
|
||||
|
||||
- 'echo "Artifact: ${APP_ASSET}"'
|
||||
- tar czf ${APP_ASSET} -C release mobilizon
|
||||
- ls -al ${APP_ASSET}
|
||||
|
||||
- eval `ssh-agent -s`
|
||||
- ssh-add <(echo "${DEPLOYEMENT_KEY}" | base64 --decode -i)
|
||||
- echo "put -r ${APP_ASSET}" | sftp -o "VerifyHostKeyDNS yes" ${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
@ -270,20 +332,27 @@ release-upload:
|
||||
when: on_success
|
||||
paths:
|
||||
- mobilizon_*.tar.gz
|
||||
parallel:
|
||||
matrix:
|
||||
- ARCH: ["amd64", "arm", "arm64"]
|
||||
|
||||
release-create:
|
||||
stage: deploy
|
||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||
rules: *tag-rules
|
||||
variables:
|
||||
APP_ASSET_AMD64: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_amd64.tar.gz"
|
||||
APP_ASSET_ARM: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_arm.tar.gz"
|
||||
APP_ASSET_ARM64: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_arm64.tar.gz"
|
||||
before_script:
|
||||
- apk --no-cache add gawk sed grep
|
||||
script: |
|
||||
APP_VERSION="${CI_COMMIT_TAG}"
|
||||
APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz"
|
||||
CHANGELOG=$(awk -v version="$APP_VERSION" '/^## / { printit = $2 == version }; printit' CHANGELOG.md | grep -v "## $APP_VERSION" | sed '1{/^$/d}')
|
||||
ENDPOINT="https://packages.joinmobilizon.org"
|
||||
|
||||
release-cli create --name "$CI_COMMIT_TAG" \
|
||||
--description "$CHANGELOG" \
|
||||
--tag-name "$CI_COMMIT_TAG" \
|
||||
--assets-link "{\"name\":\"${APP_ASSET}\",\"url\":\"${ENDPOINT}/${APP_ASSET}\"}"
|
||||
--assets-link "{\"name\":\"${APP_ASSET_AMD64}\",\"url\":\"${ENDPOINT}/${APP_ASSET_AMD64}\"}" \
|
||||
--assets-link "{\"name\":\"${APP_ASSET_ARM}\",\"url\":\"${ENDPOINT}/${APP_ASSET_ARM}\"}" \
|
||||
--assets-link "{\"name\":\"${APP_ASSET_ARM64}\",\"url\":\"${ENDPOINT}/${APP_ASSET_ARM64}\"}"
|
||||
|
@ -8,4 +8,5 @@
|
||||
73B351E4CB3AF715AD450A085F5E6304
|
||||
BBACD7F0BACD4A6D3010C26604671692
|
||||
6D4D4A4821B93BCFAC9CDBB367B34C4B
|
||||
5674F0D127852889ED0132DC2F442AAB
|
||||
5674F0D127852889ED0132DC2F442AAB
|
||||
1600B7206E47F630D94AB54C360906F0
|
392
CHANGELOG.md
392
CHANGELOG.md
@ -1,11 +1,344 @@
|
||||
# Changelog
|
||||
|
||||
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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.3.0 - 2021-08-12
|
||||
## 2.0.0 - 2021-11-23
|
||||
|
||||
Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
|
||||
### Added
|
||||
|
||||
- Added possibility to follow groups and be notified from new upcoming events
|
||||
- Export list of participants to CSV, `PDF` and `ODS`
|
||||
- Allow to set timezone for an event. The timezone is automatically defined from the address if one is defined. If the event timezone is different than the user's current one, a toggle is shown to switch between the two.
|
||||
- Added initial support for Right To Left languages (such as arabic) and [BiDi](https://en.wikipedia.org/wiki/Bidirectional_text)
|
||||
- Group followers and members get an notification email by default when a group publishes a new event (subject to activity notification settings)
|
||||
- Group admins can now approve or deny new memberships
|
||||
- Build releases in `arm` and `arm64` format in addition to `amd64`
|
||||
- Build Docker images in `arm` and `arm64` format in addition to `amd64`
|
||||
- Added possibility to indicate the event is fully online
|
||||
- Added possibility to search only for online events
|
||||
- Added possibility to search only in past events
|
||||
- Detect event, comments and posts languages automatically. Allows setting language
|
||||
- Allow to change an user's password through the users.modify mix task
|
||||
- Add instance setting so that only the admin can create groups
|
||||
- Add instance setting so that only groups can create events
|
||||
- Added JSON-LD metadata about the event in emails
|
||||
- Added a quick link to email notification settings at the bottom of emails
|
||||
- Allow to access Mobilizon with a specific language directly by using `https://instance.tld/lang` where `lang` is a language supported by Mobilizon
|
||||
- Added organizer actor name (profile or group) in the icalendar export
|
||||
- Add initial support for federation with Gancio
|
||||
|
||||
### Changed
|
||||
|
||||
- Multiple UI improvements, including post, event and participation cards, discussions and emails. The « My Events » page was also redesigned to allow showing events from your groups.
|
||||
- Various accessibility improvements
|
||||
- Event update notification is send to participants ~30 minutes after the event update, so that successive edits are throttled.
|
||||
- Event, post and comments titles and content now have expose their detected language in HTML, for improved screen reader experience
|
||||
- Delete current actor ID as well from local storage when unlogging
|
||||
- Show a default text for instance contact in default terms text when no instance contact is set
|
||||
- Only show locatecontrol button in leaflet map when we can do geolocation
|
||||
- Disable push column in notification settings when push is not available
|
||||
- Show actual language instead of language code in Users admin view
|
||||
- Empty old & new passwords fields when successful password change
|
||||
- Don't link to the group page from admin when actor is suspended
|
||||
- Warn participants when the event organizer is suspended (and therefore the event cancelled)
|
||||
- Improve metadata on public page
|
||||
- Make sure some event action pages (participate remotely or without an account) don't get indexed by search engines
|
||||
- Only send `Tombstone` element in `Delete` activities, not the whole previous deleted element.
|
||||
- Make sure `Delete` activity are send correctly to everyone
|
||||
- Only add address and tags to event icalendar export if they exist
|
||||
- `master` branch has been renamed to `main`
|
||||
- Mention following groups on the registration page
|
||||
- Add missing group name to activity notifications
|
||||
- Warn while registering and logging when the email contains uppercase characters
|
||||
- Improve json-ld metadata on event live streams
|
||||
- Add "eventAttendanceMode" to JSON-ld schema.org event representation
|
||||
- Improve sending pending participation notifications
|
||||
- Add "formerType" and "delete" attributes on Tombstones ActivityPub objects representation
|
||||
- Improve MyEvents page description text
|
||||
|
||||
### Removed
|
||||
|
||||
- Support for Elixir < 1.12 and OTP < 22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix tags autocomplete
|
||||
- Fix config onboarding after LDAP initial connexion
|
||||
- Fix events pagination on tags page
|
||||
- Fixed deduplicated files from orphan media being deleted as well
|
||||
- Fix deleting own account
|
||||
- Fix search returning user profiles instead of only groups
|
||||
- Fix federating geo coordinates
|
||||
- Fix an issue with group activity items when moving resources
|
||||
- Fix an issue with Identity Picker
|
||||
- Fix an issue with TagInput
|
||||
- Fix an issue when leaving a group
|
||||
- Fix admin settings edition
|
||||
- Fix an issue when showing public page of suspended group
|
||||
- Removed non existing page (`/about/mobilizon`) from sitemap
|
||||
- Fix action logs containing group suspension events
|
||||
- Fixed group physical address not exposed to ActivityPub
|
||||
- Release front-end files are no longer in duplicate
|
||||
- Only show datetime timezone toggle on event if the timezone offset is different from our own
|
||||
- Fix error when determining audience for Discussion when deleting a comment
|
||||
- Fix a couple of accessibility issues
|
||||
- Limit to acceptable tags when pasting raw HTML into comment fields on front-end
|
||||
- Fixed group map display
|
||||
- Fixed updating group physical address
|
||||
- Allow group members to access group drafts
|
||||
- Improve group refreshment workflow
|
||||
- Fixed date signature generation for federation
|
||||
- Fixed an issue when duplicating a group event from another profile
|
||||
- Fixed event metadata not saved on eventcreation
|
||||
- Use a different pagination parameter for searched events and featured events on search page
|
||||
- Fixed creating group activities when creating events with some fields
|
||||
- Move release package at correct path for CI upload
|
||||
- Fixed event contacts that were not exposed and fetched over federation
|
||||
- Don't sign fetch when fetching actor for a given signature
|
||||
- Some various HTTP signatures issues
|
||||
- Fixed actor AP representation of avatar
|
||||
- Handle errors when fetching actor pictures
|
||||
- Fixed sending group events to followers on Mastodon
|
||||
- Fixed actors avatars and banners being deleted if the same file was also an orphan media
|
||||
- Fix spacing in organizer picker
|
||||
- Increase number of close events and follow group events
|
||||
- Fix accessing user profile in admin section
|
||||
- Set initial values for some EventMetadata elements, fixing submitting them right away with no value
|
||||
- Avoid giving an error page if the apollo futureParticipations query is undefined
|
||||
- Fixed path to exports in production
|
||||
- Fixed padding below truncated title of event cards
|
||||
- Fixed exports that weren't enabled in Docker
|
||||
- Fixed error page when event end date is null
|
||||
- Fixed event language not being allowed to be null
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed private messages sent as event replies from Mastodon that were shown publically as public comments. They are now discarded.
|
||||
|
||||
### Translations
|
||||
|
||||
- Czech
|
||||
- Gaelic
|
||||
- German
|
||||
- Hungarian
|
||||
- Indonesian
|
||||
- Norwegian Nynorsk
|
||||
- Occitan
|
||||
- Persian
|
||||
- Portuguese (Brazil)
|
||||
- Russian
|
||||
- Slovenian
|
||||
- Spanish
|
||||
|
||||
|
||||
## 2.0.0-rc.3 - 2021-11-22
|
||||
|
||||
This lists changes since 2.0.0-rc.3. Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed path to exports in production
|
||||
- Fixed padding below truncated title of event cards
|
||||
- Fixed exports that weren't enabled in Docker
|
||||
- Fixed error page when event end date is null
|
||||
|
||||
## 2.0.0-rc.2 - 2021-11-22
|
||||
|
||||
This lists changes since 2.0.0-rc.1. Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve MyEvents page description text
|
||||
|
||||
### Fixed
|
||||
- Fix spacing in organizer picker
|
||||
- Increase number of close events and follow group events
|
||||
- Fix accessing user profile in admin section
|
||||
- Set initial values for some EventMetadata elements, fixing submitting them right away with no value
|
||||
- Avoid giving an error page if the apollo futureParticipations query is undefined
|
||||
### Translations
|
||||
|
||||
- German
|
||||
- Hungarian
|
||||
|
||||
## 2.0.0-rc.1 - 2021-11-20
|
||||
|
||||
This lists changes since 2.0.0-beta.2. Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
|
||||
### Changed
|
||||
|
||||
- Mention following groups on the registration page
|
||||
- Add missing group name to activity notifications
|
||||
- Warn while registering and logging when the email contains uppercase characters
|
||||
- Improve json-ld metadata on event live streams
|
||||
- Add "eventAttendanceMode" to JSON-ld schema.org event representation
|
||||
- Improve sending pending participation notifications
|
||||
- Add "formerType" and "delete" attributes on Tombstones ActivityPub objects representation
|
||||
|
||||
### Fixed
|
||||
- Fixed creating group activities when creating events with some fields
|
||||
- Move release package at correct path for CI upload
|
||||
- Fixed event contacts that were not exposed and fetched over federation
|
||||
- Don't sign fetch when fetching actor for a given signature
|
||||
- Some various HTTP signatures issues
|
||||
- Fixed actor AP representation of avatar
|
||||
- Handle errors when fetching actor pictures
|
||||
- Fixed sending group events to followers on Mastodon
|
||||
- Fixed actors avatars and banners being deleted if the same file was also an orphan media
|
||||
|
||||
### Translations
|
||||
|
||||
- Gaelic
|
||||
- Spanish
|
||||
|
||||
## 2.0.0-beta.2 - 2021-11-15
|
||||
|
||||
This lists changes since 2.0.0-beta.1. Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
### Added
|
||||
|
||||
- Group followers and members get an notification email by default when a group publishes a new event (subject to activity notification settings)
|
||||
- Group admins can now approve or deny new memberships
|
||||
- Added organizer actor name (profile or group) in the icalendar export
|
||||
- Add initial support for federation with Gancio
|
||||
|
||||
### Changed
|
||||
|
||||
- Event update notification is send to participants ~30 minutes after the event update, so that successive edits are throttled.
|
||||
- Event, post and comments titles and content now have expose their detected language in HTML, for improved screen reader experience
|
||||
|
||||
### Fixed
|
||||
|
||||
- Release front-end files are no longer in duplicate
|
||||
- Only show datetime timezone toggle on event if the timezone offset is different from our own
|
||||
- Fix error when determining audience for Discussion when deleting a comment
|
||||
- Fix a couple of accessibility issues
|
||||
- Limit to acceptable tags when pasting raw HTML into comment fields on front-end
|
||||
- Fixed group map display
|
||||
- Fixed updating group physical address
|
||||
- Allow group members to access group drafts
|
||||
- Improve group refreshment workflow
|
||||
- Fixed date signature generation for federation
|
||||
- Fixed an issue when duplicating a group event from another profile
|
||||
- Fixed event metadata not saved on eventcreation
|
||||
- Use a different pagination parameter for searched events and featured events on search page
|
||||
|
||||
### Translations
|
||||
|
||||
- Gaelic
|
||||
- Spanish
|
||||
|
||||
## 2.0.0-beta.1 - 2021-11-09
|
||||
|
||||
Please read the [UPGRADE.md](https://framagit.org/framasoft/mobilizon/-/blob/main/UPGRADE.md#upgrading-from-13-to-20) file as well.
|
||||
### Added
|
||||
|
||||
- Added possibility to follow groups and be notified from new upcoming events
|
||||
- Export list of participants to CSV, `PDF` and `ODS`
|
||||
- Allow to set timezone for an event. The timezone is automatically defined from the address if one is defined. If the event timezone is different than the user's current one, a toggle is shown to switch between the two.
|
||||
- Added initial support for Right To Left languages (such as arabic) and [BiDi](https://en.wikipedia.org/wiki/Bidirectional_text)
|
||||
- Build releases in `arm` and `arm64` format in addition to `amd64`
|
||||
- Build Docker images in `arm` and `arm64` format in addition to `amd64`
|
||||
- Added possibility to indicate the event is fully online
|
||||
- Added possibility to search only for online events
|
||||
- Added possibility to search only in past events
|
||||
- Detect event, comments and posts languages automatically. Allows setting language
|
||||
- Allow to change an user's password through the users.modify mix task
|
||||
- Add instance setting so that only the admin can create groups
|
||||
- Add instance setting so that only groups can create events
|
||||
- Added JSON-LD metadata about the event in emails
|
||||
- Added a quick link to email notification settings at the bottom of emails
|
||||
- Allow to access Mobilizon with a specific language directly by using `https://instance.tld/lang` where `lang` is a language supported by Mobilizon
|
||||
|
||||
### Changed
|
||||
|
||||
- Multiple UI improvements, including post, event and participation cards, discussions and emails. The « My Events » page was also redesigned to allow showing events from your groups.
|
||||
- Various accessibility improvements
|
||||
- Delete current actor ID as well from local storage when unlogging
|
||||
- Show a default text for instance contact in default terms text when no instance contact is set
|
||||
- Only show locatecontrol button in leaflet map when we can do geolocation
|
||||
- Disable push column in notification settings when push is not available
|
||||
- Show actual language instead of language code in Users admin view
|
||||
- Empty old & new passwords fields when successful password change
|
||||
- Don't link to the group page from admin when actor is suspended
|
||||
- Warn participants when the event organizer is suspended (and therefore the event cancelled)
|
||||
- Improve metadata on public page
|
||||
- Make sure some event action pages (participate remotely or without an account) don't get indexed by search engines
|
||||
- Only send `Tombstone` element in `Delete` activities, not the whole previous deleted element.
|
||||
- Make sure `Delete` activity are send correctly to everyone
|
||||
- Only add address and tags to event icalendar export if they exist
|
||||
- `master` branch has been renamed to `main`
|
||||
|
||||
### Removed
|
||||
|
||||
- Support for Elixir < 1.12 and OTP < 22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix tags autocomplete
|
||||
- Fix config onboarding after LDAP initial connexion
|
||||
- Fix events pagination on tags page
|
||||
- Fixed deduplicated files from orphan media being deleted as well
|
||||
- Fix deleting own account
|
||||
- Fix search returning user profiles instead of only groups
|
||||
- Fix federating geo coordinates
|
||||
- Fix an issue with group activity items when moving resources
|
||||
- Fix an issue with Identity Picker
|
||||
- Fix an issue with TagInput
|
||||
- Fix an issue when leaving a group
|
||||
- Fix admin settings edition
|
||||
- Fix an issue when showing public page of suspended group
|
||||
- Removed non existing page (`/about/mobilizon`) from sitemap
|
||||
- Fix action logs containing group suspension events
|
||||
- Fixed group physical address not exposed to ActivityPub
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed private messages sent as event replies from Mastodon that were shown publically as public comments. They are now discarded.
|
||||
### Translations
|
||||
|
||||
- Czech
|
||||
- Gaelic
|
||||
- German
|
||||
- Indonesian
|
||||
- Norwegian Nynorsk
|
||||
- Occitan
|
||||
- Persian
|
||||
- Portuguese (Brazil)
|
||||
- Russian
|
||||
- Slovenian
|
||||
- Spanish
|
||||
|
||||
## 1.3.2 - 2021-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed deduplicated files from orphan media being cleanup as well
|
||||
- Fixed config onboarding after initial connection
|
||||
- Fixed current actor ID not being deleted from localstorage after logout
|
||||
- Fixed missing pagination on tag exploring page
|
||||
- Fixed deleting own account
|
||||
- Fixed user profiles that could show up in group search
|
||||
- Fixed accessibility issues on the account settings page
|
||||
|
||||
## 1.3.1 - 2021-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed default listen IP and sitemap creation for Docker configurations
|
||||
- Fixed issues related to user timezone setting being shown as set when it wasn't, leading to timezone sometimes missing and causing issues (#746, #815)
|
||||
- Fixed issues with managing resources (#837, #838)
|
||||
|
||||
### Translations
|
||||
|
||||
- Gaelic
|
||||
- Finnish
|
||||
- Spanish
|
||||
|
||||
## 1.3.0 - 2021-08-17
|
||||
|
||||
### Added
|
||||
|
||||
@ -688,23 +1021,23 @@ This version introduces a new way to install and host Mobilizon : Elixir releas
|
||||
|
||||
### Special operations
|
||||
|
||||
- **Reattach media files to their entity.**
|
||||
* **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
|
||||
* Source install
|
||||
`MIX_ENV=prod mix mobilizon.maintenance.fix_unattached_media_in_body`
|
||||
- Docker
|
||||
* Docker
|
||||
`docker-compose exec mobilizon mobilizon_ctl maintenance.fix_unattached_media_in_body`
|
||||
|
||||
- **Refresh remote profiles to save avatars locally**
|
||||
* **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
|
||||
* Source install
|
||||
`MIX_ENV=prod mix mobilizon.actors.refresh --all`
|
||||
- Docker
|
||||
* Docker
|
||||
`docker-compose exec mobilizon mobilizon_ctl actors.refresh --all`
|
||||
|
||||
- **imagemagick and webp are now a required dependency** to build Mobilizon.
|
||||
* **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.
|
||||
|
||||
@ -747,7 +1080,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas
|
||||
- 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
|
||||
|
||||
@ -756,7 +1089,6 @@ This version introduces a new way to install and host Mobilizon : Elixir releas
|
||||
### Translations
|
||||
|
||||
Updated translations:
|
||||
|
||||
- Catalan
|
||||
- Dutch
|
||||
- English
|
||||
@ -929,21 +1261,20 @@ Updated translations:
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
```
|
||||
MIX_ENV=prod mix deps.clean mime --build
|
||||
```
|
||||
* The [nginx configuration](https://framagit.org/framasoft/mobilizon/-/blob/main/support/nginx/mobilizon.conf) has been changed with improvements and support for custom error pages.
|
||||
|
||||
- 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))
|
||||
* The cmake dependency has been added (see [our documentation](https://docs.joinmobilizon.org/administration/dependencies/#basic-tools))
|
||||
|
||||
### Added
|
||||
|
||||
- Possibility to login using LDAP
|
||||
- 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)
|
||||
- resources (collections of links, with folders, accessible to your group members)
|
||||
- discussions (group private and organized chats)
|
||||
@ -967,22 +1298,20 @@ Updated translations:
|
||||
### Security
|
||||
|
||||
- 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
|
||||
|
||||
### Special operations
|
||||
|
||||
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).
|
||||
|
||||
A minimal file template [is available](https://framagit.org/framasoft/mobilizon/blob/master/priv/templates/config.template.eex) to check for missing configuration.
|
||||
A minimal file template [is available](https://framagit.org/framasoft/mobilizon/blob/main/priv/templates/config.template.eex) to check for missing configuration.
|
||||
|
||||
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/main/support/systemd/mobilizon.service).
|
||||
|
||||
### Added
|
||||
|
||||
- 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 add a note as a participant when event participation is manually validated (required when participating without an account)
|
||||
@ -999,7 +1328,6 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
|
||||
- Allow user to change language
|
||||
|
||||
### Changed
|
||||
|
||||
- Configuration handling (see above)
|
||||
- Improved a bit color theme
|
||||
- Signature validation also now checks if `Date` header has acceptable values
|
||||
@ -1010,7 +1338,6 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
|
||||
- Improved public event page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed URL search
|
||||
- Fixed content accessed through URL search being public
|
||||
- Fix event links in some emails
|
||||
@ -1018,21 +1345,17 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
|
||||
## [1.0.0-beta.2] - 2019-12-18
|
||||
|
||||
### Special operations
|
||||
|
||||
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.
|
||||
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):
|
||||
|
||||
- `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):
|
||||
|
||||
- `mix mobilizon.move_participant_stats`
|
||||
* `mix mobilizon.move_participant_stats`
|
||||
|
||||
### Added
|
||||
|
||||
- Federation is active
|
||||
- Added an interface for admins to view and manage instance followers and followings
|
||||
- Ability to comment below events
|
||||
@ -1057,7 +1380,6 @@ In order to move participant stats to the event table for existing events, you n
|
||||
- Upgraded frontend and backend dependencies
|
||||
|
||||
### Changed
|
||||
|
||||
- Move participant stats to event table **(read special instructions above)**
|
||||
- Limit length (20 characters) and number (10) of tags allowed
|
||||
- Added some backend changes and validation for field length
|
||||
@ -1071,7 +1393,6 @@ 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix event URL validation and check if hostname is correct before showing it
|
||||
- Fix participations stats on the MyEvents page
|
||||
- Fix event description lists margin
|
||||
@ -1101,11 +1422,8 @@ 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
|
||||
|
||||
### Security
|
||||
|
||||
- Sanitize event title to avoid XSS
|
||||
|
||||
## [1.0.0-beta.1] - 2019-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release
|
||||
|
@ -1,6 +1,6 @@
|
||||
FROM elixir:alpine
|
||||
|
||||
RUN apk add --no-cache inotify-tools postgresql-client yarn file make gcc libc-dev argon2 imagemagick cmake build-base libwebp-tools bash ncurses git
|
||||
RUN apk add --no-cache inotify-tools postgresql-client yarn file make gcc libc-dev argon2 imagemagick cmake build-base libwebp-tools bash ncurses git python3
|
||||
|
||||
RUN mix local.hex --force && mix local.rebar --force
|
||||
|
||||
|
18
Makefile
18
Makefile
@ -1,5 +1,5 @@
|
||||
init:
|
||||
@bash docker/message.sh "start"
|
||||
@bash docker/message.sh "Start"
|
||||
make start
|
||||
|
||||
setup: stop
|
||||
@ -10,16 +10,18 @@ migrate:
|
||||
logs:
|
||||
docker-compose logs -f
|
||||
start: stop
|
||||
@bash docker/message.sh "starting Mobilizon with docker"
|
||||
@bash docker/message.sh "Starting Mobilizon with Docker"
|
||||
docker-compose up -d api
|
||||
@bash docker/message.sh "Docker server started."
|
||||
@bash docker/message.sh "Docker server started"
|
||||
stop:
|
||||
@bash docker/message.sh "stopping Mobilizon"
|
||||
@bash docker/message.sh "Stopping Mobilizon"
|
||||
docker-compose down
|
||||
@bash docker/message.sh "stopped"
|
||||
@bash docker/message.sh "Mobilizon is stopped"
|
||||
test: stop
|
||||
@bash docker/message.sh "Running tests"
|
||||
docker-compose -f docker-compose.yml -f docker-compose.test.yml run api mix test
|
||||
@bash docker/message.sh "Tests runned"
|
||||
|
||||
docker-compose -f docker-compose.yml -f docker-compose.test.yml run api mix test $(only)
|
||||
@bash docker/message.sh "Done running tests"
|
||||
format:
|
||||
docker-compose run --rm api bash -c "mix format && mix credo --strict"
|
||||
@bash docker/message.sh "Code is now ready to commit :)"
|
||||
target: init
|
||||
|
88
UPGRADE.md
88
UPGRADE.md
@ -1,3 +1,91 @@
|
||||
# Upgrading from 1.3 to 2.0
|
||||
|
||||
Requirements dependencies depend on the way Mobilizon is installed.
|
||||
## New Elixir version requirement
|
||||
### Docker and Release install
|
||||
|
||||
You are already using latest Elixir version in the release tarball and Docker images.
|
||||
|
||||
### Source install
|
||||
|
||||
**Elixir 1.12 and Erlang OTP 22 are now required**. If your distribution or the repositories from Erlang Solutions don't provide these versions, you need to uninstall the current versions and install [Elixir](https://github.com/asdf-vm/asdf-elixir) through the [ASDF tool](https://asdf-vm.com/).
|
||||
|
||||
## Geographic timezone data
|
||||
|
||||
Mobilizon 2.0 uses data based on [timezone-boundary-builder](https://github.com/evansiroky/timezone-boundary-builder) (which is based itself on OpenStreetMap data) to determine the timezone of an event automatically, based on it's geocoordinates. However, this needs ~700Mio of disk, so we don't redistribute data directly, depending on the case. It's possible to skip this part, but users will need to manually pick the timezone for every event they created when it has a different timezone from their own.
|
||||
|
||||
### Docker install
|
||||
|
||||
The geographic timezone data is already bundled into the image, you have nothing to do.
|
||||
### Release install
|
||||
|
||||
In order to keep the release tarballs light, the geographic timezone data is not bundled directly. You need to download the data :
|
||||
* either raw from Github, but **requires an extra ~1Gio of memory** to process the data
|
||||
|
||||
```sh
|
||||
sudo -u mobilizon mkdir /var/lib/mobilizon/timezones
|
||||
sudo -u mobilizon ./bin/mobilizon_ctl tz_world.update
|
||||
```
|
||||
|
||||
* either already processed from our own distribution server
|
||||
|
||||
```sh
|
||||
sudo -u mobilizon mkdir /var/lib/mobilizon/timezones
|
||||
sudo -u mobilizon curl -L 'https://packages.joinmobilizon.org/tz_world/timezones-geodata.dets' -o /var/lib/mobilizon/timezones/timezones-geodata.dets
|
||||
```
|
||||
|
||||
In both cases, ~700Mio of disk will be used. You may use the following configuration to specify where the data is expected:
|
||||
```elixir
|
||||
config :tz_world, data_dir: "/some/place"
|
||||
```
|
||||
|
||||
### Source install
|
||||
|
||||
You need to download the data :
|
||||
* either raw from Github, but **requires an extra ~1Gio of memory** to process the data
|
||||
|
||||
```sh
|
||||
sudo -u mobilizon mkdir /var/lib/mobilizon/timezones
|
||||
sudo -u mobilizon mix mobilizon.tz_world.update
|
||||
```
|
||||
|
||||
* either already processed from our own distribution server
|
||||
|
||||
```sh
|
||||
sudo -u mobilizon mkdir /var/lib/mobilizon/timezones
|
||||
sudo -u mobilizon curl -L 'https://packages.joinmobilizon.org/tz_world/timezones-geodata.dets' -o /var/lib/mobilizon/timezones/timezones-geodata.dets
|
||||
```
|
||||
|
||||
In both cases, ~700Mio of disk will be used. You may use the following configuration to specify where the data is expected:
|
||||
```elixir
|
||||
config :tz_world, data_dir: "/some/place"
|
||||
```
|
||||
|
||||
## Exports folder
|
||||
|
||||
Create the folder for default CSV export:
|
||||
|
||||
```sh
|
||||
sudo -u mobilizon mkdir -p /var/lib/mobilizon/uploads/exports/csv
|
||||
```
|
||||
|
||||
This path can be configured, see [the dedicated docs page about this](https://docs.joinmobilizon.org/administration/configure/exports/).
|
||||
Files in this folder are temporary and are cleaned once an hour.
|
||||
|
||||
## New optional dependencies
|
||||
|
||||
These are optional, installing them will allow Mobilizon to export to PDF and ODS as well. Mobilizon 2.0 allows to export the participant list, but more is planned.
|
||||
### Docker
|
||||
Everything is included in our Docker image.
|
||||
### Release and source install
|
||||
|
||||
New optional Python dependencies:
|
||||
* `Python` >= 3.6
|
||||
* `weasyprint` for PDF export (with [a few extra dependencies](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html))
|
||||
* `pyexcel-ods3` for ODS export (no extra dependencies)
|
||||
|
||||
Both can be installed through pip. You need to enable and configure exports for PDF and ODS in the configuration afterwards. Read [the dedicated docs page about this](https://docs.joinmobilizon.org/administration/configure/exports/).
|
||||
|
||||
# Upgrading from 1.0 to 1.1
|
||||
|
||||
The 1.1 version of Mobilizon brings Elixir releases support. An Elixir release is a self-contained directory that contains all of Mobilizon's code (front-end and backend), it's dependencies, as well as the Erlang Virtual Machine and runtime (only the parts you need). As long as the release has been assembled on the same OS and architecture, it can be deploy and run straight away. [Read more about releases](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#releases).
|
||||
|
@ -40,9 +40,11 @@ config :mobilizon, :instance,
|
||||
email_reply_to: "noreply@localhost"
|
||||
|
||||
config :mobilizon, :groups, enabled: true
|
||||
|
||||
config :mobilizon, :events, creation: true
|
||||
|
||||
config :mobilizon, :restrictions, only_admin_can_create_groups: false
|
||||
config :mobilizon, :restrictions, only_groups_can_create_events: false
|
||||
|
||||
# Configures the endpoint
|
||||
config :mobilizon, Mobilizon.Web.Endpoint,
|
||||
url: [
|
||||
@ -86,6 +88,10 @@ config :mobilizon, Mobilizon.Web.Upload,
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "/var/lib/mobilizon/uploads"
|
||||
|
||||
config :tz_world, data_dir: "/var/lib/mobilizon/timezones"
|
||||
|
||||
config :mobilizon, Timex.Gettext, default_locale: "en"
|
||||
|
||||
config :mobilizon, :media_proxy,
|
||||
enabled: true,
|
||||
proxy_opts: [
|
||||
@ -179,6 +185,8 @@ config :phoenix, :filter_parameters, ["password", "token"]
|
||||
config :absinthe, schema: Mobilizon.GraphQL.Schema
|
||||
config :absinthe, Absinthe.Logger, filter_variables: ["token", "password", "secret"]
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Gettext, one_module_per_locale: true
|
||||
|
||||
config :ex_cldr,
|
||||
default_locale: "en",
|
||||
default_backend: Mobilizon.Cldr
|
||||
@ -189,7 +197,9 @@ config :http_signatures,
|
||||
config :mobilizon, :cldr,
|
||||
locales: [
|
||||
"fr",
|
||||
"en"
|
||||
"en",
|
||||
"ru",
|
||||
"ar"
|
||||
]
|
||||
|
||||
config :mobilizon, :activitypub,
|
||||
@ -282,6 +292,7 @@ config :mobilizon, Oban,
|
||||
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.SendActivityRecapWorker, queue: :notifications},
|
||||
{"@daily", Mobilizon.Service.Workers.CleanOldActivityWorker, queue: :background}
|
||||
]},
|
||||
@ -317,6 +328,12 @@ config :mobilizon, Mobilizon.Service.Notifier.Email, enabled: true
|
||||
|
||||
config :mobilizon, Mobilizon.Service.Notifier.Push, enabled: true
|
||||
|
||||
config :mobilizon, :exports,
|
||||
path: "/var/lib/mobilizon/uploads/exports",
|
||||
formats: [
|
||||
Mobilizon.Service.Export.Participants.CSV
|
||||
]
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
||||
|
@ -58,6 +58,8 @@ config :logger, :console, format: "[$level] $message\n", level: :debug
|
||||
|
||||
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Gettext, allowed_locales: ["fr", "en", "ru", "ar"]
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
@ -92,6 +94,10 @@ config :mobilizon, Mobilizon.Web.Auth.Guardian,
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "uploads"
|
||||
|
||||
config :mobilizon, :exports, path: "uploads/exports"
|
||||
|
||||
config :tz_world, data_dir: "_build/dev/lib/tz_world/priv"
|
||||
|
||||
config :mobilizon, :anonymous,
|
||||
reports: [
|
||||
allowed: true
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import Config
|
||||
|
||||
listen_ip = System.get_env("MOBILIZON_INSTANCE_LISTEN_IP", "127.0.0.1")
|
||||
listen_ip = System.get_env("MOBILIZON_INSTANCE_LISTEN_IP", "0.0.0.0")
|
||||
|
||||
listen_ip =
|
||||
case listen_ip |> to_charlist() |> :inet.parse_address() do
|
||||
@ -14,7 +14,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
|
||||
server: true,
|
||||
url: [host: System.get_env("MOBILIZON_INSTANCE_HOST", "mobilizon.lan")],
|
||||
http: [
|
||||
port: System.get_env("MOBILIZON_INSTANCE_PORT", "4000"),
|
||||
port: String.to_integer(System.get_env("MOBILIZON_INSTANCE_PORT", "4000")),
|
||||
ip: listen_ip
|
||||
],
|
||||
secret_key_base: System.get_env("MOBILIZON_INSTANCE_SECRET_KEY_BASE", "changethis")
|
||||
@ -33,9 +33,6 @@ config :mobilizon, :instance,
|
||||
email_from: System.get_env("MOBILIZON_INSTANCE_EMAIL", "noreply@mobilizon.lan"),
|
||||
email_reply_to: System.get_env("MOBILIZON_REPLY_EMAIL", "noreply@mobilizon.lan")
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local,
|
||||
uploads: System.get_env("MOBILIZON_UPLOADS", "/app/uploads")
|
||||
|
||||
config :mobilizon, Mobilizon.Storage.Repo,
|
||||
adapter: Ecto.Adapters.Postgres,
|
||||
username: System.get_env("MOBILIZON_DATABASE_USERNAME", "username"),
|
||||
@ -68,4 +65,16 @@ config :geolix,
|
||||
}
|
||||
]
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "/var/lib/mobilizon/uploads"
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local,
|
||||
uploads: System.get_env("MOBILIZON_UPLOADS", "/var/lib/mobilizon/uploads")
|
||||
|
||||
config :mobilizon, :exports,
|
||||
path: System.get_env("MOBILIZON_UPLOADS_EXPORTS", "/var/lib/mobilizon/uploads/exports"),
|
||||
formats: [
|
||||
Mobilizon.Service.Export.Participants.CSV,
|
||||
Mobilizon.Service.Export.Participants.PDF,
|
||||
Mobilizon.Service.Export.Participants.ODS
|
||||
]
|
||||
|
||||
config :tz_world,
|
||||
data_dir: System.get_env("MOBILIZON_TIMEZONES_DIR", "/var/lib/mobilizon/timezones")
|
||||
|
@ -37,12 +37,3 @@ config :mobilizon, :cldr,
|
||||
"ru",
|
||||
"sv"
|
||||
]
|
||||
|
||||
cond do
|
||||
System.get_env("INSTANCE_CONFIG") &&
|
||||
File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") ->
|
||||
import_config System.get_env("INSTANCE_CONFIG")
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
|
@ -60,15 +60,22 @@ config :mobilizon, Mobilizon.Web.Upload, filters: [], link_name: false
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "test/uploads"
|
||||
|
||||
config :exvcr,
|
||||
vcr_cassette_library_dir: "test/fixtures/vcr_cassettes"
|
||||
config :mobilizon, :exports, path: "test/uploads/exports"
|
||||
|
||||
config :tz_world, data_dir: "_build/test/lib/tz_world/priv"
|
||||
|
||||
config :tesla, Mobilizon.Service.HTTP.ActivityPub,
|
||||
adapter: Mobilizon.Service.HTTP.ActivityPub.Mock
|
||||
|
||||
config :tesla, Mobilizon.Service.HTTP.WebfingerClient,
|
||||
adapter: Mobilizon.Service.HTTP.WebfingerClient.Mock
|
||||
|
||||
config :tesla, Mobilizon.Service.HTTP.GeospatialClient,
|
||||
adapter: Mobilizon.Service.HTTP.GeospatialClient.Mock
|
||||
|
||||
config :tesla, Mobilizon.Service.HTTP.HostMetaClient,
|
||||
adapter: Mobilizon.Service.HTTP.HostMetaClient.Mock
|
||||
|
||||
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock
|
||||
|
||||
config :mobilizon, Oban, queues: false, plugins: false
|
||||
@ -77,6 +84,8 @@ config :mobilizon, Mobilizon.Web.Auth.Guardian, secret_key: "some secret"
|
||||
|
||||
config :mobilizon, :activitypub, sign_object_fetches: false
|
||||
|
||||
config :mobilizon, Mobilizon.Web.Gettext, allowed_locales: ["fr", "en", "es", "ru"]
|
||||
|
||||
config :junit_formatter, report_dir: "."
|
||||
|
||||
if System.get_env("DOCKER", "false") == "false" && File.exists?("./config/test.secret.exs") do
|
||||
|
@ -1,4 +1,4 @@
|
||||
version: "3"
|
||||
version: "3.2"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
44
docker/multiarch/Dockerfile
Normal file
44
docker/multiarch/Dockerfile
Normal file
@ -0,0 +1,44 @@
|
||||
FROM elixir as build
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
ENV MIX_ENV prod
|
||||
# ENV LANG en_US.UTF-8
|
||||
ARG APP_ASSET
|
||||
|
||||
# Set the right versions
|
||||
ENV ELIXIR_VERSION latest
|
||||
ENV ERLANG_VERSION latest
|
||||
ENV NODE_VERSION 16
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential cmake postgresql-client git curl gnupg unzip exiftool webp imagemagick gifsicle
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# # Install Node & yarn
|
||||
# RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
|
||||
# RUN npm install -g yarn
|
||||
|
||||
# Install build tools
|
||||
RUN source /root/.bashrc && \
|
||||
mix local.rebar --force && \
|
||||
mix local.hex -if-missing --force
|
||||
|
||||
RUN mkdir /mobilizon
|
||||
COPY ./ /mobilizon
|
||||
WORKDIR /mobilizon
|
||||
|
||||
# # Build front-end
|
||||
# RUN yarn --cwd "js" install --frozen-lockfile
|
||||
# RUN yarn --cwd "js" run build
|
||||
|
||||
# Elixir release
|
||||
RUN source /root/.bashrc && \
|
||||
mix deps.get --only prod && \
|
||||
mix compile && \
|
||||
mix phx.digest.clean --all && \
|
||||
mix release --path release/mobilizon && \
|
||||
cd release/mobilizon && \
|
||||
ln -s lib/mobilizon-*/priv priv && \
|
||||
cd ../../
|
||||
|
||||
# Make a release archive
|
||||
RUN tar -zcf /mobilizon/${APP_ASSET} -C release mobilizon
|
1
docker/multiarch/README.md
Normal file
1
docker/multiarch/README.md
Normal file
@ -0,0 +1 @@
|
||||
Contains the Dockerfile used to generate multi-arch Elixir releases
|
@ -4,7 +4,11 @@ FROM node:16-alpine as assets
|
||||
RUN apk add --no-cache python3 build-base libwebp-tools bash imagemagick ncurses
|
||||
WORKDIR /build
|
||||
COPY js .
|
||||
RUN yarn install \
|
||||
|
||||
ENV CYPRESS_INSTALL_BINARY 0
|
||||
|
||||
# Network timeout because it's slow when cross-compiling
|
||||
RUN yarn install --network-timeout 100000 \
|
||||
&& yarn run build
|
||||
|
||||
# Then, build the application binary
|
||||
@ -26,7 +30,7 @@ COPY rel ./rel
|
||||
COPY support ./support
|
||||
COPY --from=assets ./priv/static ./priv/static
|
||||
|
||||
RUN mix phx.digest \
|
||||
RUN mix phx.digest.clean --all \
|
||||
&& mix release
|
||||
|
||||
# Finally setup the app
|
||||
@ -45,9 +49,11 @@ LABEL org.opencontainers.image.title="mobilizon" \
|
||||
org.opencontainers.image.revision=$VCS_REF \
|
||||
org.opencontainers.image.created=$BUILD_DATE
|
||||
|
||||
RUN apk add --no-cache openssl ca-certificates ncurses-libs file postgresql-client libgcc libstdc++ imagemagick
|
||||
RUN apk add --no-cache openssl ca-certificates ncurses-libs file postgresql-client libgcc libstdc++ imagemagick python3 py3-pip py3-pillow py3-cffi py3-brotli gcc musl-dev python3-dev pango libxslt-dev
|
||||
RUN pip install weasyprint pyexcel-ods3
|
||||
|
||||
RUN mkdir -p /app/uploads && chown nobody:nobody /app/uploads
|
||||
RUN mkdir -p /var/lib/mobilizon/uploads && chown nobody:nobody /var/lib/mobilizon/uploads
|
||||
RUN mkdir -p /var/lib/mobilizon/timezones && chown nobody:nobody /var/lib/mobilizon/timezones
|
||||
RUN mkdir -p /etc/mobilizon && chown nobody:nobody /etc/mobilizon
|
||||
|
||||
USER nobody
|
||||
|
@ -1,10 +1,15 @@
|
||||
FROM elixir:latest
|
||||
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
|
||||
|
||||
ENV REFRESHED_AT=2021-06-07
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool
|
||||
ENV REFRESHED_AT=2021-10-04
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool python3-pip python3-setuptools
|
||||
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
|
||||
RUN npm install -g yarn wait-on
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
RUN mix local.hex --force && mix local.rebar --force
|
||||
# Weasyprint 53 requires pango >= 1.44.0, which is not available in Stretch.
|
||||
# TODO: Remove the version requirement when elixir:latest is based on Bullseye
|
||||
# https://github.com/erlang/docker-erlang-otp/issues/362
|
||||
# https://github.com/Kozea/WeasyPrint/issues/1384
|
||||
RUN pip3 install -Iv weasyprint==52 pyexcel_ods3
|
||||
RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/
|
||||
|
@ -1,28 +0,0 @@
|
||||
# We build Elixir manually to have the oldest acceptable version of OTP
|
||||
FROM erlang:21
|
||||
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
|
||||
|
||||
# elixir expects utf8.
|
||||
ENV ELIXIR_VERSION="v1.11.4" \
|
||||
LANG=C.UTF-8
|
||||
|
||||
RUN set -xe \
|
||||
&& ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/archive/${ELIXIR_VERSION}.tar.gz" \
|
||||
&& ELIXIR_DOWNLOAD_SHA256="85c7118a0db6007507313db5bddf370216d9394ed7911fe80f21e2fbf7f54d29" \
|
||||
&& curl -fSL -o elixir-src.tar.gz $ELIXIR_DOWNLOAD_URL \
|
||||
&& echo "$ELIXIR_DOWNLOAD_SHA256 elixir-src.tar.gz" | sha256sum -c - \
|
||||
&& mkdir -p /usr/local/src/elixir \
|
||||
&& tar -xzC /usr/local/src/elixir --strip-components=1 -f elixir-src.tar.gz \
|
||||
&& rm elixir-src.tar.gz \
|
||||
&& cd /usr/local/src/elixir \
|
||||
&& make install clean
|
||||
|
||||
CMD ["iex"]
|
||||
|
||||
ENV REFRESHED_AT=2021-06-07
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool
|
||||
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash && apt-get install nodejs -yq
|
||||
RUN npm install -g yarn wait-on
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
RUN mix local.hex --force && mix local.rebar --force
|
||||
RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/
|
1
docker/tests/README.md
Normal file
1
docker/tests/README.md
Normal file
@ -0,0 +1 @@
|
||||
Contains the Dockerfile for the image used to run the tests
|
@ -9,8 +9,7 @@ module.exports = {
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
"@vue/prettier",
|
||||
"@vue/prettier/@typescript-eslint",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
|
||||
plugins: ["prettier"],
|
||||
|
@ -5,79 +5,41 @@
|
||||
"kind": "INTERFACE",
|
||||
"name": "ActionLogObject",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Comment"
|
||||
},
|
||||
{
|
||||
"name": "Event"
|
||||
},
|
||||
{
|
||||
"name": "Person"
|
||||
},
|
||||
{
|
||||
"name": "Report"
|
||||
},
|
||||
{
|
||||
"name": "ReportNote"
|
||||
},
|
||||
{
|
||||
"name": "User"
|
||||
}
|
||||
{ "name": "Comment" },
|
||||
{ "name": "Event" },
|
||||
{ "name": "Group" },
|
||||
{ "name": "Person" },
|
||||
{ "name": "Report" },
|
||||
{ "name": "ReportNote" },
|
||||
{ "name": "User" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "INTERFACE",
|
||||
"name": "ActivityObject",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Comment"
|
||||
},
|
||||
{
|
||||
"name": "Discussion"
|
||||
},
|
||||
{
|
||||
"name": "Event"
|
||||
},
|
||||
{
|
||||
"name": "Group"
|
||||
},
|
||||
{
|
||||
"name": "Member"
|
||||
},
|
||||
{
|
||||
"name": "Post"
|
||||
},
|
||||
{
|
||||
"name": "Resource"
|
||||
}
|
||||
{ "name": "Comment" },
|
||||
{ "name": "Discussion" },
|
||||
{ "name": "Event" },
|
||||
{ "name": "Group" },
|
||||
{ "name": "Member" },
|
||||
{ "name": "Post" },
|
||||
{ "name": "Resource" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Person"
|
||||
},
|
||||
{
|
||||
"name": "Group"
|
||||
},
|
||||
{
|
||||
"name": "Application"
|
||||
}
|
||||
{ "name": "Application" },
|
||||
{ "name": "Group" },
|
||||
{ "name": "Person" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "INTERFACE",
|
||||
"name": "Interactable",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Event"
|
||||
},
|
||||
{
|
||||
"name": "Group"
|
||||
}
|
||||
]
|
||||
"possibleTypes": [{ "name": "Event" }, { "name": "Group" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "mobilizon",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"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:unit": "LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 TZ=UTC vue-cli-service test:unit",
|
||||
"test:e2e": "vue-cli-service test:e2e",
|
||||
"lint": "vue-cli-service lint",
|
||||
"build:assets": "vue-cli-service build",
|
||||
@ -15,7 +15,7 @@
|
||||
"@absinthe/socket": "^0.2.1",
|
||||
"@absinthe/socket-apollo-link": "^0.2.1",
|
||||
"@apollo/client": "^3.3.16",
|
||||
"@mdi/font": "^5.0.45",
|
||||
"@mdi/font": "^6.1.95",
|
||||
"@tiptap/core": "^2.0.0-beta.41",
|
||||
"@tiptap/extension-blockquote": "^2.0.0-beta.6",
|
||||
"@tiptap/extension-bubble-menu": "^2.0.0-beta.9",
|
||||
@ -29,6 +29,8 @@
|
||||
"@tiptap/extension-underline": "^2.0.0-beta.7",
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.37",
|
||||
"@tiptap/vue-2": "^2.0.0-beta.21",
|
||||
"@vue-a11y/announcer": "^2.1.0",
|
||||
"@vue-a11y/skip-to": "^2.1.2",
|
||||
"@vue/apollo-option": "4.0.0-alpha.11",
|
||||
"apollo-absinthe-upload-link": "^1.5.0",
|
||||
"blurhash": "^1.1.3",
|
||||
@ -36,7 +38,8 @@
|
||||
"bulma-divider": "^0.2.0",
|
||||
"core-js": "^3.6.4",
|
||||
"date-fns": "^2.16.0",
|
||||
"graphql": "^15.0.0",
|
||||
"date-fns-tz": "^1.1.6",
|
||||
"graphql": "^16.0.0",
|
||||
"graphql-tag": "^2.10.3",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
@ -45,8 +48,9 @@
|
||||
"lodash": "^4.17.11",
|
||||
"ngeohash": "^0.6.3",
|
||||
"p-debounce": "^4.0.0",
|
||||
"phoenix": "^1.4.11",
|
||||
"phoenix": "^1.6",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"sanitize-html": "^2.5.3",
|
||||
"tippy.js": "^6.2.3",
|
||||
"unfetch": "^4.2.0",
|
||||
"v-tooltip": "^2.1.3",
|
||||
@ -61,49 +65,50 @@
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.18",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/leaflet": "^1.5.2",
|
||||
"@types/leaflet.locatecontrol": "^0.60.7",
|
||||
"@types/lodash": "^4.14.141",
|
||||
"@types/ngeohash": "^0.6.2",
|
||||
"@types/phoenix": "^1.5.2",
|
||||
"@types/prosemirror-inputrules": "^1.0.2",
|
||||
"@types/prosemirror-model": "^1.7.2",
|
||||
"@types/prosemirror-state": "^1.2.4",
|
||||
"@types/prosemirror-view": "^1.11.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.18.0",
|
||||
"@typescript-eslint/parser": "^4.18.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-e2e-cypress": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-pwa": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-router": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0-beta.3",
|
||||
"@vue/cli-plugin-unit-jest": "~5.0.0-beta.3",
|
||||
"@vue/cli-service": "~5.0.0-beta.3",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"@types/sanitize-html": "^2.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.3.0",
|
||||
"@typescript-eslint/parser": "^5.3.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-e2e-cypress": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-pwa": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0-rc.0",
|
||||
"@vue/cli-plugin-unit-jest": "~5.0.0-rc.0",
|
||||
"@vue/cli-service": "~5.0.0-rc.0",
|
||||
"@vue/eslint-config-typescript": "^9.0.0",
|
||||
"@vue/test-utils": "^1.1.0",
|
||||
"eslint": "^7.20.0",
|
||||
"@vue/vue2-jest": "^27.0.0-alpha.3",
|
||||
"@vue/vue3-jest": "^27.0.0-alpha.1",
|
||||
"cypress": "^8.3.0",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-cypress": "^2.10.3",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-vue": "^7.6.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"flush-promises": "^1.0.2",
|
||||
"jest": "^26.6.3",
|
||||
"jest-junit": "^12.0.0",
|
||||
"jest": "^27.1.0",
|
||||
"jest-junit": "^13.0.0",
|
||||
"mock-apollo-client": "^1.1.0",
|
||||
"prettier": "^2.2.1",
|
||||
"prettier-eslint": "^13.0.0",
|
||||
"sass": "^1.34.1",
|
||||
"sass-loader": "^12.0.0",
|
||||
"ts-jest": "^26.5.3",
|
||||
"typescript": "~4.1.5",
|
||||
"vue-i18n-extract": "^1.0.2",
|
||||
"vue-jest": "^4.0.1",
|
||||
"ts-jest": "27",
|
||||
"typescript": "~4.4.3",
|
||||
"vue-i18n-extract": "^2.0.4",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"webpack-cli": "^4.7.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"webpack": "5.44.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div id="mobilizon">
|
||||
<VueAnnouncer />
|
||||
<VueSkipTo to="#main" :label="$t('Skip to main content')" />
|
||||
<NavBar />
|
||||
<div v-if="config && config.demoMode">
|
||||
<b-message
|
||||
@ -7,7 +9,7 @@
|
||||
type="is-danger"
|
||||
:title="$t('Warning').toLocaleUpperCase()"
|
||||
closable
|
||||
aria-close-label="Close"
|
||||
:aria-close-label="$t('Close')"
|
||||
>
|
||||
<p>
|
||||
{{ $t("This is a demonstration site to test Mobilizon.") }}
|
||||
@ -22,9 +24,9 @@
|
||||
</div>
|
||||
<error v-if="error" :error="error" />
|
||||
|
||||
<main v-else>
|
||||
<main id="main" v-else>
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
<router-view ref="routerView" />
|
||||
</transition>
|
||||
</main>
|
||||
<mobilizon-footer />
|
||||
@ -32,7 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
|
||||
import NavBar from "./components/NavBar.vue";
|
||||
import {
|
||||
AUTH_ACCESS_TOKEN,
|
||||
@ -52,6 +54,7 @@ import { IConfig } from "./types/config.model";
|
||||
import { ICurrentUser } from "./types/current-user.model";
|
||||
import jwt_decode, { JwtPayload } from "jwt-decode";
|
||||
import { refreshAccessToken } from "./apollo/utils";
|
||||
import { Route } from "vue-router";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -82,6 +85,8 @@ export default class App extends Vue {
|
||||
|
||||
interval: number | undefined = undefined;
|
||||
|
||||
@Ref("routerView") routerView!: Vue;
|
||||
|
||||
async created(): Promise<void> {
|
||||
if (await this.initializeCurrentUser()) {
|
||||
await initializeCurrentActor(this.$apollo.provider.defaultClient);
|
||||
@ -197,6 +202,41 @@ export default class App extends Vue {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
|
||||
@Watch("$route", { immediate: true })
|
||||
updateAnnouncement(route: Route): void {
|
||||
const pageTitle = this.extractPageTitleFromRoute(route);
|
||||
if (pageTitle) {
|
||||
this.$announcer.polite(
|
||||
this.$t("Navigated to {pageTitle}", {
|
||||
pageTitle,
|
||||
}) as string
|
||||
);
|
||||
}
|
||||
// Set the focus to the router view
|
||||
// https://marcus.io/blog/accessible-routing-vuejs
|
||||
setTimeout(() => {
|
||||
const focusTarget = this.routerView?.$el as HTMLElement;
|
||||
if (focusTarget) {
|
||||
// Make focustarget programmatically focussable
|
||||
focusTarget.setAttribute("tabindex", "-1");
|
||||
|
||||
// Focus element
|
||||
focusTarget.focus();
|
||||
|
||||
// Remove tabindex from focustarget.
|
||||
// Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
|
||||
focusTarget.removeAttribute("tabindex");
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
extractPageTitleFromRoute(route: Route): string {
|
||||
if (route.meta?.announcer?.message) {
|
||||
return route.meta?.announcer?.message();
|
||||
}
|
||||
return document.title;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -206,7 +246,6 @@ export default class App extends Vue {
|
||||
/* Icons */
|
||||
$mdi-font-path: "~@mdi/font/fonts";
|
||||
@import "~@mdi/font/scss/materialdesignicons";
|
||||
|
||||
@import "common";
|
||||
|
||||
#mobilizon {
|
||||
@ -218,4 +257,8 @@ $mdi-font-path: "~@mdi/font/fonts";
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-skip-to {
|
||||
z-index: 40;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,8 +1,11 @@
|
||||
@use "@/styles/_mixins" as *;
|
||||
@import "variables.scss";
|
||||
|
||||
@import "~bulma";
|
||||
@import "~bulma-divider";
|
||||
@import "~buefy/src/scss/buefy";
|
||||
@import "styles/vue-announcer.scss";
|
||||
@import "styles/vue-skip-to.scss";
|
||||
|
||||
// a {
|
||||
// color: $violet-2;
|
||||
@ -79,7 +82,7 @@ $color-black: #000;
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.2rem;
|
||||
@include margin-right(0.2rem);
|
||||
}
|
||||
|
||||
.mention-suggestion {
|
||||
@ -88,7 +91,7 @@ $color-black: #000;
|
||||
|
||||
.mention .mention {
|
||||
background: initial;
|
||||
margin-right: 0;
|
||||
@include margin-right(0);
|
||||
}
|
||||
|
||||
.select select {
|
||||
@ -240,3 +243,63 @@ footer.footer[data-v-40ab164b] span.select select {
|
||||
background: $chapril_blue_light;
|
||||
color: $footer-text-color;
|
||||
}
|
||||
|
||||
@mixin focus() {
|
||||
&:focus {
|
||||
border: 2px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
ul.menu-list > li,
|
||||
p {
|
||||
@include focus;
|
||||
}
|
||||
.navbar-item {
|
||||
@include focus;
|
||||
}
|
||||
|
||||
.navbar-dropdown span.navbar-item:hover {
|
||||
background-color: whitesmoke;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulma/Buefy fixes
|
||||
*/
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tags .tag:not(:last-child) {
|
||||
margin-right: unset;
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
|
||||
.button .icon {
|
||||
&:first-child:not(:last-child) {
|
||||
@include margin-left(calc(-0.5em - 1px));
|
||||
@include margin-right(0.25em);
|
||||
}
|
||||
&:last-child:not(:first-child) {
|
||||
@include margin-right(calc(-0.5em - 1px));
|
||||
@include margin-left(0.25em);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons .button:not(:last-child):not(.is-fullwidth) {
|
||||
margin-right: unset;
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
|
||||
.breadcrumb li:first-child a {
|
||||
padding-left: unset;
|
||||
@include padding-left(0);
|
||||
@include padding-right(0.75em);
|
||||
}
|
||||
.media-left {
|
||||
@include margin-left(1rem);
|
||||
}
|
||||
a.dropdown-item {
|
||||
@include padding-right(3rem);
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<p>
|
||||
<a :title="contact" v-if="configLink" :href="configLink.uri">{{
|
||||
<a dir="auto" :title="contact" v-if="configLink" :href="configLink.uri">{{
|
||||
configLink.text
|
||||
}}</a>
|
||||
<span v-else-if="contact">{{ contact }}</span>
|
||||
<span dir="auto" v-else-if="contact">{{ contact }}</span>
|
||||
<span v-else>{{ $t("contact uninformed") }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
@ -1,27 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="media" style="align-items: top">
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="actor.avatar">
|
||||
<img class="is-rounded" :src="actor.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
||||
</div>
|
||||
<div class="media" style="align-items: top" dir="auto">
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="actor.avatar">
|
||||
<img class="is-rounded" :src="actor.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
||||
</div>
|
||||
|
||||
<div class="media-content">
|
||||
<p>
|
||||
{{ actor.name || `@${usernameWithDomain(actor)}` }}
|
||||
</p>
|
||||
<p class="has-text-grey-dark" v-if="actor.name">
|
||||
@{{ usernameWithDomain(actor) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="full"
|
||||
class="summary"
|
||||
:class="{ limit: limit }"
|
||||
v-html="actor.summary"
|
||||
/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p>
|
||||
{{ actor.name || `@${usernameWithDomain(actor)}` }}
|
||||
</p>
|
||||
<p class="has-text-grey-dark" v-if="actor.name">
|
||||
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="full"
|
||||
class="summary"
|
||||
:class="{ limit: limit }"
|
||||
v-html="actor.summary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -53,6 +51,15 @@ export default class ActorCard extends Vue {
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
||||
.media {
|
||||
.media-left {
|
||||
margin-right: initial;
|
||||
@include margin-right(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: block !important;
|
||||
z-index: 10000;
|
||||
@ -105,7 +112,7 @@ export default class ActorCard extends Vue {
|
||||
}
|
||||
|
||||
&[x-placement^="right"] {
|
||||
margin-left: 5px;
|
||||
@include margin-left(5px);
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 5px 5px 5px 0;
|
||||
@ -114,13 +121,13 @@ export default class ActorCard extends Vue {
|
||||
border-bottom-color: transparent !important;
|
||||
left: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
@include margin-left(0);
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="left"] {
|
||||
margin-right: 5px;
|
||||
@include margin-right(5px);
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 5px 0 5px 5px;
|
||||
@ -129,8 +136,8 @@ export default class ActorCard extends Vue {
|
||||
border-bottom-color: transparent !important;
|
||||
right: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
@include margin-left(0);
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,6 +26,7 @@ export default class ActorInline extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
div.actor-inline {
|
||||
align-items: flex-start;
|
||||
display: inline-flex;
|
||||
@ -36,7 +37,7 @@ div.actor-inline {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.5rem;
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
div.actor-name {
|
||||
flex-basis: auto;
|
||||
|
@ -6,7 +6,7 @@
|
||||
:class="{ inline, clickable: actor && actor.type === ActorType.GROUP }"
|
||||
>
|
||||
<slot></slot>
|
||||
<template slot="popover" class="popover">
|
||||
<template slot="popover">
|
||||
<actor-card :full="true" :actor="actor" :popover="true" />
|
||||
</template>
|
||||
</v-popover>
|
||||
|
@ -11,7 +11,7 @@
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<hr />
|
||||
<hr role="presentation" />
|
||||
<p class="content">
|
||||
<span>
|
||||
{{
|
||||
@ -21,6 +21,7 @@
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="config"
|
||||
v-html="
|
||||
$t(
|
||||
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.',
|
||||
@ -32,7 +33,7 @@
|
||||
"
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<hr role="presentation" />
|
||||
<p class="content">
|
||||
{{
|
||||
$t(
|
||||
|
@ -148,6 +148,11 @@ export default class GroupActivityItem extends mixins(ActivityMixin) {
|
||||
case Openness.INVITE_ONLY:
|
||||
details.push("The group can now only be joined with an invite.");
|
||||
break;
|
||||
case Openness.MODERATED:
|
||||
details.push(
|
||||
"The group can now be joined by anyone, but new members need to be approved by an administrator."
|
||||
);
|
||||
break;
|
||||
case Openness.OPEN:
|
||||
details.push("The group can now be joined by anyone.");
|
||||
break;
|
||||
|
@ -9,13 +9,7 @@
|
||||
:inline="true"
|
||||
slot="member"
|
||||
>
|
||||
<b>
|
||||
{{
|
||||
$t("@{username}", {
|
||||
username: usernameWithDomain(activity.object.actor),
|
||||
})
|
||||
}}</b
|
||||
></popover-actor-card
|
||||
<b> {{ displayName(activity.object.actor) }}</b></popover-actor-card
|
||||
>
|
||||
<b slot="member" v-else>{{
|
||||
subjectParams.member_actor_federated_username
|
||||
@ -25,13 +19,7 @@
|
||||
:inline="true"
|
||||
slot="profile"
|
||||
>
|
||||
<b>
|
||||
{{
|
||||
$t("@{username}", {
|
||||
username: usernameWithDomain(activity.author),
|
||||
})
|
||||
}}</b
|
||||
></popover-actor-card
|
||||
<b> {{ displayName(activity.author) }}</b></popover-actor-card
|
||||
></i18n
|
||||
>
|
||||
<small class="has-text-grey-dark activity-date">{{
|
||||
@ -41,7 +29,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { displayName } from "@/types/actor";
|
||||
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
|
||||
import { Component } from "vue-property-decorator";
|
||||
import RouteName from "../../router/name";
|
||||
@ -62,7 +50,7 @@ export const MEMBER_ROLE_VALUE: Record<string, number> = {
|
||||
},
|
||||
})
|
||||
export default class MemberActivityItem extends mixins(ActivityMixin) {
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
displayName = displayName;
|
||||
RouteName = RouteName;
|
||||
ActivityMemberSubject = ActivityMemberSubject;
|
||||
|
||||
@ -83,6 +71,14 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
|
||||
return "You added the member {member}.";
|
||||
}
|
||||
return "{profile} added the member {member}.";
|
||||
case ActivityMemberSubject.MEMBER_APPROVED:
|
||||
if (this.isAuthorCurrentActor) {
|
||||
return "You approved {member}'s membership.";
|
||||
}
|
||||
if (this.isObjectMemberCurrentActor) {
|
||||
return "Your membership was approved by {profile}.";
|
||||
}
|
||||
return "{profile} approved {member}'s membership.";
|
||||
case ActivityMemberSubject.MEMBER_JOINED:
|
||||
return "{member} joined the group.";
|
||||
case ActivityMemberSubject.MEMBER_UPDATED:
|
||||
@ -94,6 +90,12 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
|
||||
}
|
||||
return "{profile} updated the member {member}.";
|
||||
case ActivityMemberSubject.MEMBER_REMOVED:
|
||||
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) {
|
||||
if (this.isAuthorCurrentActor) {
|
||||
return "You rejected {member}'s membership request.";
|
||||
}
|
||||
return "{profile} rejected {member}'s membership request.";
|
||||
}
|
||||
if (this.isAuthorCurrentActor) {
|
||||
return "You excluded member {member}.";
|
||||
}
|
||||
|
@ -172,7 +172,8 @@ export default class ResourceActivityItem extends mixins(ActivityMixin) {
|
||||
if (this.subjectParams.resource_path) {
|
||||
const parentPath = this.parentPath(this.subjectParams.resource_path);
|
||||
const directory = parentPath.split("/");
|
||||
return directory.pop();
|
||||
const res = directory.pop();
|
||||
res === "" ? null : res;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
125
js/src/components/Address/AddressInfo.vue
Normal file
125
js/src/components/Address/AddressInfo.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<address dir="auto">
|
||||
<b-icon
|
||||
v-if="showIcon"
|
||||
:icon="address.poiInfos.poiIcon.icon"
|
||||
size="is-medium"
|
||||
class="icon"
|
||||
/>
|
||||
<p>
|
||||
<span
|
||||
class="addressDescription"
|
||||
:title="address.poiInfos.name"
|
||||
v-if="address.poiInfos.name"
|
||||
>
|
||||
{{ address.poiInfos.name }}
|
||||
</span>
|
||||
<br v-if="address.poiInfos.name" />
|
||||
<span class="has-text-grey-dark">
|
||||
{{ address.poiInfos.alternativeName }}
|
||||
</span>
|
||||
<br />
|
||||
<small
|
||||
v-if="
|
||||
userTimezoneDifferent &&
|
||||
longShortTimezoneNamesDifferent &&
|
||||
timezoneLongNameValid
|
||||
"
|
||||
class="has-text-grey-dark"
|
||||
>
|
||||
🌐
|
||||
{{
|
||||
$t("{timezoneLongName} ({timezoneShortName})", {
|
||||
timezoneLongName,
|
||||
timezoneShortName,
|
||||
})
|
||||
}}
|
||||
</small>
|
||||
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark">
|
||||
🌐 {{ timezoneShortName }}
|
||||
</small>
|
||||
</p>
|
||||
</address>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IAddress } from "@/types/address.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class AddressInfo extends Vue {
|
||||
@Prop({ required: true, type: Object as PropType<IAddress> })
|
||||
address!: IAddress;
|
||||
|
||||
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean;
|
||||
@Prop({ required: false, default: false, type: Boolean })
|
||||
showTimezone!: boolean;
|
||||
@Prop({ required: false, type: String }) userTimezone!: string;
|
||||
|
||||
get userTimezoneDifferent(): boolean {
|
||||
return (
|
||||
this.userTimezone != undefined &&
|
||||
this.address.timezone != undefined &&
|
||||
this.userTimezone !== this.address.timezone
|
||||
);
|
||||
}
|
||||
|
||||
get longShortTimezoneNamesDifferent(): boolean {
|
||||
return (
|
||||
this.timezoneLongName != undefined &&
|
||||
this.timezoneShortName != undefined &&
|
||||
this.timezoneLongName !== this.timezoneShortName
|
||||
);
|
||||
}
|
||||
|
||||
get timezoneLongName(): string | undefined {
|
||||
return this.timezoneName("long");
|
||||
}
|
||||
|
||||
get timezoneShortName(): string | undefined {
|
||||
return this.timezoneName("short");
|
||||
}
|
||||
|
||||
get timezoneLongNameValid(): boolean {
|
||||
return (
|
||||
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/)
|
||||
);
|
||||
}
|
||||
|
||||
private timezoneName(format: "long" | "short"): string | undefined {
|
||||
return this.extractTimezone(
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
timeZoneName: format,
|
||||
timeZone: this.address.timezone,
|
||||
}).formatToParts()
|
||||
);
|
||||
}
|
||||
|
||||
private extractTimezone(
|
||||
parts: Intl.DateTimeFormatPart[]
|
||||
): string | undefined {
|
||||
return parts.find((part) => part.type === "timeZoneName")?.value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
address {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
span.addressDescription {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 0 auto;
|
||||
min-width: 100%;
|
||||
max-width: 4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
span.icon {
|
||||
@include padding-right(1rem);
|
||||
}
|
||||
}
|
||||
</style>
|
45
js/src/components/Address/InlineAddress.vue
Normal file
45
js/src/components/Address/InlineAddress.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div
|
||||
class="ellipsis"
|
||||
:title="
|
||||
isDescriptionDifferentFromLocality
|
||||
? `${physicalAddress.description}, ${physicalAddress.locality}`
|
||||
: physicalAddress.description
|
||||
"
|
||||
>
|
||||
<b-icon icon="map-marker" />
|
||||
<span v-if="isDescriptionDifferentFromLocality">
|
||||
{{ physicalAddress.description }},
|
||||
{{ physicalAddress.locality }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ physicalAddress.description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IAddress } from "@/types/address.model";
|
||||
import { PropType } from "vue";
|
||||
import { Prop, Vue, Component } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class InlineAddress extends Vue {
|
||||
@Prop({ required: true, type: Object as PropType<IAddress> })
|
||||
physicalAddress!: IAddress;
|
||||
|
||||
get isDescriptionDifferentFromLocality(): boolean {
|
||||
return (
|
||||
this.physicalAddress?.description !== this.physicalAddress?.locality &&
|
||||
this.physicalAddress?.description !== undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.ellipsis {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Ref } from "vue-property-decorator";
|
||||
import { Component, Mixins } from "vue-property-decorator";
|
||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
@ -173,8 +173,6 @@ export default class Followers extends Mixins(RelayMixin) {
|
||||
|
||||
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
|
||||
|
||||
@Ref("table") readonly table!: any;
|
||||
|
||||
toggle(row: Record<string, unknown>): void {
|
||||
this.table.toggleDetails(row);
|
||||
}
|
||||
@ -211,12 +209,14 @@ export default class Followers extends Mixins(RelayMixin) {
|
||||
});
|
||||
await this.$apollo.queries.relayFollowers.refetch();
|
||||
this.checkedRows = [];
|
||||
} catch (e) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,12 +230,14 @@ export default class Followers extends Mixins(RelayMixin) {
|
||||
});
|
||||
await this.$apollo.queries.relayFollowers.refetch();
|
||||
this.checkedRows = [];
|
||||
} catch (e) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,7 +254,7 @@ export default class Followers extends Mixins(RelayMixin) {
|
||||
limit: FOLLOWERS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
@ -203,7 +203,7 @@ export default class Followings extends Mixins(RelayMixin) {
|
||||
limit: FOLLOWINGS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
@ -254,12 +254,14 @@ export default class Followings extends Mixins(RelayMixin) {
|
||||
},
|
||||
});
|
||||
this.newRelayAddress = "";
|
||||
} catch (err) {
|
||||
Snackbar.open({
|
||||
message: err.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.message) {
|
||||
Snackbar.open({
|
||||
message: err.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,12 +297,14 @@ export default class Followings extends Mixins(RelayMixin) {
|
||||
});
|
||||
await this.$apollo.queries.relayFollowings.refetch();
|
||||
this.checkedRows = [];
|
||||
} catch (e) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
}"
|
||||
class="comment-element"
|
||||
>
|
||||
<article class="media" :id="commentId">
|
||||
<article class="media" :id="commentId" dir="auto">
|
||||
<popover-actor-card
|
||||
:actor="comment.actor"
|
||||
:inline="true"
|
||||
@ -32,11 +32,11 @@
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<span class="first-line" v-if="!comment.deletedAt">
|
||||
<span class="first-line" v-if="!comment.deletedAt" dir="auto">
|
||||
<strong :class="{ organizer: commentFromOrganizer }">{{
|
||||
comment.actor.name
|
||||
}}</strong>
|
||||
<small>{{ usernameWithDomain(comment.actor) }}</small>
|
||||
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small>
|
||||
</span>
|
||||
<a v-else class="comment-link" :href="commentURL">
|
||||
<span>{{ $t("[deleted]") }}</span>
|
||||
@ -63,7 +63,12 @@
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
<div v-if="!comment.deletedAt" v-html="comment.text" />
|
||||
<div
|
||||
v-if="!comment.deletedAt"
|
||||
v-html="comment.text"
|
||||
dir="auto"
|
||||
:lang="comment.language"
|
||||
/>
|
||||
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
|
||||
<div class="load-replies" v-if="comment.totalReplies">
|
||||
<p v-if="!showReplies" @click="fetchReplies">
|
||||
@ -128,7 +133,7 @@
|
||||
<div class="content">
|
||||
<span class="first-line">
|
||||
<strong>{{ currentActor.name }}</strong>
|
||||
<small>@{{ currentActor.preferredUsername }}</small>
|
||||
<small dir="ltr">@{{ currentActor.preferredUsername }}</small>
|
||||
</span>
|
||||
<br />
|
||||
<span class="editor-line">
|
||||
@ -137,6 +142,7 @@
|
||||
ref="commentEditor"
|
||||
v-model="newComment.text"
|
||||
mode="comment"
|
||||
:aria-label="$t('Comment body')"
|
||||
/>
|
||||
<b-button
|
||||
:disabled="newComment.text.trim().length === 0"
|
||||
@ -298,6 +304,10 @@ export default class Comment extends Vue {
|
||||
onConfirm: this.reportComment,
|
||||
outsideDomain: this.comment.actor.domain,
|
||||
},
|
||||
// https://github.com/buefy/buefy/pull/3589
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
closeButtonAriaLabel: this.$t("Close"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -322,17 +332,20 @@ export default class Comment extends Vue {
|
||||
position: "is-bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
form.reply {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@ -352,7 +365,7 @@ form.reply {
|
||||
}
|
||||
|
||||
& > small {
|
||||
margin-left: 0.3rem;
|
||||
@include margin-left(0.3rem);
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,14 +375,14 @@ form.reply {
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
padding-right: 10px;
|
||||
@include padding-right(10px);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.comment-link {
|
||||
text-decoration: none;
|
||||
margin-left: 5px;
|
||||
@include margin-left(5px);
|
||||
color: $text;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@ -398,6 +411,7 @@ a.comment-link {
|
||||
color: $white;
|
||||
.reply-btn,
|
||||
small,
|
||||
span,
|
||||
strong,
|
||||
.icons button {
|
||||
color: $white;
|
||||
@ -412,7 +426,7 @@ a.comment-link {
|
||||
}
|
||||
|
||||
.media-left {
|
||||
margin-right: 0.5rem;
|
||||
@include margin-right(5px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -423,7 +437,7 @@ a.comment-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
@include margin-right(10px);
|
||||
|
||||
.vertical-border {
|
||||
width: 3px;
|
||||
@ -441,9 +455,12 @@ a.comment-link {
|
||||
|
||||
.media .media-content {
|
||||
overflow-x: initial;
|
||||
.content .editor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.content {
|
||||
text-align: start;
|
||||
.editor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
@ -512,7 +529,7 @@ article {
|
||||
}
|
||||
|
||||
.reply-action .icon {
|
||||
padding-right: 0.4rem;
|
||||
@include padding-right(0.4rem);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
|
@ -12,7 +12,7 @@
|
||||
>{{ $t("Comments are closed for everybody else.") }}</b-notification
|
||||
>
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<figure class="media-left" v-if="newComment.actor">
|
||||
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
@ -23,6 +23,7 @@
|
||||
ref="commenteditor"
|
||||
mode="comment"
|
||||
v-model="newComment.text"
|
||||
:aria-label="$t('Comment body')"
|
||||
/>
|
||||
</p>
|
||||
<p class="help is-danger" v-if="emptyCommentError">
|
||||
@ -30,9 +31,11 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="field notify-participants" v-if="isEventOrganiser">
|
||||
<b-switch v-model="newComment.isAnnouncement">{{
|
||||
$t("Notify participants")
|
||||
}}</b-switch>
|
||||
<b-switch
|
||||
aria-labelledby="notify-participants-toggle"
|
||||
v-model="newComment.isAnnouncement"
|
||||
>{{ $t("Notify participants") }}</b-switch
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -42,8 +45,8 @@
|
||||
type="is-primary"
|
||||
class="comment-button-submit"
|
||||
icon-left="send"
|
||||
:aria-label="$t('Post a comment')"
|
||||
/>
|
||||
>{{ $t("Send") }}</b-button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
@ -56,11 +59,11 @@
|
||||
>
|
||||
{{ $t("Loading comments…") }}
|
||||
</p>
|
||||
<transition-group name="comment-empty-list" mode="out-in" v-else>
|
||||
<transition-group tag="div" name="comment-empty-list" v-else>
|
||||
<transition-group
|
||||
key="list"
|
||||
name="comment-list"
|
||||
v-if="comments.length"
|
||||
v-if="filteredOrderedComments.length"
|
||||
class="comment-list"
|
||||
tag="ul"
|
||||
>
|
||||
@ -74,9 +77,9 @@
|
||||
@delete-comment="deleteComment"
|
||||
/>
|
||||
</transition-group>
|
||||
<div v-else class="no-comments" key="no-comments">
|
||||
<empty-content v-else icon="comment" key="no-comments" :inline="true">
|
||||
<span>{{ $t("No comments yet") }}</span>
|
||||
</div>
|
||||
</empty-content>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
@ -96,6 +99,7 @@ import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { IPerson } from "../../types/actor";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -116,6 +120,7 @@ import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
components: {
|
||||
Comment,
|
||||
IdentityPickerWrapper,
|
||||
EmptyContent,
|
||||
editor: () =>
|
||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
||||
},
|
||||
@ -213,7 +218,7 @@ export default class CommentTree extends Vue {
|
||||
|
||||
// and reset the new comment field
|
||||
this.newComment = new CommentModel();
|
||||
} catch (errors) {
|
||||
} catch (errors: any) {
|
||||
console.error(errors);
|
||||
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
|
||||
const error = errors.graphQLErrors[0];
|
||||
@ -295,7 +300,7 @@ export default class CommentTree extends Vue {
|
||||
},
|
||||
});
|
||||
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(error.graphQLErrors[0].message);
|
||||
@ -360,21 +365,35 @@ export default class CommentTree extends Vue {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@import "~bulma/sass/utilities/mixins.sass";
|
||||
form.new-comment {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.media-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
.media {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
.media-left {
|
||||
@include mobile {
|
||||
@include margin-right(0.5rem);
|
||||
@include margin-left(0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 0;
|
||||
.media-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
width: min-content;
|
||||
|
||||
&.notify-participants {
|
||||
margin-top: 0.5rem;
|
||||
.field {
|
||||
flex: 1;
|
||||
@include padding-right(10px);
|
||||
margin-bottom: 0;
|
||||
|
||||
&.notify-participants {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@
|
||||
<b-icon v-else size="is-large" icon="account-circle" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div class="meta" dir="auto">
|
||||
<span
|
||||
class="first-line name"
|
||||
v-if="comment.actor && !comment.deletedAt"
|
||||
@ -64,7 +64,11 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!editMode && !comment.deletedAt" class="text-wrapper">
|
||||
<div
|
||||
v-if="!editMode && !comment.deletedAt"
|
||||
class="text-wrapper"
|
||||
dir="auto"
|
||||
>
|
||||
<div class="description-content" v-html="comment.text"></div>
|
||||
<p
|
||||
v-if="
|
||||
@ -88,7 +92,7 @@
|
||||
{{ $t("[This comment has been deleted by it's author]") }}
|
||||
</div>
|
||||
<form v-else class="edition" @submit.prevent="updateComment">
|
||||
<editor v-model="updatedComment" />
|
||||
<editor v-model="updatedComment" :aria-label="$t('Comment body')" />
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
native-type="submit"
|
||||
@ -141,13 +145,16 @@ export default class DiscussionComment extends Vue {
|
||||
}
|
||||
|
||||
updateComment(): void {
|
||||
this.comment.text = this.updatedComment;
|
||||
this.$emit("update-comment", this.comment);
|
||||
this.$emit("update-comment", {
|
||||
...this.comment,
|
||||
text: this.updatedComment,
|
||||
});
|
||||
this.toggleEditMode();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
article.comment {
|
||||
display: flex;
|
||||
border-top: 1px solid #e9e9e9;
|
||||
@ -163,7 +170,7 @@ article.comment {
|
||||
padding: 0 1rem 0.3em;
|
||||
|
||||
.name {
|
||||
margin-right: auto;
|
||||
@include margin-right(auto);
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@ -216,7 +223,7 @@ article.comment {
|
||||
::v-deep blockquote {
|
||||
border-left: 0.2em solid #333;
|
||||
display: block;
|
||||
padding-left: 1em;
|
||||
@include padding-left(1em);
|
||||
}
|
||||
|
||||
::v-deep p {
|
||||
|
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="discussion-minimalist-card-wrapper"
|
||||
dir="auto"
|
||||
:to="{
|
||||
name: RouteName.DISCUSSION,
|
||||
params: { slug: discussion.slug, id: discussion.id },
|
||||
@ -37,6 +38,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="ellipsis has-text-grey-dark"
|
||||
dir="auto"
|
||||
v-if="!discussion.lastComment.deletedAt"
|
||||
>
|
||||
{{ htmlTextEllipsis }}
|
||||
@ -83,6 +85,7 @@ export default class DiscussionListItem extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.discussion-minimalist-card-wrapper {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
@ -92,7 +95,7 @@ export default class DiscussionListItem extends Vue {
|
||||
align-items: center;
|
||||
|
||||
.calendar-icon {
|
||||
margin-right: 1rem;
|
||||
@include margin-right(1rem);
|
||||
}
|
||||
|
||||
.title-info-wrapper {
|
||||
|
@ -16,6 +16,7 @@
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
type="button"
|
||||
:title="$t('Bold')"
|
||||
>
|
||||
<b-icon icon="format-bold" />
|
||||
</button>
|
||||
@ -25,6 +26,7 @@
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
type="button"
|
||||
:title="$t('Italic')"
|
||||
>
|
||||
<b-icon icon="format-italic" />
|
||||
</button>
|
||||
@ -34,6 +36,7 @@
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
type="button"
|
||||
:title="$t('Underline')"
|
||||
>
|
||||
<b-icon icon="format-underline" />
|
||||
</button>
|
||||
@ -44,6 +47,7 @@
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
type="button"
|
||||
:title="$t('Heading Level 1')"
|
||||
>
|
||||
<b-icon icon="format-header-1" />
|
||||
</button>
|
||||
@ -54,6 +58,7 @@
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
type="button"
|
||||
:title="$t('Heading Level 2')"
|
||||
>
|
||||
<b-icon icon="format-header-2" />
|
||||
</button>
|
||||
@ -64,6 +69,7 @@
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
type="button"
|
||||
:title="$t('Heading Level 3')"
|
||||
>
|
||||
<b-icon icon="format-header-3" />
|
||||
</button>
|
||||
@ -73,6 +79,7 @@
|
||||
@click="showLinkMenu()"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
type="button"
|
||||
:title="$t('Add link')"
|
||||
>
|
||||
<b-icon icon="link" />
|
||||
</button>
|
||||
@ -82,6 +89,7 @@
|
||||
class="menubar__button"
|
||||
@click="editor.chain().focus().unsetLink().run()"
|
||||
type="button"
|
||||
:title="$t('Remove link')"
|
||||
>
|
||||
<b-icon icon="link-off" />
|
||||
</button>
|
||||
@ -91,6 +99,7 @@
|
||||
v-if="!isBasicMode"
|
||||
@click="showImagePrompt()"
|
||||
type="button"
|
||||
:title="$t('Add picture')"
|
||||
>
|
||||
<b-icon icon="image" />
|
||||
</button>
|
||||
@ -101,6 +110,7 @@
|
||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
type="button"
|
||||
:title="$t('Bullet list')"
|
||||
>
|
||||
<b-icon icon="format-list-bulleted" />
|
||||
</button>
|
||||
@ -111,6 +121,7 @@
|
||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
type="button"
|
||||
:title="$t('Ordered list')"
|
||||
>
|
||||
<b-icon icon="format-list-numbered" />
|
||||
</button>
|
||||
@ -121,6 +132,7 @@
|
||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
type="button"
|
||||
:title="$t('Quote')"
|
||||
>
|
||||
<b-icon icon="format-quote-close" />
|
||||
</button>
|
||||
@ -130,6 +142,7 @@
|
||||
class="menubar__button"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
type="button"
|
||||
:title="$t('Undo')"
|
||||
>
|
||||
<b-icon icon="undo" />
|
||||
</button>
|
||||
@ -139,6 +152,7 @@
|
||||
class="menubar__button"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
type="button"
|
||||
:title="$t('Redo')"
|
||||
>
|
||||
<b-icon icon="redo" />
|
||||
</button>
|
||||
@ -155,6 +169,7 @@
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
type="button"
|
||||
:title="$t('Bold')"
|
||||
>
|
||||
<b-icon icon="format-bold" />
|
||||
<span class="visually-hidden">{{ $t("Bold") }}</span>
|
||||
@ -165,6 +180,7 @@
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
type="button"
|
||||
:title="$t('Italic')"
|
||||
>
|
||||
<b-icon icon="format-italic" />
|
||||
<span class="visually-hidden">{{ $t("Italic") }}</span>
|
||||
@ -195,6 +211,8 @@ import ListItem from "@tiptap/extension-list-item";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { AutoDir } from "./Editor/Autodir";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
@Component({
|
||||
components: { EditorContent, BubbleMenu },
|
||||
@ -211,6 +229,8 @@ export default class EditorComponent extends Vue {
|
||||
|
||||
@Prop({ required: false, default: 100_000_000 }) maxSize!: number;
|
||||
|
||||
@Prop({ required: false }) ariaLabel!: string;
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
editor: Editor | null = null;
|
||||
@ -240,6 +260,14 @@ export default class EditorComponent extends Vue {
|
||||
|
||||
mounted(): void {
|
||||
this.editor = new Editor({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-multiline": this.isShortMode.toString(),
|
||||
"aria-label": this.ariaLabel,
|
||||
role: "textbox",
|
||||
},
|
||||
transformPastedHTML: this.transformPastedHTML,
|
||||
},
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Document,
|
||||
@ -249,6 +277,7 @@ export default class EditorComponent extends Vue {
|
||||
ListItem,
|
||||
Mention.configure(MentionOptions),
|
||||
CustomImage,
|
||||
AutoDir,
|
||||
Underline,
|
||||
Link.configure({
|
||||
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
|
||||
@ -265,6 +294,19 @@ export default class EditorComponent extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
transformPastedHTML(html: string): string {
|
||||
// When using comment mode, limit to acceptable tags
|
||||
if (this.isCommentMode) {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: ["b", "i", "em", "strong", "a"],
|
||||
allowedAttributes: {
|
||||
a: ["href", "rel", "target"],
|
||||
},
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
@Watch("value")
|
||||
onValueChanged(val: string): void {
|
||||
if (!this.editor) return;
|
||||
@ -315,7 +357,7 @@ export default class EditorComponent extends Vue {
|
||||
})
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(error.graphQLErrors[0].message);
|
||||
@ -351,6 +393,7 @@ export default class EditorComponent extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@use "@/styles/_mixins" as *;
|
||||
@import "./Editor/style.scss";
|
||||
|
||||
$color-black: #000;
|
||||
@ -367,7 +410,7 @@ $color-white: #eee;
|
||||
border: 0;
|
||||
color: $color-black;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.2rem;
|
||||
@include margin-right(0.2rem);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
@ -439,7 +482,7 @@ $color-white: #eee;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1rem;
|
||||
@include padding-left(1rem);
|
||||
}
|
||||
|
||||
ul {
|
||||
@ -455,7 +498,7 @@ $color-white: #eee;
|
||||
blockquote {
|
||||
border-left: 3px solid rgba($color-black, 0.1);
|
||||
color: rgba($color-black, 0.8);
|
||||
padding-left: 0.8rem;
|
||||
@include padding-left(0.8rem);
|
||||
font-style: italic;
|
||||
|
||||
p {
|
||||
|
30
js/src/components/Editor/Autodir.ts
Normal file
30
js/src/components/Editor/Autodir.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* Allows to set dir="auto" on top nodes
|
||||
* Taken from https://github.com/ueberdosis/tiptap/issues/1621#issuecomment-918990408
|
||||
*/
|
||||
export const AutoDir = Extension.create({
|
||||
name: "AutoDir",
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: [
|
||||
"heading",
|
||||
"paragraph",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
"blockquote",
|
||||
],
|
||||
attributes: {
|
||||
autoDir: {
|
||||
renderHTML: () => ({
|
||||
dir: "auto",
|
||||
}),
|
||||
parseHTML: (element) => element.dir || "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
@ -27,6 +27,7 @@ const debouncedFetchItems = pDebounce(fetchItems, 200);
|
||||
const mentionOptions: Partial<any> = {
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
dir: "ltr",
|
||||
},
|
||||
suggestion: {
|
||||
items: async (query: string): Promise<IPerson[]> => {
|
||||
|
@ -103,9 +103,11 @@
|
||||
:active="copied !== false"
|
||||
always
|
||||
>
|
||||
<b-button @click="copyErrorToClipboard">{{
|
||||
$t("Copy details to clipboard")
|
||||
}}</b-button>
|
||||
<b-button
|
||||
@click="copyErrorToClipboard"
|
||||
@keyup.enter="copyErrorToClipboard"
|
||||
>{{ $t("Copy details to clipboard") }}</b-button
|
||||
>
|
||||
</b-tooltip>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -11,6 +11,8 @@
|
||||
icon="map-marker"
|
||||
expanded
|
||||
@select="updateSelected"
|
||||
v-bind="$attrs"
|
||||
dir="auto"
|
||||
>
|
||||
<template #default="{ option }">
|
||||
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
||||
@ -20,12 +22,17 @@
|
||||
</template>
|
||||
</b-autocomplete>
|
||||
</b-field>
|
||||
<b-field v-if="canDoGeoLocation">
|
||||
<b-field
|
||||
v-if="canDoGeoLocation"
|
||||
:message="fieldErrors"
|
||||
:type="{ 'is-danger': fieldErrors.length }"
|
||||
>
|
||||
<b-button
|
||||
type="is-text"
|
||||
v-if="!gettingLocation"
|
||||
icon-right="target"
|
||||
@click="locateMe"
|
||||
@keyup.enter="locateMe"
|
||||
>{{ $t("Use my location") }}</b-button
|
||||
>
|
||||
<span v-else>{{ $t("Getting location") }}</span>
|
||||
@ -52,26 +59,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||
import { LatLng } from "leaflet";
|
||||
import debounce from "lodash/debounce";
|
||||
import { DebouncedFunc } from "lodash";
|
||||
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import { Address, IAddress } from "../../types/address.model";
|
||||
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
|
||||
import { CONFIG } from "../../graphql/config";
|
||||
import { IConfig } from "../../types/config.model";
|
||||
import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
"map-leaflet": () =>
|
||||
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
|
||||
},
|
||||
apollo: {
|
||||
config: CONFIG,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
})
|
||||
export default class AddressAutoComplete extends Vue {
|
||||
@Prop({ required: true }) value!: IAddress;
|
||||
export default class AddressAutoComplete extends Mixins(
|
||||
AddressAutoCompleteMixin
|
||||
) {
|
||||
@Prop({ required: false, default: false }) type!: string | false;
|
||||
@Prop({ required: false, default: true, type: Boolean })
|
||||
doGeoLocation!: boolean;
|
||||
@ -80,84 +77,20 @@ export default class AddressAutoComplete extends Vue {
|
||||
|
||||
selected: IAddress = new Address();
|
||||
|
||||
isFetching = false;
|
||||
|
||||
initialQueryText = "";
|
||||
|
||||
addressModalActive = false;
|
||||
|
||||
showmap = false;
|
||||
|
||||
private gettingLocation = false;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
private location!: GeolocationPosition;
|
||||
|
||||
private gettingLocationError: any;
|
||||
|
||||
private mapDefaultZoom = 15;
|
||||
|
||||
config!: IConfig;
|
||||
|
||||
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
|
||||
|
||||
// We put this in data because of issues like
|
||||
// https://github.com/vuejs/vue-class-component/issues/263
|
||||
data(): Record<string, unknown> {
|
||||
return {
|
||||
fetchAsyncData: debounce(this.asyncData, 200),
|
||||
};
|
||||
}
|
||||
|
||||
async asyncData(query: string): Promise<void> {
|
||||
if (!query.length) {
|
||||
this.addressData = [];
|
||||
this.selected = new Address();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < 3) {
|
||||
this.addressData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
const variables: { query: string; locale: string; type?: string } = {
|
||||
query,
|
||||
locale: this.$i18n.locale,
|
||||
};
|
||||
if (this.type) {
|
||||
variables.type = this.type;
|
||||
}
|
||||
const result = await this.$apollo.query({
|
||||
query: ADDRESS,
|
||||
fetchPolicy: "network-only",
|
||||
variables,
|
||||
});
|
||||
|
||||
this.addressData = result.data.searchAddress.map(
|
||||
(address: IAddress) => new Address(address)
|
||||
);
|
||||
this.isFetching = false;
|
||||
}
|
||||
|
||||
@Watch("config")
|
||||
watchConfig(config: IConfig): void {
|
||||
if (!config.geocoding.autocomplete) {
|
||||
// If autocomplete is disabled, we put a larger debounce value
|
||||
// so that we don't request with incomplete address
|
||||
this.fetchAsyncData = debounce(this.asyncData, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
get queryText(): string {
|
||||
get queryText2(): string {
|
||||
if (this.value !== undefined) {
|
||||
return new Address(this.value).fullName;
|
||||
}
|
||||
return this.initialQueryText;
|
||||
}
|
||||
|
||||
set queryText(queryText: string) {
|
||||
set queryText2(queryText: string) {
|
||||
this.initialQueryText = queryText;
|
||||
}
|
||||
|
||||
@ -186,80 +119,6 @@ export default class AddressAutoComplete extends Vue {
|
||||
this.showmap = !this.showmap;
|
||||
}
|
||||
|
||||
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
|
||||
// If the position has been updated through autocomplete selection, no need to geocode it!
|
||||
if (this.checkCurrentPosition(e)) return;
|
||||
const result = await this.$apollo.query({
|
||||
query: REVERSE_GEOCODE,
|
||||
variables: {
|
||||
latitude: e.lat,
|
||||
longitude: e.lng,
|
||||
zoom,
|
||||
locale: this.$i18n.locale,
|
||||
},
|
||||
});
|
||||
|
||||
this.addressData = result.data.reverseGeocode.map(
|
||||
(address: IAddress) => new Address(address)
|
||||
);
|
||||
if (this.addressData.length > 0) {
|
||||
const defaultAddress = new Address(this.addressData[0]);
|
||||
this.selected = defaultAddress;
|
||||
this.$emit("input", this.selected);
|
||||
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
|
||||
}
|
||||
}
|
||||
|
||||
checkCurrentPosition(e: LatLng): boolean {
|
||||
if (!this.selected || !this.selected.geom) return false;
|
||||
const lat = parseFloat(this.selected.geom.split(";")[1]);
|
||||
const lon = parseFloat(this.selected.geom.split(";")[0]);
|
||||
|
||||
return e.lat === lat && e.lng === lon;
|
||||
}
|
||||
|
||||
async locateMe(): Promise<void> {
|
||||
this.gettingLocation = true;
|
||||
try {
|
||||
this.location = await AddressAutoComplete.getLocation();
|
||||
this.mapDefaultZoom = 12;
|
||||
this.reverseGeoCode(
|
||||
new LatLng(
|
||||
this.location.coords.latitude,
|
||||
this.location.coords.longitude
|
||||
),
|
||||
12
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.gettingLocationError = e.message;
|
||||
}
|
||||
this.gettingLocation = false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
static async getLocation(): Promise<GeolocationPosition> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!("geolocation" in navigator)) {
|
||||
reject(new Error("Geolocation is not available."));
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
resolve(pos);
|
||||
},
|
||||
(err) => {
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get isSecureContext(): boolean {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
|
||||
get canDoGeoLocation(): boolean {
|
||||
return this.isSecureContext && this.doGeoLocation;
|
||||
}
|
||||
|
@ -12,18 +12,17 @@
|
||||
</docs>
|
||||
|
||||
<template>
|
||||
<time
|
||||
<div
|
||||
class="datetime-container"
|
||||
:class="{ small }"
|
||||
:datetime="dateObj.getUTCSeconds()"
|
||||
:style="`--small: ${smallStyle}`"
|
||||
>
|
||||
<div class="datetime-container-header" />
|
||||
<div class="datetime-container-content">
|
||||
<span class="day">{{ day }}</span>
|
||||
<span class="month">{{ month }}</span>
|
||||
<time :datetime="dateObj.toISOString()" class="day">{{ day }}</time>
|
||||
<time :datetime="dateObj.toISOString()" class="month">{{ month }}</time>
|
||||
</div>
|
||||
</time>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
@ -54,14 +53,13 @@ export default class DateCalendarIcon extends Vue {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
time.datetime-container {
|
||||
div.datetime-container {
|
||||
background: $chapril_blue_light;
|
||||
border: 1px solid $borders;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
overflow-y: hidden;
|
||||
@ -80,7 +78,7 @@ time.datetime-container {
|
||||
height: calc(30px * var(--small));
|
||||
}
|
||||
|
||||
span {
|
||||
time {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: $violet-3;
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="card"
|
||||
:to="{ name: 'Event', params: { uuid: event.uuid } }"
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
>
|
||||
<div class="card-image">
|
||||
<figure class="image is-16by9">
|
||||
@ -24,7 +24,7 @@
|
||||
v-for="tag in (event.tags || []).slice(0, 3)"
|
||||
:key="tag.slug"
|
||||
>
|
||||
<b-tag type="is-light">{{ tag.title }}</b-tag>
|
||||
<b-tag type="is-light" dir="auto">{{ tag.title }}</b-tag>
|
||||
</router-link>
|
||||
</div>
|
||||
</figure>
|
||||
@ -39,79 +39,72 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="event-title" :title="event.title">{{ event.title }}</p>
|
||||
<div
|
||||
class="event-subtitle"
|
||||
v-if="event.physicalAddress"
|
||||
:title="
|
||||
isDescriptionDifferentFromLocality
|
||||
? `${event.physicalAddress.description}, ${event.physicalAddress.locality}`
|
||||
: event.physicalAddress.description
|
||||
"
|
||||
<h3
|
||||
class="event-title"
|
||||
:title="event.title"
|
||||
dir="auto"
|
||||
:lang="event.language"
|
||||
>
|
||||
<!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>-->
|
||||
<span v-if="isDescriptionDifferentFromLocality">
|
||||
{{ event.physicalAddress.description }},
|
||||
{{ event.physicalAddress.locality }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ event.physicalAddress.description }}
|
||||
</span>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<div class="content-end">
|
||||
<div class="event-organizer" dir="auto">
|
||||
<figure
|
||||
class="image is-24x24"
|
||||
v-if="organizer(event) && organizer(event).avatar"
|
||||
>
|
||||
<img
|
||||
class="is-rounded"
|
||||
:src="organizer(event).avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
<span class="organizer-name">
|
||||
{{ organizerDisplayName(event) }}
|
||||
</span>
|
||||
</div>
|
||||
<inline-address
|
||||
dir="auto"
|
||||
v-if="event.physicalAddress"
|
||||
class="event-subtitle"
|
||||
:physical-address="event.physicalAddress"
|
||||
/>
|
||||
<div
|
||||
class="event-subtitle"
|
||||
dir="auto"
|
||||
v-else-if="event.options && event.options.isOnline"
|
||||
>
|
||||
<b-icon icon="video" />
|
||||
<span>{{ $t("Online") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="date-and-title">-->
|
||||
<!-- <div class="date-component">-->
|
||||
<!-- <date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" />-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="title-wrapper">-->
|
||||
<!-- <h4>{{ event.title }}</h4>-->
|
||||
<!-- <div class="organizer-place-wrapper has-text-grey">-->
|
||||
<!-- <span>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</span>-->
|
||||
<!-- ·-->
|
||||
<!-- <span v-if="event.physicalAddress">-->
|
||||
<!-- {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}-->
|
||||
<!-- </span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
|
||||
<!-- <div v-if="event.participants.length > 0 &&-->
|
||||
<!-- mergedOptions.loggedPerson &&-->
|
||||
<!-- event.participants[0].actor.id === mergedOptions.loggedPerson.id">-->
|
||||
<!-- <b-tag type="is-info"><translate>Organizer</translate></b-tag>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div v-else-if="event.participants.length === 1">-->
|
||||
<!-- <translate-->
|
||||
<!-- :translate-params="{name: event.participants[0].actor.preferredUsername}"-->
|
||||
<!-- >{name} organizes this event</translate>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div v-else>-->
|
||||
<!-- <span v-for="participant in event.participants" :key="participant.actor.uuid">-->
|
||||
<!-- {{ participant.actor.preferredUsername }}-->
|
||||
<!-- <span v-if="participant.role === ParticipantRole.CREATOR">(organizer)</span>,-->
|
||||
<!-- <!– <translate-->
|
||||
<!-- :translate-params="{name: participant.actor.preferredUsername}"-->
|
||||
<!-- > {name} is in,</translate>–>-->
|
||||
<!-- </span>-->
|
||||
<!-- </div>-->
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { IEvent, IEventCardOptions } from "@/types/event.model";
|
||||
import {
|
||||
IEvent,
|
||||
IEventCardOptions,
|
||||
organizerDisplayName,
|
||||
organizer,
|
||||
} from "@/types/event.model";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import { Actor, Person } from "@/types/actor";
|
||||
import { EventStatus, ParticipantRole } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DateCalendarIcon,
|
||||
LazyImageWrapper,
|
||||
InlineAddress,
|
||||
},
|
||||
})
|
||||
export default class EventCard extends Vue {
|
||||
@ -125,6 +118,10 @@ export default class EventCard extends Vue {
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
organizerDisplayName = organizerDisplayName;
|
||||
|
||||
organizer = organizer;
|
||||
|
||||
defaultOptions: IEventCardOptions = {
|
||||
hideDate: false,
|
||||
loggedPerson: false,
|
||||
@ -143,18 +140,13 @@ export default class EventCard extends Vue {
|
||||
this.event.organizerActor || this.mergedOptions.organizerActor
|
||||
);
|
||||
}
|
||||
|
||||
get isDescriptionDifferentFromLocality(): boolean {
|
||||
return (
|
||||
this.event?.physicalAddress?.description !==
|
||||
this.event?.physicalAddress?.locality &&
|
||||
this.event?.physicalAddress?.description !== undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@use "@/styles/_event-card";
|
||||
|
||||
a.card {
|
||||
display: block;
|
||||
background: $secondary;
|
||||
@ -190,7 +182,7 @@ a.card {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
margin-right: -3px;
|
||||
@include margin-right(-3px);
|
||||
z-index: 10;
|
||||
max-width: 40%;
|
||||
|
||||
@ -220,12 +212,14 @@ a.card {
|
||||
}
|
||||
|
||||
.card-content {
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
|
||||
& > .media {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
& > .media-left {
|
||||
margin-top: -15px;
|
||||
@ -234,37 +228,39 @@ a.card {
|
||||
align-items: flex-end;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 0rem;
|
||||
@include margin-left(0);
|
||||
}
|
||||
|
||||
& > .media-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow-x: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.25rem;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.4rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content-end {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.event-subtitle {
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
color: #3c376e;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 14rem;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.organizer-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,66 +18,100 @@
|
||||
</docs>
|
||||
|
||||
<template>
|
||||
<span v-if="!endsOn">{{
|
||||
beginsOn | formatDateTimeString(showStartTime)
|
||||
}}</span>
|
||||
<span v-else-if="isSameDay() && showStartTime && showEndTime">
|
||||
{{
|
||||
<p v-if="!endsOn">
|
||||
<span>{{
|
||||
formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
|
||||
}}</span>
|
||||
<br />
|
||||
<b-switch
|
||||
size="is-small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</b-switch>
|
||||
</p>
|
||||
<p v-else-if="isSameDay() && showStartTime && showEndTime">
|
||||
<span>{{
|
||||
$t("On {date} from {startTime} to {endTime}", {
|
||||
date: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn),
|
||||
endTime: formatTime(endsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endTime: formatTime(endsOn, timezoneToShow),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="isSameDay() && !showStartTime && showEndTime">
|
||||
{{
|
||||
$t("On {date} ending at {endTime}", {
|
||||
date: formatDate(beginsOn),
|
||||
endTime: formatTime(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="isSameDay() && showStartTime && !showEndTime">
|
||||
}}</span>
|
||||
<br />
|
||||
<b-switch
|
||||
size="is-small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</b-switch>
|
||||
</p>
|
||||
<p v-else-if="isSameDay() && showStartTime && !showEndTime">
|
||||
{{
|
||||
$t("On {date} starting at {startTime}", {
|
||||
date: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="isSameDay()">{{
|
||||
$t("On {date}", { date: formatDate(beginsOn) })
|
||||
}}</span>
|
||||
<span v-else-if="endsOn && showStartTime && showEndTime">
|
||||
{{
|
||||
$t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn),
|
||||
endDate: formatDate(endsOn),
|
||||
endTime: formatTime(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="endsOn && showStartTime">
|
||||
{{
|
||||
$t("From the {startDate} at {startTime} to the {endDate}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn),
|
||||
endDate: formatDate(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="endsOn">
|
||||
</p>
|
||||
<p v-else-if="isSameDay()">
|
||||
{{ $t("On {date}", { date: formatDate(beginsOn) }) }}
|
||||
</p>
|
||||
<p v-else-if="endsOn && showStartTime && showEndTime">
|
||||
<span>
|
||||
{{
|
||||
$t(
|
||||
"From the {startDate} at {startTime} to the {endDate} at {endTime}",
|
||||
{
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endDate: formatDate(endsOn),
|
||||
endTime: formatTime(endsOn, timezoneToShow),
|
||||
}
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<b-switch
|
||||
size="is-small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ multipleTimeZones }}
|
||||
</b-switch>
|
||||
</p>
|
||||
<p v-else-if="endsOn && showStartTime">
|
||||
<span>
|
||||
{{
|
||||
$t("From the {startDate} at {startTime} to the {endDate}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endDate: formatDate(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<b-switch
|
||||
size="is-small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</b-switch>
|
||||
</p>
|
||||
<p v-else-if="endsOn">
|
||||
{{
|
||||
$t("From the {startDate} to the {endDate}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
endDate: formatDate(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { getTimezoneOffset } from "date-fns-tz";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
@ -90,14 +124,47 @@ export default class EventFullDate extends Vue {
|
||||
|
||||
@Prop({ required: false, default: true }) showEndTime!: boolean;
|
||||
|
||||
@Prop({ required: false }) timezone!: string;
|
||||
|
||||
@Prop({ required: false }) userTimezone!: string;
|
||||
|
||||
showLocalTimezone = true;
|
||||
|
||||
get timezoneToShow(): string {
|
||||
if (this.showLocalTimezone) {
|
||||
return this.timezone;
|
||||
}
|
||||
return this.userActualTimezone;
|
||||
}
|
||||
|
||||
get userActualTimezone(): string {
|
||||
if (this.userTimezone) {
|
||||
return this.userTimezone;
|
||||
}
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
formatDate(value: Date): string | undefined {
|
||||
if (!this.$options.filters) return undefined;
|
||||
return this.$options.filters.formatDateString(value);
|
||||
}
|
||||
|
||||
formatTime(value: Date): string | undefined {
|
||||
formatTime(value: Date, timezone: string): string | undefined {
|
||||
if (!this.$options.filters) return undefined;
|
||||
return this.$options.filters.formatTimeString(value);
|
||||
return this.$options.filters.formatTimeString(value, timezone || undefined);
|
||||
}
|
||||
|
||||
formatDateTimeString(
|
||||
value: Date,
|
||||
timezone: string,
|
||||
showTime: boolean
|
||||
): string | undefined {
|
||||
if (!this.$options.filters) return undefined;
|
||||
return this.$options.filters.formatDateTimeString(
|
||||
value,
|
||||
timezone,
|
||||
showTime
|
||||
);
|
||||
}
|
||||
|
||||
isSameDay(): boolean {
|
||||
@ -106,5 +173,37 @@ export default class EventFullDate extends Vue {
|
||||
new Date(this.endsOn).toDateString();
|
||||
return this.endsOn !== undefined && sameDay;
|
||||
}
|
||||
|
||||
get differentFromUserTimezone(): boolean {
|
||||
return (
|
||||
!!this.timezone &&
|
||||
!!this.userActualTimezone &&
|
||||
getTimezoneOffset(this.timezone) !==
|
||||
getTimezoneOffset(this.userActualTimezone) &&
|
||||
this.timezone !== this.userActualTimezone
|
||||
);
|
||||
}
|
||||
|
||||
get singleTimeZone(): string {
|
||||
if (this.showLocalTimezone) {
|
||||
return this.$t("Local time ({timezone})", {
|
||||
timezone: this.timezoneToShow,
|
||||
}) as string;
|
||||
}
|
||||
return this.$t("Time in your timezone ({timezone})", {
|
||||
timezone: this.timezoneToShow,
|
||||
}) as string;
|
||||
}
|
||||
|
||||
get multipleTimeZones(): string {
|
||||
if (this.showLocalTimezone) {
|
||||
return this.$t("Local time ({timezone})", {
|
||||
timezone: this.timezoneToShow,
|
||||
}) as string;
|
||||
}
|
||||
return this.$t("Times in your timezone ({timezone})", {
|
||||
timezone: this.timezoneToShow,
|
||||
}) as string;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -12,7 +12,7 @@
|
||||
<h2 class="title">{{ event.title }}</h2>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="participation-actor has-text-grey">
|
||||
<div class="participation-actor has-text-grey-dark">
|
||||
<span v-if="event.physicalAddress && event.physicalAddress.locality">
|
||||
{{ event.physicalAddress.locality }}
|
||||
</span>
|
||||
@ -128,6 +128,7 @@ export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
article.box {
|
||||
div.content {
|
||||
padding: 5px;
|
||||
@ -148,7 +149,7 @@ article.box {
|
||||
|
||||
div.date-component {
|
||||
flex: 0;
|
||||
margin-right: 16px;
|
||||
@include margin-right(16px);
|
||||
}
|
||||
|
||||
.title {
|
||||
|
176
js/src/components/Event/EventMap.vue
Normal file
176
js/src/components/Event/EventMap.vue
Normal file
@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<button type="button" class="delete" @click="$emit('close')" />
|
||||
</header>
|
||||
<div class="modal-card-body">
|
||||
<section class="map">
|
||||
<map-leaflet
|
||||
:coords="physicalAddress.geom"
|
||||
:marker="{
|
||||
text: physicalAddress.fullName,
|
||||
icon: physicalAddress.poiInfos.poiIcon.icon,
|
||||
}"
|
||||
/>
|
||||
</section>
|
||||
<section class="columns is-centered map-footer">
|
||||
<div class="column is-half has-text-centered">
|
||||
<p class="address">
|
||||
<i class="mdi mdi-map-marker"></i>
|
||||
{{ physicalAddress.fullName }}
|
||||
</p>
|
||||
<p class="getting-there">{{ $t("Getting there") }}</p>
|
||||
<div
|
||||
class="buttons"
|
||||
v-if="
|
||||
addressLinkToRouteByCar ||
|
||||
addressLinkToRouteByBike ||
|
||||
addressLinkToRouteByFeet
|
||||
"
|
||||
>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByFeet"
|
||||
:href="addressLinkToRouteByFeet"
|
||||
>
|
||||
<i class="mdi mdi-walk"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByBike"
|
||||
:href="addressLinkToRouteByBike"
|
||||
>
|
||||
<i class="mdi mdi-bike"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByTransit"
|
||||
:href="addressLinkToRouteByTransit"
|
||||
>
|
||||
<i class="mdi mdi-bus"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByCar"
|
||||
:href="addressLinkToRouteByCar"
|
||||
>
|
||||
<i class="mdi mdi-car"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Address, IAddress } from "@/types/address.model";
|
||||
import { RoutingTransportationType, RoutingType } from "@/types/enums";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
|
||||
const RoutingParamType = {
|
||||
[RoutingType.OPENSTREETMAP]: {
|
||||
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
|
||||
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
|
||||
[RoutingTransportationType.TRANSIT]: null,
|
||||
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
|
||||
},
|
||||
[RoutingType.GOOGLE_MAPS]: {
|
||||
[RoutingTransportationType.FOOT]: "dirflg=w",
|
||||
[RoutingTransportationType.BIKE]: "dirflg=b",
|
||||
[RoutingTransportationType.TRANSIT]: "dirflg=r",
|
||||
[RoutingTransportationType.CAR]: "driving",
|
||||
},
|
||||
};
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
"map-leaflet": () =>
|
||||
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
|
||||
},
|
||||
})
|
||||
export default class EventMap extends Vue {
|
||||
@Prop({ type: Object as PropType<IAddress> }) address!: IAddress;
|
||||
@Prop({ type: String }) routingType!: RoutingType;
|
||||
|
||||
get physicalAddress(): Address | null {
|
||||
if (!this.address) return null;
|
||||
|
||||
return new Address(this.address);
|
||||
}
|
||||
|
||||
makeNavigationPath(
|
||||
transportationType: RoutingTransportationType
|
||||
): string | undefined {
|
||||
const geometry = this.physicalAddress?.geom;
|
||||
if (geometry) {
|
||||
/**
|
||||
* build urls to routing map
|
||||
*/
|
||||
if (!RoutingParamType[this.routingType][transportationType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlGeometry = geometry.split(";").reverse().join(",");
|
||||
|
||||
switch (this.routingType) {
|
||||
case RoutingType.GOOGLE_MAPS:
|
||||
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
|
||||
RoutingParamType[this.routingType][transportationType]
|
||||
}`;
|
||||
case RoutingType.OPENSTREETMAP:
|
||||
default: {
|
||||
const bboxX = geometry.split(";").reverse()[0];
|
||||
const bboxY = geometry.split(";").reverse()[1];
|
||||
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
|
||||
RoutingParamType[this.routingType][transportationType]
|
||||
}#map=14/${bboxX}/${bboxY}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get addressLinkToRouteByCar(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.CAR);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByBike(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.BIKE);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByFeet(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.FOOT);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByTransit(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.modal-card-head {
|
||||
justify-content: flex-end;
|
||||
button.delete {
|
||||
@include margin-right(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
section.map {
|
||||
height: calc(100% - 8rem);
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
section.map-footer {
|
||||
p.address {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
div.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -14,9 +14,9 @@
|
||||
/>
|
||||
</span>
|
||||
<b-icon v-else-if="icon" :icon="icon" size="is-medium" />
|
||||
<p :class="{ 'padding-left': icon }">
|
||||
<div class="content-wrapper" :class="{ 'padding-left': icon }">
|
||||
<slot></slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -42,8 +42,9 @@ div.eventMetadataBlock {
|
||||
align-items: center;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
p {
|
||||
.content-wrapper {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&.padding-left {
|
||||
padding: 0 20px;
|
||||
|
@ -9,6 +9,7 @@
|
||||
:src="`/img/${metadataItem.icon.substring(8)}_monochrome.svg`"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<b-icon v-else-if="metadataItem.icon" :icon="metadataItem.icon" />
|
||||
@ -130,11 +131,12 @@ export default class EventMetadataItem extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.card .media {
|
||||
align-items: center;
|
||||
|
||||
& > button {
|
||||
margin-left: 1rem;
|
||||
@include margin-left(1rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -9,9 +9,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<b-field grouped :label="$t('Find or add an element')">
|
||||
<b-field
|
||||
grouped
|
||||
:label="$t('Find or add an element')"
|
||||
label-for="event-metadata-autocomplete"
|
||||
>
|
||||
<b-autocomplete
|
||||
expanded
|
||||
:clear-on-select="true"
|
||||
v-model="search"
|
||||
ref="autocomplete"
|
||||
:data="filteredDataArray"
|
||||
@ -19,7 +24,9 @@
|
||||
group-options="items"
|
||||
open-on-focus
|
||||
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
|
||||
id="event-metadata-autocomplete"
|
||||
@select="(option) => addElement(option)"
|
||||
dir="auto"
|
||||
>
|
||||
<template slot-scope="props">
|
||||
<div class="media">
|
||||
@ -32,6 +39,7 @@
|
||||
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
/>
|
||||
<b-icon v-else-if="props.option.icon" :icon="props.option.icon" />
|
||||
<b-icon v-else icon="help-circle" />
|
||||
@ -55,7 +63,11 @@
|
||||
</b-button>
|
||||
</p>
|
||||
</b-field>
|
||||
<b-modal has-modal-card v-model="showNewElementModal">
|
||||
<b-modal
|
||||
has-modal-card
|
||||
v-model="showNewElementModal"
|
||||
:close-button-aria-label="$t('Close')"
|
||||
>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<button
|
||||
@ -129,7 +141,10 @@ export default class EventMetadataList extends Vue {
|
||||
}
|
||||
|
||||
set metadata(metadata: IEventMetadata[]) {
|
||||
this.$emit("input", metadata);
|
||||
this.$emit(
|
||||
"input",
|
||||
metadata.filter((elem) => elem)
|
||||
);
|
||||
}
|
||||
|
||||
localizedCategories: Record<EventMetadataCategories, string> = {
|
||||
|
@ -1,31 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<event-metadata-block
|
||||
v-if="!event.options.isOnline"
|
||||
:title="$t('Location')"
|
||||
:icon="physicalAddress ? physicalAddress.poiInfos.poiIcon.icon : 'earth'"
|
||||
>
|
||||
<div class="address-wrapper">
|
||||
<span v-if="!physicalAddress">{{ $t("No address defined") }}</span>
|
||||
<div class="address" v-if="physicalAddress">
|
||||
<div>
|
||||
<address>
|
||||
<p
|
||||
class="addressDescription"
|
||||
:title="physicalAddress.poiInfos.name"
|
||||
>
|
||||
{{ physicalAddress.poiInfos.name }}
|
||||
</p>
|
||||
<p class="has-text-grey-dark">
|
||||
{{ physicalAddress.poiInfos.alternativeName }}
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
<span
|
||||
<address-info :address="physicalAddress" />
|
||||
<b-button
|
||||
type="is-text"
|
||||
class="map-show-button"
|
||||
@click="showMap = !showMap"
|
||||
@click="$emit('showMapModal', true)"
|
||||
v-if="physicalAddress.geom"
|
||||
>{{ $t("Show map") }}</span
|
||||
>
|
||||
{{ $t("Show map") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</event-metadata-block>
|
||||
@ -34,6 +25,8 @@
|
||||
:beginsOn="event.beginsOn"
|
||||
:show-start-time="event.options.showStartTime"
|
||||
:show-end-time="event.options.showEndTime"
|
||||
:timezone="event.options.timezone"
|
||||
:userTimezone="userTimezone"
|
||||
:endsOn="event.endsOn"
|
||||
/>
|
||||
</event-metadata-block>
|
||||
@ -140,91 +133,12 @@
|
||||
>
|
||||
<span v-else>{{ extra.value }}</span>
|
||||
</event-metadata-block>
|
||||
<b-modal
|
||||
class="map-modal"
|
||||
v-if="physicalAddress && physicalAddress.geom"
|
||||
:active.sync="showMap"
|
||||
has-modal-card
|
||||
full-screen
|
||||
>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<button type="button" class="delete" @click="showMap = false" />
|
||||
</header>
|
||||
<div class="modal-card-body">
|
||||
<section class="map">
|
||||
<map-leaflet
|
||||
:coords="physicalAddress.geom"
|
||||
:marker="{
|
||||
text: physicalAddress.fullName,
|
||||
icon: physicalAddress.poiInfos.poiIcon.icon,
|
||||
}"
|
||||
/>
|
||||
</section>
|
||||
<section class="columns is-centered map-footer">
|
||||
<div class="column is-half has-text-centered">
|
||||
<p class="address">
|
||||
<i class="mdi mdi-map-marker"></i>
|
||||
{{ physicalAddress.fullName }}
|
||||
</p>
|
||||
<p class="getting-there">{{ $t("Getting there") }}</p>
|
||||
<div
|
||||
class="buttons"
|
||||
v-if="
|
||||
addressLinkToRouteByCar ||
|
||||
addressLinkToRouteByBike ||
|
||||
addressLinkToRouteByFeet
|
||||
"
|
||||
>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByFeet"
|
||||
:href="addressLinkToRouteByFeet"
|
||||
>
|
||||
<i class="mdi mdi-walk"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByBike"
|
||||
:href="addressLinkToRouteByBike"
|
||||
>
|
||||
<i class="mdi mdi-bike"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByTransit"
|
||||
:href="addressLinkToRouteByTransit"
|
||||
>
|
||||
<i class="mdi mdi-bus"></i
|
||||
></a>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByCar"
|
||||
:href="addressLinkToRouteByCar"
|
||||
>
|
||||
<i class="mdi mdi-car"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Address } from "@/types/address.model";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import {
|
||||
EventMetadataKeyType,
|
||||
EventMetadataType,
|
||||
RoutingTransportationType,
|
||||
RoutingType,
|
||||
} from "@/types/enums";
|
||||
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
@ -234,11 +148,13 @@ import EventMetadataBlock from "./EventMetadataBlock.vue";
|
||||
import EventFullDate from "./EventFullDate.vue";
|
||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import AddressInfo from "../../components/Address/AddressInfo.vue";
|
||||
import {
|
||||
IEventMetadata,
|
||||
IEventMetadataDescription,
|
||||
} from "@/types/event-metadata";
|
||||
import { eventMetaDataList } from "../../services/EventMetadata";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@ -246,15 +162,14 @@ import { eventMetaDataList } from "../../services/EventMetadata";
|
||||
EventFullDate,
|
||||
PopoverActorCard,
|
||||
ActorCard,
|
||||
"map-leaflet": () =>
|
||||
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
|
||||
AddressInfo,
|
||||
},
|
||||
})
|
||||
export default class EventMetadataSidebar extends Vue {
|
||||
@Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent;
|
||||
@Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig;
|
||||
|
||||
showMap = false;
|
||||
@Prop({ required: true }) user!: IUser | undefined;
|
||||
@Prop({ required: false, default: false }) showMap!: boolean;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
@ -265,21 +180,6 @@ export default class EventMetadataSidebar extends Vue {
|
||||
EventMetadataType = EventMetadataType;
|
||||
EventMetadataKeyType = EventMetadataKeyType;
|
||||
|
||||
RoutingParamType = {
|
||||
[RoutingType.OPENSTREETMAP]: {
|
||||
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
|
||||
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
|
||||
[RoutingTransportationType.TRANSIT]: null,
|
||||
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
|
||||
},
|
||||
[RoutingType.GOOGLE_MAPS]: {
|
||||
[RoutingTransportationType.FOOT]: "dirflg=w",
|
||||
[RoutingTransportationType.BIKE]: "dirflg=b",
|
||||
[RoutingTransportationType.TRANSIT]: "dirflg=r",
|
||||
[RoutingTransportationType.CAR]: "driving",
|
||||
},
|
||||
};
|
||||
|
||||
get physicalAddress(): Address | null {
|
||||
if (!this.event.physicalAddress) return null;
|
||||
|
||||
@ -296,50 +196,6 @@ export default class EventMetadataSidebar extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
makeNavigationPath(
|
||||
transportationType: RoutingTransportationType
|
||||
): string | undefined {
|
||||
const geometry = this.physicalAddress?.geom;
|
||||
if (geometry) {
|
||||
const routingType = this.config.maps.routing.type;
|
||||
/**
|
||||
* build urls to routing map
|
||||
*/
|
||||
if (!this.RoutingParamType[routingType][transportationType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlGeometry = geometry.split(";").reverse().join(",");
|
||||
|
||||
switch (routingType) {
|
||||
case RoutingType.GOOGLE_MAPS:
|
||||
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}`;
|
||||
case RoutingType.OPENSTREETMAP:
|
||||
default: {
|
||||
const bboxX = geometry.split(";").reverse()[0];
|
||||
const bboxY = geometry.split(";").reverse()[1];
|
||||
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}#map=14/${bboxX}/${bboxY}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get addressLinkToRouteByCar(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.CAR);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByBike(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.BIKE);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByFeet(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.FOOT);
|
||||
}
|
||||
|
||||
get addressLinkToRouteByTransit(): undefined | string {
|
||||
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
|
||||
}
|
||||
|
||||
urlToHostname(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
@ -372,6 +228,10 @@ export default class EventMetadataSidebar extends Vue {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get userTimezone(): string | undefined {
|
||||
return this.user?.settings?.timezone;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@ -401,50 +261,6 @@ div.address-wrapper {
|
||||
.map-show-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
span.addressDescription {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 0 auto;
|
||||
min-width: 100%;
|
||||
max-width: 4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:not(.addressDescription) {
|
||||
flex: 1;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.map-modal {
|
||||
.modal-card-head {
|
||||
justify-content: flex-end;
|
||||
button.delete {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
section.map {
|
||||
height: calc(100% - 8rem);
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
section.map-footer {
|
||||
p.address {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
div.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,19 +1,58 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="event-minimalist-card-wrapper"
|
||||
dir="auto"
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
>
|
||||
<date-calendar-icon
|
||||
class="calendar-icon"
|
||||
:date="event.beginsOn"
|
||||
:small="true"
|
||||
/>
|
||||
<div class="title-info-wrapper">
|
||||
<p class="event-minimalist-title">{{ event.title }}</p>
|
||||
<p v-if="event.physicalAddress" class="has-text-grey">
|
||||
{{ event.physicalAddress.description }}
|
||||
</p>
|
||||
<p v-else>
|
||||
<div class="event-preview mr-0 ml-0">
|
||||
<div>
|
||||
<div class="date-component">
|
||||
<date-calendar-icon :date="event.beginsOn" :small="true" />
|
||||
</div>
|
||||
<lazy-image-wrapper
|
||||
:picture="event.picture"
|
||||
:rounded="true"
|
||||
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-info-wrapper has-text-grey-dark">
|
||||
<h3 class="event-minimalist-title" :lang="event.language" dir="auto">
|
||||
<b-tag
|
||||
class="mr-2"
|
||||
type="is-warning"
|
||||
size="is-medium"
|
||||
v-if="event.draft"
|
||||
>{{ $t("Draft") }}</b-tag
|
||||
>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<inline-address
|
||||
v-if="event.physicalAddress"
|
||||
class="event-subtitle"
|
||||
:physical-address="event.physicalAddress"
|
||||
/>
|
||||
<div
|
||||
class="event-subtitle"
|
||||
v-else-if="event.options && event.options.isOnline"
|
||||
>
|
||||
<b-icon icon="video" />
|
||||
<span>{{ $t("Online") }}</span>
|
||||
</div>
|
||||
<div class="event-subtitle event-organizer" v-if="showOrganizer">
|
||||
<figure
|
||||
class="image is-24x24"
|
||||
v-if="organizer(event) && organizer(event).avatar"
|
||||
>
|
||||
<img class="is-rounded" :src="organizer(event).avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
<span class="organizer-name">
|
||||
{{ organizerDisplayName(event) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="participant-metadata">
|
||||
<b-icon icon="account-multiple" />
|
||||
<span v-if="event.options.maximumAttendeeCapacity !== 0">
|
||||
{{
|
||||
$tc(
|
||||
@ -64,44 +103,88 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { IEvent, organizer, organizerDisplayName } from "@/types/event.model";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DateCalendarIcon,
|
||||
LazyImageWrapper,
|
||||
InlineAddress,
|
||||
},
|
||||
})
|
||||
export default class EventMinimalistCard extends Vue {
|
||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
showOrganizer!: boolean;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
ParticipantRole = ParticipantRole;
|
||||
|
||||
organizerDisplayName = organizerDisplayName;
|
||||
|
||||
organizer = organizer;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@use "@/styles/_event-card";
|
||||
@import "~bulma/sass/utilities/mixins.sass";
|
||||
@import "@/variables.scss";
|
||||
|
||||
.event-minimalist-card-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-gap: 5px 10px;
|
||||
grid-template-areas: "preview" "body";
|
||||
color: initial;
|
||||
align-items: flex-start;
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 200px 3fr;
|
||||
grid-template-areas: "preview body";
|
||||
}
|
||||
|
||||
.event-preview {
|
||||
& > div {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
|
||||
div.date-component {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-icon {
|
||||
margin-right: 1rem;
|
||||
@include margin-right(1rem);
|
||||
}
|
||||
|
||||
.title-info-wrapper {
|
||||
flex: 2;
|
||||
|
||||
.event-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
|
||||
serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
padding-bottom: 5px;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-weight: bold;
|
||||
color: $title-color;
|
||||
}
|
||||
|
||||
::v-deep .icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<article class="box">
|
||||
<div class="identity-header">
|
||||
<article class="box mb-5 mt-4">
|
||||
<div class="identity-header" dir="auto">
|
||||
<figure class="image is-24x24" v-if="participation.actor.avatar">
|
||||
<img
|
||||
class="is-rounded"
|
||||
@ -10,80 +10,107 @@
|
||||
width="24"
|
||||
/>
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
{{ displayNameAndUsername(participation.actor) }}
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<div class="date-component">
|
||||
<date-calendar-icon
|
||||
:date="participation.event.beginsOn"
|
||||
:small="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="content-and-actions">
|
||||
<div class="list-card-content">
|
||||
<div class="title-wrapper">
|
||||
<div class="event-preview mr-0 ml-0">
|
||||
<div>
|
||||
<div class="date-component">
|
||||
<date-calendar-icon
|
||||
:date="participation.event.beginsOn"
|
||||
:small="true"
|
||||
/>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: participation.event.uuid },
|
||||
}"
|
||||
>
|
||||
<h3 class="title">{{ participation.event.title }}</h3>
|
||||
<lazy-image-wrapper
|
||||
:rounded="true"
|
||||
:picture="participation.event.picture"
|
||||
style="
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="participation-actor">
|
||||
<span>
|
||||
<b-icon
|
||||
icon="earth"
|
||||
v-if="participation.event.visibility === EventVisibility.PUBLIC"
|
||||
/>
|
||||
<b-icon
|
||||
icon="link"
|
||||
v-else-if="
|
||||
participation.event.visibility === EventVisibility.UNLISTED
|
||||
"
|
||||
/>
|
||||
<b-icon
|
||||
icon="lock"
|
||||
v-else-if="
|
||||
participation.event.visibility === EventVisibility.PRIVATE
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
participation.event.physicalAddress &&
|
||||
participation.event.physicalAddress.locality
|
||||
"
|
||||
>{{ participation.event.physicalAddress.locality }} -</span
|
||||
</div>
|
||||
<div class="list-card-content">
|
||||
<div class="title-wrapper" dir="auto">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: participation.event.uuid },
|
||||
}"
|
||||
>
|
||||
<i18n
|
||||
tag="span"
|
||||
path="Organized by {name}"
|
||||
v-if="organizerActor.id !== currentActor.id"
|
||||
>
|
||||
<popover-actor-card
|
||||
slot="name"
|
||||
:actor="organizerActor"
|
||||
:inline="true"
|
||||
>
|
||||
{{ organizerActor.displayName() }}
|
||||
</popover-actor-card>
|
||||
</i18n>
|
||||
<span v-else>{{ $t("Organized by you") }}</span>
|
||||
<h3 class="title" :lang="participation.event.language">
|
||||
{{ participation.event.title }}
|
||||
</h3>
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
<inline-address
|
||||
v-if="participation.event.physicalAddress"
|
||||
class="event-subtitle"
|
||||
:physical-address="participation.event.physicalAddress"
|
||||
/>
|
||||
<div
|
||||
class="event-subtitle"
|
||||
v-else-if="
|
||||
participation.event.options &&
|
||||
participation.event.options.isOnline
|
||||
"
|
||||
>
|
||||
<b-icon icon="video" />
|
||||
<span>{{ $t("Online") }}</span>
|
||||
</div>
|
||||
<div class="event-subtitle event-organizer">
|
||||
<figure
|
||||
class="image is-24x24"
|
||||
v-if="
|
||||
organizer(participation.event) &&
|
||||
organizer(participation.event).avatar
|
||||
"
|
||||
>
|
||||
<img
|
||||
class="is-rounded"
|
||||
:src="organizer(participation.event).avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
<span class="organizer-name">
|
||||
{{ organizerDisplayName(participation.event) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="event-subtitle event-participants">
|
||||
<b-icon
|
||||
:class="{ 'has-text-danger': lastSeatsLeft }"
|
||||
icon="account-group"
|
||||
/>
|
||||
<span
|
||||
class="participant-stats"
|
||||
v-if="
|
||||
![
|
||||
ParticipantRole.PARTICIPANT,
|
||||
ParticipantRole.NOT_APPROVED,
|
||||
].includes(participation.role)
|
||||
"
|
||||
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
<!-- Less than 10 seats left -->
|
||||
<span class="has-text-danger" v-if="lastSeatsLeft">
|
||||
{{
|
||||
$t("{number} seats left", {
|
||||
number: seatsLeft,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="participation.event.options.maximumAttendeeCapacity !== 0"
|
||||
v-else-if="
|
||||
participation.event.options.maximumAttendeeCapacity !== 0
|
||||
"
|
||||
>
|
||||
{{
|
||||
$tc(
|
||||
@ -111,28 +138,27 @@
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-if="participation.event.participantStats.notApproved > 0">
|
||||
<b-button
|
||||
type="is-text"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
query: { role: ParticipantRole.NOT_APPROVED },
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
$tc(
|
||||
"{count} requests waiting",
|
||||
participation.event.participantStats.notApproved,
|
||||
{
|
||||
count: participation.event.participantStats.notApproved,
|
||||
}
|
||||
)
|
||||
}}
|
||||
</b-button>
|
||||
</span>
|
||||
<b-button
|
||||
v-if="participation.event.participantStats.notApproved > 0"
|
||||
type="is-text"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
query: { role: ParticipantRole.NOT_APPROVED },
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
$tc(
|
||||
"{count} requests waiting",
|
||||
participation.event.participantStats.notApproved,
|
||||
{
|
||||
count: participation.event.participantStats.notApproved,
|
||||
}
|
||||
)
|
||||
}}
|
||||
</b-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -233,7 +259,11 @@ import { mixins } from "vue-class-component";
|
||||
import { RawLocation, Route } from "vue-router";
|
||||
import { EventVisibility, ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "../../types/participant.model";
|
||||
import { IEventCardOptions } from "../../types/event.model";
|
||||
import {
|
||||
IEventCardOptions,
|
||||
organizer,
|
||||
organizerDisplayName,
|
||||
} from "../../types/event.model";
|
||||
import { displayNameAndUsername, IActor, IPerson } from "../../types/actor";
|
||||
import ActorMixin from "../../mixins/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
@ -241,6 +271,9 @@ import EventMixin from "../../mixins/event";
|
||||
import RouteName from "../../router/name";
|
||||
import { changeIdentity } from "../../utils/auth";
|
||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
import { PropType } from "vue";
|
||||
|
||||
const defaultOptions: IEventCardOptions = {
|
||||
hideDate: true,
|
||||
@ -254,6 +287,8 @@ const defaultOptions: IEventCardOptions = {
|
||||
components: {
|
||||
DateCalendarIcon,
|
||||
PopoverActorCard,
|
||||
LazyImageWrapper,
|
||||
InlineAddress,
|
||||
},
|
||||
apollo: {
|
||||
currentActor: {
|
||||
@ -261,11 +296,15 @@ const defaultOptions: IEventCardOptions = {
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class EventListCard extends mixins(ActorMixin, EventMixin) {
|
||||
export default class EventParticipationCard extends mixins(
|
||||
ActorMixin,
|
||||
EventMixin
|
||||
) {
|
||||
/**
|
||||
* The participation associated
|
||||
*/
|
||||
@Prop({ required: true }) participation!: IParticipant;
|
||||
@Prop({ required: true, type: Object as PropType<IParticipant> })
|
||||
participation!: IParticipant;
|
||||
|
||||
/**
|
||||
* Options are merged with default options
|
||||
@ -281,6 +320,10 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
|
||||
|
||||
displayNameAndUsername = displayNameAndUsername;
|
||||
|
||||
organizerDisplayName = organizerDisplayName;
|
||||
|
||||
organizer = organizer;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
get mergedOptions(): IEventCardOptions {
|
||||
@ -304,13 +347,13 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
|
||||
participation.actor.id !== this.currentActor.id &&
|
||||
participation.event.organizerActor
|
||||
) {
|
||||
const organizer = participation.event.organizerActor as IPerson;
|
||||
await changeIdentity(this.$apollo.provider.defaultClient, organizer);
|
||||
const organizerActor = participation.event.organizerActor as IPerson;
|
||||
await changeIdentity(this.$apollo.provider.defaultClient, organizerActor);
|
||||
this.$buefy.notification.open({
|
||||
message: this.$t(
|
||||
"Current identity has been changed to {identityName} in order to manage this event.",
|
||||
{
|
||||
identityName: organizer.preferredUsername,
|
||||
identityName: organizerActor.preferredUsername,
|
||||
}
|
||||
) as string,
|
||||
type: "is-info",
|
||||
@ -330,16 +373,37 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
|
||||
}
|
||||
return this.participation.event.organizerActor;
|
||||
}
|
||||
|
||||
get seatsLeft(): number | null {
|
||||
if (this.participation.event.options.maximumAttendeeCapacity > 0) {
|
||||
return (
|
||||
this.participation.event.options.maximumAttendeeCapacity -
|
||||
this.participation.event.participantStats.participant
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get lastSeatsLeft(): boolean {
|
||||
if (this.seatsLeft) {
|
||||
return this.seatsLeft < 10;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@use "@/styles/_event-card";
|
||||
@import "~bulma/sass/utilities/mixins.sass";
|
||||
|
||||
article.box {
|
||||
div.tag-container {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
margin-right: -5px;
|
||||
@include margin-left(-5px);
|
||||
z-index: 10;
|
||||
max-width: 40%;
|
||||
|
||||
@ -359,49 +423,67 @@ article.box {
|
||||
|
||||
.list-card {
|
||||
display: flex;
|
||||
padding: 0 6px;
|
||||
padding: 0 6px 0 0;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
||||
div.date-component {
|
||||
align-self: flex-start;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: 1px;
|
||||
height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 0rem;
|
||||
}
|
||||
|
||||
.content-and-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 1rem;
|
||||
display: grid;
|
||||
grid-gap: 5px 10px;
|
||||
grid-template-areas: "preview" "body" "actions";
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
grid-template-areas: "preview body" "actions actions";
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 1fr 3fr 1fr;
|
||||
grid-template-areas: "preview body actions";
|
||||
}
|
||||
|
||||
.event-preview {
|
||||
grid-area: preview;
|
||||
|
||||
& > div {
|
||||
height: 128px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
div.date-component {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
object-position: center;
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding-right: 7.5px;
|
||||
padding: 7px;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
div.list-card-content {
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
min-width: 350px;
|
||||
grid-area: body;
|
||||
|
||||
.participation-actor span,
|
||||
.participant-stats span {
|
||||
.participant-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
|
||||
button {
|
||||
height: auto;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
div.title-wrapper {
|
||||
@ -419,11 +501,11 @@ article.box {
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
line-height: 1em;
|
||||
font-size: 1.4em;
|
||||
padding-bottom: 5px;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
margin: auto 0;
|
||||
font-weight: bold;
|
||||
color: $title-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -434,10 +516,10 @@ article.box {
|
||||
background: $yellow-2;
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
padding-left: calc(48px + 15px);
|
||||
|
||||
figure {
|
||||
padding-right: 3px;
|
||||
figure,
|
||||
span.icon {
|
||||
@include padding-right(3px);
|
||||
}
|
||||
}
|
||||
|
@ -1,59 +1,94 @@
|
||||
<template>
|
||||
<div class="address-autocomplete">
|
||||
<b-field expanded>
|
||||
<template slot="label">
|
||||
{{ actualLabel }}
|
||||
<b-button
|
||||
v-if="canShowLocateMeButton && !gettingLocation"
|
||||
size="is-small"
|
||||
icon-right="map-marker"
|
||||
@click="locateMe"
|
||||
/>
|
||||
<span v-else-if="gettingLocation">{{ $t("Getting location") }}</span>
|
||||
</template>
|
||||
<b-autocomplete
|
||||
:data="addressData"
|
||||
v-model="queryText"
|
||||
:placeholder="$t('e.g. 10 Rue Jangot')"
|
||||
field="fullName"
|
||||
:loading="isFetching"
|
||||
@typing="fetchAsyncData"
|
||||
icon="map-marker"
|
||||
<div class="address-autocomplete columns is-desktop">
|
||||
<div class="column">
|
||||
<b-field
|
||||
:label-for="id"
|
||||
expanded
|
||||
@select="updateSelected"
|
||||
:message="fieldErrors"
|
||||
:type="{ 'is-danger': fieldErrors.length }"
|
||||
>
|
||||
<template #default="{ option }">
|
||||
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
||||
<b>{{ option.poiInfos.name }}</b
|
||||
><br />
|
||||
<small>{{ option.poiInfos.alternativeName }}</small>
|
||||
<template slot="label">
|
||||
{{ actualLabel }}
|
||||
<span
|
||||
class="is-size-6 has-text-weight-normal"
|
||||
v-if="gettingLocation"
|
||||
>{{ $t("Getting location") }}</span
|
||||
>
|
||||
</template>
|
||||
<template slot="empty">
|
||||
<span v-if="isFetching">{{ $t("Searching…") }}</span>
|
||||
<div v-else-if="queryText.length >= 3" class="is-enabled">
|
||||
<span>{{ $t('No results for "{queryText}"', { queryText }) }}</span>
|
||||
<span>{{
|
||||
$t(
|
||||
"You can try another search term or drag and drop the marker on the map",
|
||||
{
|
||||
queryText,
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
<!-- <p class="control" @click="openNewAddressModal">-->
|
||||
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
|
||||
<!-- </p>-->
|
||||
</div>
|
||||
</template>
|
||||
</b-autocomplete>
|
||||
<b-button
|
||||
:disabled="!queryText"
|
||||
@click="resetAddress"
|
||||
class="reset-area"
|
||||
icon-left="close"
|
||||
/>
|
||||
</b-field>
|
||||
<div class="map" v-if="selected && selected.geom && selected.poiInfos">
|
||||
<p class="control" v-if="canShowLocateMeButton && !gettingLocation">
|
||||
<b-button
|
||||
icon-right="map-marker"
|
||||
@click="locateMe"
|
||||
:title="$t('Use my location')"
|
||||
/>
|
||||
</p>
|
||||
<b-autocomplete
|
||||
:data="addressData"
|
||||
v-model="queryText"
|
||||
:placeholder="$t('e.g. 10 Rue Jangot')"
|
||||
field="fullName"
|
||||
:loading="isFetching"
|
||||
@typing="fetchAsyncData"
|
||||
:icon="canShowLocateMeButton ? null : 'map-marker'"
|
||||
expanded
|
||||
@select="updateSelected"
|
||||
v-bind="$attrs"
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
dir="auto"
|
||||
>
|
||||
<template #default="{ option }">
|
||||
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
||||
<b>{{ option.poiInfos.name }}</b
|
||||
><br />
|
||||
<small>{{ option.poiInfos.alternativeName }}</small>
|
||||
</template>
|
||||
<template #empty>
|
||||
<span v-if="isFetching">{{ $t("Searching…") }}</span>
|
||||
<div v-else-if="queryText.length >= 3" class="is-enabled">
|
||||
<span>{{
|
||||
$t('No results for "{queryText}"', { queryText })
|
||||
}}</span>
|
||||
<span>{{
|
||||
$t(
|
||||
"You can try another search term or drag and drop the marker on the map",
|
||||
{
|
||||
queryText,
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
<!-- <p class="control" @click="openNewAddressModal">-->
|
||||
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
|
||||
<!-- </p>-->
|
||||
</div>
|
||||
</template>
|
||||
</b-autocomplete>
|
||||
<b-button
|
||||
:disabled="!queryText"
|
||||
@click="resetAddress"
|
||||
class="reset-area"
|
||||
icon-left="close"
|
||||
:title="$t('Clear address field')"
|
||||
/>
|
||||
</b-field>
|
||||
<div
|
||||
class="card"
|
||||
v-if="!hideSelected && (selected.originId || selected.url)"
|
||||
>
|
||||
<div class="card-content">
|
||||
<address-info
|
||||
:address="selected"
|
||||
:show-icon="true"
|
||||
:show-timezone="true"
|
||||
:user-timezone="userTimezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="map column"
|
||||
v-if="!hideMap && selected && selected.geom && selected.poiInfos"
|
||||
>
|
||||
<map-leaflet
|
||||
:coords="selected.geom"
|
||||
:marker="{
|
||||
@ -65,149 +100,47 @@
|
||||
:readOnly="false"
|
||||
/>
|
||||
</div>
|
||||
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
|
||||
<!-- <div class="modal-card" style="width: auto">-->
|
||||
<!-- <header class="modal-card-head">-->
|
||||
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
|
||||
<!-- </header>-->
|
||||
<!-- <section class="modal-card-body">-->
|
||||
<!-- <form>-->
|
||||
<!-- <b-field :label="$t('Name')">-->
|
||||
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
|
||||
<!-- </b-field>-->
|
||||
|
||||
<!-- <b-field :label="$t('Street')">-->
|
||||
<!-- <b-input v-model="selected.street" />-->
|
||||
<!-- </b-field>-->
|
||||
|
||||
<!-- <b-field grouped>-->
|
||||
<!-- <b-field :label="$t('Postal Code')">-->
|
||||
<!-- <b-input v-model="selected.postalCode" />-->
|
||||
<!-- </b-field>-->
|
||||
|
||||
<!-- <b-field :label="$t('Locality')">-->
|
||||
<!-- <b-input v-model="selected.locality" />-->
|
||||
<!-- </b-field>-->
|
||||
<!-- </b-field>-->
|
||||
|
||||
<!-- <b-field grouped>-->
|
||||
<!-- <b-field :label="$t('Region')">-->
|
||||
<!-- <b-input v-model="selected.region" />-->
|
||||
<!-- </b-field>-->
|
||||
|
||||
<!-- <b-field :label="$t('Country')">-->
|
||||
<!-- <b-input v-model="selected.country" />-->
|
||||
<!-- </b-field>-->
|
||||
<!-- </b-field>-->
|
||||
<!-- </form>-->
|
||||
<!-- </section>-->
|
||||
<!-- <footer class="modal-card-foot">-->
|
||||
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
|
||||
<!-- </footer>-->
|
||||
<!-- </div>-->
|
||||
<!-- </b-modal>-->
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
|
||||
import { LatLng } from "leaflet";
|
||||
import debounce from "lodash/debounce";
|
||||
import { DebouncedFunc } from "lodash";
|
||||
import { Address, IAddress } from "../../types/address.model";
|
||||
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
|
||||
import { CONFIG } from "../../graphql/config";
|
||||
import { IConfig } from "../../types/config.model";
|
||||
import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
|
||||
import AddressInfo from "../../components/Address/AddressInfo.vue";
|
||||
|
||||
@Component({
|
||||
inheritAttrs: false,
|
||||
components: {
|
||||
"map-leaflet": () =>
|
||||
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
|
||||
},
|
||||
apollo: {
|
||||
config: CONFIG,
|
||||
AddressInfo,
|
||||
},
|
||||
})
|
||||
export default class FullAddressAutoComplete extends Vue {
|
||||
@Prop({ required: true }) value!: IAddress;
|
||||
|
||||
export default class FullAddressAutoComplete extends Mixins(
|
||||
AddressAutoCompleteMixin
|
||||
) {
|
||||
@Prop({ required: false, default: "" }) label!: string;
|
||||
|
||||
addressData: IAddress[] = [];
|
||||
|
||||
selected: IAddress = new Address();
|
||||
|
||||
isFetching = false;
|
||||
|
||||
queryText: string = (this.value && new Address(this.value).fullName) || "";
|
||||
@Prop({ required: false }) userTimezone!: string;
|
||||
@Prop({ required: false, default: false, type: Boolean }) disabled!: boolean;
|
||||
@Prop({ required: false, default: false, type: Boolean }) hideMap!: boolean;
|
||||
@Prop({ required: false, default: false, type: Boolean })
|
||||
hideSelected!: boolean;
|
||||
|
||||
addressModalActive = false;
|
||||
|
||||
private gettingLocation = false;
|
||||
private static componentId = 0;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
private location!: GeolocationPosition;
|
||||
|
||||
private gettingLocationError: any;
|
||||
|
||||
private mapDefaultZoom = 15;
|
||||
|
||||
config!: IConfig;
|
||||
|
||||
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
|
||||
|
||||
// We put this in data because of issues like
|
||||
// https://github.com/vuejs/vue-class-component/issues/263
|
||||
data(): Record<string, unknown> {
|
||||
return {
|
||||
fetchAsyncData: debounce(this.asyncData, 200),
|
||||
};
|
||||
created(): void {
|
||||
FullAddressAutoComplete.componentId += 1;
|
||||
}
|
||||
|
||||
async asyncData(query: string): Promise<void> {
|
||||
if (!query.length) {
|
||||
this.addressData = [];
|
||||
this.selected = new Address();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < 3) {
|
||||
this.addressData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
const result = await this.$apollo.query({
|
||||
query: ADDRESS,
|
||||
fetchPolicy: "network-only",
|
||||
variables: {
|
||||
query,
|
||||
locale: this.$i18n.locale,
|
||||
},
|
||||
});
|
||||
|
||||
this.addressData = result.data.searchAddress.map(
|
||||
(address: IAddress) => new Address(address)
|
||||
);
|
||||
this.isFetching = false;
|
||||
}
|
||||
|
||||
@Watch("config")
|
||||
watchConfig(config: IConfig): void {
|
||||
if (!config.geocoding.autocomplete) {
|
||||
// If autocomplete is disabled, we put a larger debounce value
|
||||
// so that we don't request with incomplete address
|
||||
this.fetchAsyncData = debounce(this.asyncData, 2000);
|
||||
}
|
||||
get id(): string {
|
||||
return `full-address-autocomplete-${FullAddressAutoComplete.componentId}`;
|
||||
}
|
||||
|
||||
@Watch("value")
|
||||
updateEditing(): void {
|
||||
if (!(this.value && this.value.id)) return;
|
||||
this.selected = this.value;
|
||||
const address = new Address(this.selected);
|
||||
if (address.poiInfos) {
|
||||
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateSelected(option: IAddress): void {
|
||||
@ -225,30 +158,6 @@ export default class FullAddressAutoComplete extends Vue {
|
||||
this.addressModalActive = true;
|
||||
}
|
||||
|
||||
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
|
||||
// If the position has been updated through autocomplete selection, no need to geocode it!
|
||||
if (this.checkCurrentPosition(e)) return;
|
||||
const result = await this.$apollo.query({
|
||||
query: REVERSE_GEOCODE,
|
||||
variables: {
|
||||
latitude: e.lat,
|
||||
longitude: e.lng,
|
||||
zoom,
|
||||
locale: this.$i18n.locale,
|
||||
},
|
||||
});
|
||||
|
||||
this.addressData = result.data.reverseGeocode.map(
|
||||
(address: IAddress) => new Address(address)
|
||||
);
|
||||
if (this.addressData.length > 0) {
|
||||
const defaultAddress = new Address(this.addressData[0]);
|
||||
this.selected = defaultAddress;
|
||||
this.$emit("input", this.selected);
|
||||
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
|
||||
}
|
||||
}
|
||||
|
||||
checkCurrentPosition(e: LatLng): boolean {
|
||||
if (!this.selected || !this.selected.geom) return false;
|
||||
const lat = parseFloat(this.selected.geom.split(";")[1]);
|
||||
@ -257,25 +166,6 @@ export default class FullAddressAutoComplete extends Vue {
|
||||
return e.lat === lat && e.lng === lon;
|
||||
}
|
||||
|
||||
async locateMe(): Promise<void> {
|
||||
this.gettingLocation = true;
|
||||
try {
|
||||
this.gettingLocation = false;
|
||||
this.location = await FullAddressAutoComplete.getLocation();
|
||||
this.mapDefaultZoom = 12;
|
||||
this.reverseGeoCode(
|
||||
new LatLng(
|
||||
this.location.coords.latitude,
|
||||
this.location.coords.longitude
|
||||
),
|
||||
12
|
||||
);
|
||||
} catch (e) {
|
||||
this.gettingLocation = false;
|
||||
this.gettingLocationError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
get actualLabel(): string {
|
||||
return this.label || (this.$t("Find an address") as string);
|
||||
}
|
||||
@ -284,38 +174,6 @@ export default class FullAddressAutoComplete extends Vue {
|
||||
get canShowLocateMeButton(): boolean {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
static async getLocation(): Promise<GeolocationPosition> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!("geolocation" in navigator)) {
|
||||
reject(new Error("Geolocation is not available."));
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
resolve(pos);
|
||||
},
|
||||
(err) => {
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Watch("queryText")
|
||||
resetAddressOnEmptyField(queryText: string): void {
|
||||
if (queryText === "" && this.selected?.id) {
|
||||
console.log("doing reset");
|
||||
this.resetAddress();
|
||||
}
|
||||
}
|
||||
|
||||
resetAddress(): void {
|
||||
this.$emit("input", null);
|
||||
this.queryText = "";
|
||||
this.selected = new Address();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
|
90
js/src/components/Event/GroupedMultiEventMinimalistCard.vue
Normal file
90
js/src/components/Event/GroupedMultiEventMinimalistCard.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="events-wrapper">
|
||||
<div class="month-group" v-for="key of keys" :key="key">
|
||||
<h2 class="is-size-5 month-name">
|
||||
{{ monthName(groupEvents(key)[0]) }}
|
||||
</h2>
|
||||
<event-minimalist-card
|
||||
class="py-4"
|
||||
v-for="event in groupEvents(key)"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import EventMinimalistCard from "./EventMinimalistCard.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EventMinimalistCard,
|
||||
},
|
||||
})
|
||||
export default class GroupedMultiEventMinimalistCard extends Vue {
|
||||
@Prop({ type: Array as PropType<IEvent[]>, required: true })
|
||||
events!: IEvent[];
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
isCurrentActorMember!: boolean;
|
||||
|
||||
get monthlyGroupedEvents(): Map<string, IEvent[]> {
|
||||
return this.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
|
||||
const beginsOn = new Date(event.beginsOn);
|
||||
const month = `${beginsOn.getUTCMonth()}-${beginsOn.getUTCFullYear()}`;
|
||||
const monthEvents = acc.get(month) || [];
|
||||
acc.set(month, [...monthEvents, event]);
|
||||
return acc;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
get keys(): string[] {
|
||||
return Array.from(this.monthlyGroupedEvents.keys()).sort((a, b) =>
|
||||
b.localeCompare(a)
|
||||
);
|
||||
}
|
||||
|
||||
groupEvents(key: string): IEvent[] {
|
||||
return this.monthlyGroupedEvents.get(key) || [];
|
||||
}
|
||||
|
||||
monthName(event: IEvent): string {
|
||||
const beginsOn = new Date(event.beginsOn);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(beginsOn);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.events-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template: 1fr;
|
||||
}
|
||||
.month-group {
|
||||
.month-name {
|
||||
text-transform: capitalize;
|
||||
text-transform: capitalize;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
font-size: 1.3rem;
|
||||
|
||||
&::after {
|
||||
background: $orange-3;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
content: "";
|
||||
width: calc(100% + 30px);
|
||||
height: 3px;
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
39
js/src/components/Event/MultiCard.vue
Normal file
39
js/src/components/Event/MultiCard.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="multi-card-event">
|
||||
<event-card
|
||||
class="event-card"
|
||||
v-for="event in events"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import EventCard from "./EventCard.vue";
|
||||
@Component({
|
||||
components: {
|
||||
EventCard,
|
||||
},
|
||||
})
|
||||
export default class MultiCard extends Vue {
|
||||
@Prop({ type: Array as PropType<IEvent[]>, required: true })
|
||||
events!: IEvent[];
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.multi-card-event {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
grid-column-gap: 20px;
|
||||
grid-row-gap: 30px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
.event-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
38
js/src/components/Event/MultiEventMinimalistCard.vue
Normal file
38
js/src/components/Event/MultiEventMinimalistCard.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="events-wrapper">
|
||||
<event-minimalist-card
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
:showOrganizer="showOrganizer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import EventMinimalistCard from "./EventMinimalistCard.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EventMinimalistCard,
|
||||
},
|
||||
})
|
||||
export default class MultiEventMinimalistCard extends Vue {
|
||||
@Prop({ type: Array as PropType<IEvent[]>, required: true })
|
||||
events!: IEvent[];
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
isCurrentActorMember!: boolean;
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
showOrganizer!: boolean;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.events-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template: 1fr;
|
||||
}
|
||||
</style>
|
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="list is-hoverable">
|
||||
<b-input
|
||||
dir="auto"
|
||||
:placeholder="$t('Filter by profile or group name')"
|
||||
v-model="actorFilter"
|
||||
/>
|
||||
@ -11,10 +12,10 @@
|
||||
v-for="availableActor in actualFilteredAvailableActors"
|
||||
:key="availableActor.id"
|
||||
>
|
||||
<div class="media">
|
||||
<div class="media" dir="auto">
|
||||
<figure class="image is-48x48" v-if="availableActor.avatar">
|
||||
<img
|
||||
class="media-left is-rounded"
|
||||
class="image is-rounded"
|
||||
:src="availableActor.avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
@ -121,6 +122,7 @@ export default class OrganizerPicker extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
::v-deep .list-item {
|
||||
box-sizing: content-box;
|
||||
|
||||
@ -133,11 +135,11 @@ export default class OrganizerPicker extends Vue {
|
||||
|
||||
figure.image,
|
||||
span.icon.media-left {
|
||||
margin-right: 0.5rem;
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
|
||||
span.icon.media-left {
|
||||
margin-left: -0.25rem;
|
||||
@include margin-left(-0.25rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
<div
|
||||
v-if="inline && selectedActor.id"
|
||||
class="inline box"
|
||||
dir="auto"
|
||||
@click="isComponentModalActive = true"
|
||||
>
|
||||
<div class="media">
|
||||
@ -12,14 +13,14 @@
|
||||
<img
|
||||
class="image is-rounded"
|
||||
:src="selectedActor.avatar.url"
|
||||
:alt="selectedActor.avatar.alt"
|
||||
:alt="selectedActor.avatar.alt || ''"
|
||||
/>
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-circle" />
|
||||
</div>
|
||||
<div class="media-content" v-if="selectedActor.name">
|
||||
<p class="is-4">{{ selectedActor.name }}</p>
|
||||
<p class="is-6 has-text-grey">
|
||||
<p class="is-6 has-text-grey-dark">
|
||||
{{ `@${selectedActor.preferredUsername}` }}
|
||||
</p>
|
||||
</div>
|
||||
@ -45,7 +46,11 @@
|
||||
/>
|
||||
<b-icon v-else size="is-large" icon="account-circle" />
|
||||
</span>
|
||||
<b-modal :active.sync="isComponentModalActive" has-modal-card>
|
||||
<b-modal
|
||||
:active.sync="isComponentModalActive"
|
||||
has-modal-card
|
||||
:close-button-aria-label="$t('Close')"
|
||||
>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ $t("Pick a profile or a group") }}</p>
|
||||
@ -65,6 +70,7 @@
|
||||
<b-input
|
||||
:placeholder="$t('Filter by name')"
|
||||
v-model="contactFilter"
|
||||
dir="auto"
|
||||
/>
|
||||
<p
|
||||
class="field"
|
||||
|
@ -30,18 +30,22 @@ A button to set your participation
|
||||
position="is-bottom-left"
|
||||
v-if="participation && participation.role === ParticipantRole.PARTICIPANT"
|
||||
>
|
||||
<button class="button is-success is-large" type="button" slot="trigger">
|
||||
<b-icon icon="check" />
|
||||
<template>
|
||||
<span>{{ $t("I participate") }}</span>
|
||||
</template>
|
||||
<b-icon icon="menu-down" />
|
||||
</button>
|
||||
<template #trigger="{ active }">
|
||||
<b-button
|
||||
type="is-success"
|
||||
size="is-large"
|
||||
icon-left="check"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
>
|
||||
{{ $t("I participate") }}
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="confirmLeave"
|
||||
@keyup.enter="confirmLeave"
|
||||
class="has-text-danger"
|
||||
>{{ $t("Cancel my participation…") }}</b-dropdown-item
|
||||
>
|
||||
@ -73,6 +77,7 @@ A button to set your participation
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="confirmLeave"
|
||||
@keyup.enter="confirmLeave"
|
||||
class="has-text-danger"
|
||||
>{{ $t("Cancel my participation request…") }}</b-dropdown-item
|
||||
>
|
||||
@ -101,21 +106,25 @@ A button to set your participation
|
||||
position="is-bottom-left"
|
||||
v-else-if="!participation && currentActor.id"
|
||||
>
|
||||
<button class="button is-primary is-large" type="button" slot="trigger">
|
||||
<template>
|
||||
<span>{{ $t("Participate") }}</span>
|
||||
</template>
|
||||
<b-icon icon="menu-down" />
|
||||
</button>
|
||||
<template #trigger="{ active }">
|
||||
<b-button
|
||||
type="is-primary"
|
||||
size="is-large"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
>
|
||||
{{ $t("Participate") }}
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item
|
||||
:value="true"
|
||||
aria-role="listitem"
|
||||
@click="joinEvent(currentActor)"
|
||||
@keyup.enter="joinEvent(currentActor)"
|
||||
>
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="currentActor.avatar">
|
||||
<div class="media-left" v-if="currentActor.avatar">
|
||||
<figure class="image is-32x32">
|
||||
<img class="is-rounded" :src="currentActor.avatar.url" alt />
|
||||
</figure>
|
||||
</div>
|
||||
@ -136,11 +145,13 @@ A button to set your participation
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="joinModal"
|
||||
@keyup.enter="joinModal"
|
||||
v-if="identities.length > 1"
|
||||
>{{ $t("with another identity…") }}</b-dropdown-item
|
||||
>
|
||||
</b-dropdown>
|
||||
<b-button
|
||||
rel="nofollow"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT,
|
||||
@ -154,6 +165,7 @@ A button to set your participation
|
||||
>
|
||||
<b-button
|
||||
tag="router-link"
|
||||
rel="nofollow"
|
||||
:to="{
|
||||
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
|
||||
params: { uuid: event.uuid },
|
||||
|
@ -86,11 +86,13 @@
|
||||
/></a>
|
||||
<a
|
||||
:href="telegramShareUrl"
|
||||
class="telegram"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Telegram"
|
||||
><b-icon icon="telegram" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
>
|
||||
<telegram-logo />
|
||||
</a>
|
||||
<a
|
||||
:href="linkedInShareUrl"
|
||||
target="_blank"
|
||||
@ -126,11 +128,13 @@ import { EventStatus, EventVisibility } from "@/types/enums";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import DiasporaLogo from "../Share/DiasporaLogo.vue";
|
||||
import MastodonLogo from "../Share/MastodonLogo.vue";
|
||||
import TelegramLogo from "../Share/TelegramLogo.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DiasporaLogo,
|
||||
MastodonLogo,
|
||||
TelegramLogo,
|
||||
},
|
||||
})
|
||||
export default class ShareEventModal extends Vue {
|
||||
@ -207,7 +211,8 @@ export default class ShareEventModal extends Vue {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.diaspora,
|
||||
.mastodon {
|
||||
.mastodon,
|
||||
.telegram {
|
||||
::v-deep span svg {
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<b-field>
|
||||
<b-field :label-for="id">
|
||||
<template slot="label">
|
||||
{{ $t("Add some tags") }}
|
||||
<b-tooltip
|
||||
@ -16,57 +16,82 @@
|
||||
:data="filteredTags"
|
||||
autocomplete
|
||||
:allow-new="true"
|
||||
:field="path"
|
||||
:field="'title'"
|
||||
icon="label"
|
||||
maxlength="20"
|
||||
maxtags="10"
|
||||
:placeholder="$t('Eg: Stockholm, Dance, Chess…')"
|
||||
@typing="getFilteredTags"
|
||||
:id="id"
|
||||
dir="auto"
|
||||
>
|
||||
</b-taginput>
|
||||
</b-field>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import get from "lodash/get";
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import { ITag } from "../../types/tag.model";
|
||||
import { FILTER_TAGS } from "@/graphql/tags";
|
||||
|
||||
@Component({
|
||||
computed: {
|
||||
tagsStrings: {
|
||||
get() {
|
||||
return this.$props.value.map((tag: ITag) => tag.title);
|
||||
},
|
||||
set(tagStrings) {
|
||||
const tagEntities = tagStrings.map((tag: string | ITag) => {
|
||||
if (typeof tag !== "string") {
|
||||
return tag;
|
||||
}
|
||||
return { title: tag, slug: tag } as ITag;
|
||||
});
|
||||
this.$emit("input", tagEntities);
|
||||
apollo: {
|
||||
tags: {
|
||||
query: FILTER_TAGS,
|
||||
variables() {
|
||||
return {
|
||||
filter: this.text,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class TagInput extends Vue {
|
||||
@Prop({ required: false, default: () => [] }) data!: ITag[];
|
||||
|
||||
@Prop({ required: true, default: "value" }) path!: string;
|
||||
|
||||
@Prop({ required: true }) value!: ITag[];
|
||||
|
||||
filteredTags: ITag[] = [];
|
||||
tags!: ITag[];
|
||||
|
||||
getFilteredTags(text: string): void {
|
||||
this.filteredTags = differenceBy(this.data, this.value, "id").filter(
|
||||
text = "";
|
||||
|
||||
private static componentId = 0;
|
||||
|
||||
created(): void {
|
||||
TagInput.componentId += 1;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return `tag-input-${TagInput.componentId}`;
|
||||
}
|
||||
|
||||
async getFilteredTags(text: string): Promise<void> {
|
||||
this.text = text;
|
||||
await this.$apollo.queries.tags.refetch();
|
||||
}
|
||||
|
||||
get filteredTags(): ITag[] {
|
||||
return differenceBy(this.tags, this.value, "id").filter(
|
||||
(option) =>
|
||||
get(option, this.path)
|
||||
option.title
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(text.toLowerCase()) >= 0
|
||||
.indexOf(this.text.toLowerCase()) >= 0 ||
|
||||
option.slug.toString().toLowerCase().indexOf(this.text.toLowerCase()) >=
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
get tagsStrings(): string[] {
|
||||
return (this.value || []).map((tag: ITag) => tag.title);
|
||||
}
|
||||
|
||||
set tagsStrings(tagsStrings: string[]) {
|
||||
const tagEntities = tagsStrings.map((tag: string | ITag) => {
|
||||
if (typeof tag !== "string") {
|
||||
return tag;
|
||||
}
|
||||
return { title: tag, slug: tag } as ITag;
|
||||
});
|
||||
this.$emit("input", tagEntities);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -20,11 +20,17 @@
|
||||
<ul>
|
||||
<li>
|
||||
<b-select
|
||||
:aria-label="$t('Language')"
|
||||
v-if="$i18n"
|
||||
v-model="locale"
|
||||
:placeholder="$t('Select a language')"
|
||||
>
|
||||
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
|
||||
<option
|
||||
v-for="(language, lang) in langs"
|
||||
:value="lang"
|
||||
:key="lang"
|
||||
:selected="isLangSelected(lang)"
|
||||
>
|
||||
{{ language }}
|
||||
</option>
|
||||
</b-select>
|
||||
@ -46,6 +52,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
rel="external"
|
||||
hreflang="en"
|
||||
href="https://forge.april.org/Chapril/mobilizon.chapril.org-mobilizon/src/branch/chapril/LICENSE"
|
||||
>
|
||||
@ -55,19 +62,25 @@
|
||||
<li>
|
||||
<a href="mailto:mobilizon-support@chapril.org">{{ $t("Contact") }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#navbar">{{ $t("Back to top") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="content has-text-centered">
|
||||
<i18n
|
||||
tag="span"
|
||||
path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
|
||||
>
|
||||
<a slot="mobilizon" href="https://joinmobilizon.org">{{
|
||||
<a rel="external" slot="mobilizon" href="https://joinmobilizon.org">{{
|
||||
$t("Mobilizon")
|
||||
}}</a>
|
||||
<span slot="date">{{ new Date().getFullYear() }}</span>
|
||||
<a href="https://joinmobilizon.org/hall-of-fame" slot="contributors">{{
|
||||
$t("more than 1360 contributors")
|
||||
}}</a>
|
||||
<a
|
||||
rel="external"
|
||||
href="https://joinmobilizon.org/hall-of-fame"
|
||||
slot="contributors"
|
||||
>{{ $t("more than 1360 contributors") }}</a
|
||||
>
|
||||
</i18n>
|
||||
</div>
|
||||
</footer>
|
||||
@ -108,6 +121,10 @@ export default class Footer extends Vue {
|
||||
this.locale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
isLangSelected(lang: string): boolean {
|
||||
return lang === this.locale;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@ -152,6 +169,13 @@ footer.footer {
|
||||
color: $white;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: $secondary;
|
||||
|
||||
&:focus {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
outline: 3px solid #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep span.select {
|
||||
|
@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
class="card"
|
||||
>
|
||||
<div class="card-image">
|
||||
<figure class="image is-16by9">
|
||||
<lazy-image-wrapper
|
||||
:picture="group.banner"
|
||||
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div class="media mb-2">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="group.avatar">
|
||||
<img class="is-rounded" :src="group.avatar.url" alt="" />
|
||||
@ -9,40 +23,114 @@
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>
|
||||
<h3>{{ group.name }}</h3>
|
||||
<p class="is-6 has-text-grey">
|
||||
<span v-if="group.domain">{{
|
||||
`@${group.preferredUsername}@${group.domain}`
|
||||
}}</span>
|
||||
<span v-else>{{ `@${group.preferredUsername}` }}</span>
|
||||
</p>
|
||||
</router-link>
|
||||
<h3 class="is-size-5 group-title" dir="auto">
|
||||
{{ displayName(group) }}
|
||||
</h3>
|
||||
<span class="is-6 has-text-grey-dark group-federated-username">
|
||||
{{ `@${usernameWithDomain(group)}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{ group.summary }}</p>
|
||||
<div class="content mb-2" dir="auto" v-html="group.summary" />
|
||||
<div class="card-custom-footer">
|
||||
<inline-address
|
||||
class="has-text-grey-dark"
|
||||
v-if="group.physicalAddress"
|
||||
:physicalAddress="group.physicalAddress"
|
||||
/>
|
||||
<p class="has-text-grey-dark">
|
||||
<b-icon icon="account" />
|
||||
{{
|
||||
$tc(
|
||||
"{count} members or followers",
|
||||
group.members.total + group.followers.total,
|
||||
{
|
||||
count: group.members.total + group.followers.total,
|
||||
}
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IGroup, usernameWithDomain } from "@/types/actor";
|
||||
import { displayName, IGroup, usernameWithDomain } from "@/types/actor";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
|
||||
@Component
|
||||
@Component({
|
||||
components: {
|
||||
LazyImageWrapper,
|
||||
InlineAddress,
|
||||
},
|
||||
})
|
||||
export default class GroupCard extends Vue {
|
||||
@Prop({ required: true }) group!: IGroup;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
.card-content {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
::v-deep .content {
|
||||
& > *:first-child {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
|
||||
* {
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
font-style: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
& > *:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.media-left {
|
||||
margin-right: inherit;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.group-title {
|
||||
line-height: 1.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-weight: bold;
|
||||
}
|
||||
.group-federated-username {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="identity-header">
|
||||
<div class="identity-header" dir="auto">
|
||||
<figure class="image is-24x24" v-if="member.actor.avatar">
|
||||
<img class="is-rounded" :src="member.actor.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
{{ displayNameAndUsername(member.actor) }}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-content" dir="auto">
|
||||
<div>
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
@ -15,7 +16,7 @@
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="media-content" dir="auto">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
@ -24,12 +25,9 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<h3>{{ member.parent.name }}</h3>
|
||||
<p class="is-6 has-text-grey">
|
||||
<span v-if="member.parent.domain">{{
|
||||
`@${member.parent.preferredUsername}@${member.parent.domain}`
|
||||
}}</span>
|
||||
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
|
||||
<h2>{{ member.parent.name }}</h2>
|
||||
<p class="is-6 has-text-grey-dark">
|
||||
<span>{{ `@${usernameWithDomain(member.parent)}` }}</span>
|
||||
<b-taglist>
|
||||
<b-tag
|
||||
type="is-info"
|
||||
@ -47,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" v-if="member.parent.summary">
|
||||
<p>{{ member.parent.summary }}</p>
|
||||
<p v-html="member.parent.summary" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -85,6 +83,7 @@ export default class GroupMemberCard extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.card {
|
||||
.card-content {
|
||||
display: flex;
|
||||
@ -110,8 +109,9 @@ export default class GroupMemberCard extends Vue {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
|
||||
figure {
|
||||
padding-right: 3px;
|
||||
figure,
|
||||
span.icon {
|
||||
@include padding-right(3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,7 @@ export default class GroupSection extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -44,7 +45,7 @@ section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
@include padding-right(0.5rem);
|
||||
}
|
||||
|
||||
.main-slot {
|
||||
@ -68,7 +69,7 @@ div.group-section-title {
|
||||
|
||||
::v-deep & > a {
|
||||
align-self: center;
|
||||
margin-right: 5px;
|
||||
@include margin-right(5px);
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
|
@ -1,57 +1,65 @@
|
||||
<template>
|
||||
<div class="media">
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<i18n
|
||||
tag="p"
|
||||
path="You have been invited by {invitedBy} to the following group:"
|
||||
>
|
||||
<b slot="invitedBy">{{ member.invitedBy.name }}</b>
|
||||
</i18n>
|
||||
</div>
|
||||
<div class="media subfield">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="member.parent.avatar">
|
||||
<img class="is-rounded" :src="member.parent.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
<div class="card">
|
||||
<div class="card-content media">
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<i18n
|
||||
tag="p"
|
||||
path="You have been invited by {invitedBy} to the following group:"
|
||||
>
|
||||
<b slot="invitedBy">{{ member.invitedBy.name }}</b>
|
||||
</i18n>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(member.parent),
|
||||
},
|
||||
}"
|
||||
>
|
||||
<h3>{{ member.parent.name }}</h3>
|
||||
<p class="is-6 has-text-grey">
|
||||
<span v-if="member.parent.domain">
|
||||
{{
|
||||
`@${member.parent.preferredUsername}@${member.parent.domain}`
|
||||
}}
|
||||
</span>
|
||||
<span v-else>{{
|
||||
`@${member.parent.preferredUsername}`
|
||||
}}</span>
|
||||
</p>
|
||||
</router-link>
|
||||
<div class="media subfield">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="member.parent.avatar">
|
||||
<img class="is-rounded" :src="member.parent.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item mr-3">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(member.parent),
|
||||
},
|
||||
}"
|
||||
>
|
||||
<h3 class="is-size-5">{{ member.parent.name }}</h3>
|
||||
<p class="is-size-7 has-text-grey-dark">
|
||||
<span v-if="member.parent.domain">
|
||||
{{
|
||||
`@${member.parent.preferredUsername}@${member.parent.domain}`
|
||||
}}
|
||||
</span>
|
||||
<span v-else>{{
|
||||
`@${member.parent.preferredUsername}`
|
||||
}}</span>
|
||||
</p>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<b-button type="is-success" @click="$emit('accept', member.id)">
|
||||
{{ $t("Accept") }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<b-button type="is-danger" @click="$emit('reject', member.id)">
|
||||
{{ $t("Decline") }}
|
||||
</b-button>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<b-button
|
||||
type="is-success"
|
||||
@click="$emit('accept', member.id)"
|
||||
>
|
||||
{{ $t("Accept") }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<b-button
|
||||
type="is-danger"
|
||||
@click="$emit('reject', member.id)"
|
||||
>
|
||||
{{ $t("Decline") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,4 +90,7 @@ export default class InvitationCard extends Vue {
|
||||
background: lighten($primary, 40%);
|
||||
padding: 10px;
|
||||
}
|
||||
h3 {
|
||||
color: $violet-3;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section v-if="invitations && invitations.length > 0">
|
||||
<section class="card my-3" v-if="invitations && invitations.length > 0">
|
||||
<InvitationCard
|
||||
v-for="member in invitations"
|
||||
:key="member.id"
|
||||
@ -13,8 +13,9 @@
|
||||
import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import InvitationCard from "@/components/Group/InvitationCard.vue";
|
||||
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@ -26,19 +27,26 @@ export default class Invitations extends Vue {
|
||||
|
||||
async acceptInvitation(id: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate<{ acceptInvitation: IMember }>(
|
||||
{
|
||||
mutation: ACCEPT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }],
|
||||
}
|
||||
);
|
||||
if (data) {
|
||||
this.$emit("accept-invitation", data.acceptInvitation);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.$apollo.mutate<{ acceptInvitation: IMember }>({
|
||||
mutation: ACCEPT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries({ data }) {
|
||||
const profile = data?.acceptInvitation?.actor as IPerson;
|
||||
const group = data?.acceptInvitation?.parent as IGroup;
|
||||
if (profile && group) {
|
||||
return [
|
||||
{
|
||||
query: PERSON_STATUS_GROUP,
|
||||
variables: { id: profile.id, group: usernameWithDomain(group) },
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(error.graphQLErrors[0].message);
|
||||
@ -48,19 +56,26 @@ export default class Invitations extends Vue {
|
||||
|
||||
async rejectInvitation(id: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate<{ rejectInvitation: IMember }>(
|
||||
{
|
||||
mutation: REJECT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }],
|
||||
}
|
||||
);
|
||||
if (data) {
|
||||
this.$emit("reject-invitation", data.rejectInvitation);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.$apollo.mutate<{ rejectInvitation: IMember }>({
|
||||
mutation: REJECT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries({ data }) {
|
||||
const profile = data?.rejectInvitation?.actor as IPerson;
|
||||
const group = data?.rejectInvitation?.parent as IGroup;
|
||||
if (profile && group) {
|
||||
return [
|
||||
{
|
||||
query: PERSON_STATUS_GROUP,
|
||||
variables: { id: profile.id, group: usernameWithDomain(group) },
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(error.graphQLErrors[0].message);
|
||||
|
39
js/src/components/Group/MultiGroupCard.vue
Normal file
39
js/src/components/Group/MultiGroupCard.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="multi-card-group">
|
||||
<group-card
|
||||
class="group-card"
|
||||
v-for="group in groups"
|
||||
:group="group"
|
||||
:key="group.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IGroup } from "@/types/actor";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import GroupCard from "./GroupCard.vue";
|
||||
@Component({
|
||||
components: {
|
||||
GroupCard,
|
||||
},
|
||||
})
|
||||
export default class MultiGroupCard extends Vue {
|
||||
@Prop({ type: Array as PropType<IGroup[]>, required: true })
|
||||
groups!: IGroup[];
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.multi-card-group {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
grid-column-gap: 30px;
|
||||
grid-row-gap: 30px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
.group-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -83,11 +83,13 @@
|
||||
/></a>
|
||||
<a
|
||||
:href="telegramShareUrl"
|
||||
class="telegram"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Telegram"
|
||||
><b-icon icon="telegram" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
>
|
||||
<telegram-logo />
|
||||
</a>
|
||||
<a
|
||||
title="Diaspora"
|
||||
:href="diasporaShareUrl"
|
||||
@ -115,12 +117,14 @@ import { Component, Prop, Vue, Ref } from "vue-property-decorator";
|
||||
import { GroupVisibility } from "@/types/enums";
|
||||
import DiasporaLogo from "../Share/DiasporaLogo.vue";
|
||||
import MastodonLogo from "../Share/MastodonLogo.vue";
|
||||
import TelegramLogo from "../Share/MastodonLogo.vue";
|
||||
import { displayName, IGroup } from "@/types/actor";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DiasporaLogo,
|
||||
MastodonLogo,
|
||||
TelegramLogo,
|
||||
},
|
||||
})
|
||||
export default class ShareGroupModal extends Vue {
|
||||
@ -194,7 +198,8 @@ export default class ShareGroupModal extends Vue {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.diaspora,
|
||||
.mastodon {
|
||||
.mastodon,
|
||||
.telegram {
|
||||
::v-deep span svg {
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
@ -16,8 +16,9 @@
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="absolute top-0 left-0 transition-opacity duration-500"
|
||||
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
:class="{ isLoaded: isLoaded ? 'opacity-100' : 'opacity-0', rounded }"
|
||||
alt=""
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,6 +38,7 @@ export default class LazyImage extends Vue {
|
||||
@Prop({ type: String, required: false, default: null }) blurhash!: string;
|
||||
@Prop({ type: Number, default: 1 }) width!: number;
|
||||
@Prop({ type: Number, default: 1 }) height!: number;
|
||||
@Prop({ type: Boolean, default: false }) rounded!: boolean;
|
||||
|
||||
inheritAttrs = false;
|
||||
isLoaded = false;
|
||||
@ -63,12 +65,14 @@ export default class LazyImage extends Vue {
|
||||
onEnter(): void {
|
||||
// Image is visible (means: has entered the viewport),
|
||||
// so start loading by setting the src attribute
|
||||
this.image.src = this.src;
|
||||
if (this.image) {
|
||||
this.image.src = this.src;
|
||||
|
||||
this.image.onload = () => {
|
||||
// Image is loaded, so start fading in
|
||||
this.isLoaded = true;
|
||||
};
|
||||
this.image.onload = () => {
|
||||
// Image is loaded, so start fading in
|
||||
this.isLoaded = true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Watch("src")
|
||||
@ -113,5 +117,8 @@ img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
&.rounded {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -5,6 +5,7 @@
|
||||
:width="pictureOrDefault.metadata.width"
|
||||
:height="pictureOrDefault.metadata.height"
|
||||
:blurhash="pictureOrDefault.metadata.blurhash"
|
||||
:rounded="rounded"
|
||||
/>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
@ -34,6 +35,7 @@ const DEFAULT_PICTURE = {
|
||||
export default class LazyImageWrapper extends Vue {
|
||||
@Prop({ required: false, type: Object as PropType<IMedia | null> })
|
||||
picture!: IMedia | null;
|
||||
@Prop({ required: false, type: Boolean, default: false }) rounded!: boolean;
|
||||
|
||||
get pictureOrDefault(): Partial<IMedia> {
|
||||
if (this.picture === null) {
|
||||
|
@ -19,7 +19,10 @@
|
||||
:zoomInTitle="$t('Zoom in')"
|
||||
:zoomOutTitle="$t('Zoom out')"
|
||||
></l-control-zoom>
|
||||
<v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" />
|
||||
<v-locatecontrol
|
||||
v-if="canDoGeoLocation"
|
||||
:options="{ icon: 'mdi mdi-map-marker' }"
|
||||
/>
|
||||
<l-marker
|
||||
:lat-lng="[lat, lon]"
|
||||
@add="openPopup"
|
||||
@ -152,6 +155,10 @@ export default class Map extends Vue {
|
||||
(this.$t("© The OpenStreetMap Contributors") as string)
|
||||
);
|
||||
}
|
||||
|
||||
get canDoGeoLocation(): boolean {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<b-navbar
|
||||
id="navbar"
|
||||
type="is-secondary"
|
||||
wrapper-class="container"
|
||||
:active.sync="mobileNavbarActive"
|
||||
@ -48,6 +49,7 @@
|
||||
"
|
||||
>
|
||||
<b-button
|
||||
v-if="!hideCreateEventsButton"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.CREATE_EVENT }"
|
||||
type="is-primary"
|
||||
@ -60,7 +62,8 @@
|
||||
tag="a"
|
||||
href="https://mediation.koena.net/framasoft/mobilizon/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener external"
|
||||
hreflang="fr"
|
||||
>
|
||||
<img
|
||||
src="/img/koena-a11y.svg"
|
||||
@ -87,12 +90,12 @@
|
||||
v-if="currentActor.id && currentUser.isLoggedIn"
|
||||
right
|
||||
collapsible
|
||||
ref="user-dropdown"
|
||||
tabindex="0"
|
||||
tag="span"
|
||||
@keyup.enter="toggleMenu"
|
||||
>
|
||||
<template
|
||||
slot="label"
|
||||
v-if="currentActor"
|
||||
class="navbar-dropdown-profile"
|
||||
>
|
||||
<template slot="label" v-if="currentActor">
|
||||
<div class="identity-wrapper">
|
||||
<div>
|
||||
<figure class="image is-32x32" v-if="currentActor.avatar">
|
||||
@ -121,8 +124,11 @@
|
||||
v-else
|
||||
:active="identity.id === currentActor.id"
|
||||
:key="identity.id"
|
||||
tabindex="0"
|
||||
@click="setIdentity(identity)"
|
||||
@keyup.enter="setIdentity(identity)"
|
||||
>
|
||||
<span @click="setIdentity(identity)">
|
||||
<span>
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="identity.avatar">
|
||||
<img
|
||||
@ -143,7 +149,7 @@
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<hr class="navbar-divider" />
|
||||
<hr class="navbar-divider" role="presentation" />
|
||||
</b-navbar-item>
|
||||
|
||||
<b-navbar-item
|
||||
@ -158,8 +164,13 @@
|
||||
>{{ $t("Administration") }}
|
||||
</b-navbar-item>
|
||||
|
||||
<b-navbar-item tag="span">
|
||||
<span @click="logout">{{ $t("Log out") }}</span>
|
||||
<b-navbar-item
|
||||
tag="span"
|
||||
tabindex="0"
|
||||
@click="logout"
|
||||
@keyup.enter="logout"
|
||||
>
|
||||
<span>{{ $t("Log out") }}</span>
|
||||
</b-navbar-item>
|
||||
</b-navbar-dropdown>
|
||||
|
||||
@ -185,7 +196,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from "vue-property-decorator";
|
||||
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
|
||||
import Logo from "@/components/Logo.vue";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { loadLanguageAsync } from "@/utils/i18n";
|
||||
@ -206,12 +217,8 @@ import RouteName from "../router/name";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentUser: {
|
||||
query: CURRENT_USER_CLIENT,
|
||||
},
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
currentUser: CURRENT_USER_CLIENT,
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
identities: {
|
||||
query: IDENTITIES,
|
||||
update: ({ identities }) =>
|
||||
@ -257,6 +264,13 @@ export default class NavBar extends Vue {
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
@Ref("user-dropdown") userDropDown!: any;
|
||||
|
||||
toggleMenu(): void {
|
||||
console.debug("called toggleMenu");
|
||||
this.userDropDown.showMenu();
|
||||
}
|
||||
|
||||
@Watch("currentActor")
|
||||
async initializeListOfIdentities(): Promise<void> {
|
||||
if (!this.currentUser.isLoggedIn) return;
|
||||
@ -326,9 +340,14 @@ export default class NavBar extends Vue {
|
||||
});
|
||||
return changeIdentity(this.$apollo.provider.defaultClient, identity);
|
||||
}
|
||||
|
||||
get hideCreateEventsButton(): boolean {
|
||||
return !!this.config?.restrictions?.onlyGroupsCanCreateEvents;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
nav {
|
||||
.navbar-item {
|
||||
a.button {
|
||||
@ -361,7 +380,7 @@ nav {
|
||||
}
|
||||
|
||||
.navbar-item.has-dropdown a.navbar-link figure {
|
||||
margin-right: 0.75rem;
|
||||
@include margin-right(0.75rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -71,7 +71,13 @@ import { IParticipant } from "../../types/participant.model";
|
||||
import RouteName from "../../router/name";
|
||||
import { CONFIRM_PARTICIPATION } from "../../graphql/event";
|
||||
|
||||
@Component
|
||||
@Component({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t("Confirm participation") as string,
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class ConfirmParticipation extends Vue {
|
||||
@Prop({ type: String, required: true }) token!: string;
|
||||
|
||||
|
@ -65,6 +65,7 @@
|
||||
<b-modal
|
||||
:active.sync="isAnonymousParticipationModalOpen"
|
||||
has-modal-card
|
||||
:close-button-aria-label="$t('Close')"
|
||||
ref="anonymous-participation-modal"
|
||||
>
|
||||
<div class="modal-card">
|
||||
|
@ -28,6 +28,12 @@ import { IEvent } from "@/types/event.model";
|
||||
},
|
||||
},
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t("Participation with account") as string,
|
||||
meta: [{ name: "robots", content: "noindex" }],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class ParticipationWithAccount extends Vue {
|
||||
@Prop({ type: String, required: true }) uuid!: string;
|
||||
|
@ -155,6 +155,12 @@ import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
},
|
||||
config: CONFIG,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t("Participation without account") as string,
|
||||
meta: [{ name: "robots", content: "noindex" }],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class ParticipationWithoutAccount extends Vue {
|
||||
@Prop({ type: String, required: true }) uuid!: string;
|
||||
@ -195,6 +201,7 @@ export default class ParticipationWithoutAccount extends Vue {
|
||||
email: this.anonymousParticipation.email,
|
||||
message: this.anonymousParticipation.message,
|
||||
locale: this.$i18n.locale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
update: (
|
||||
store: ApolloCache<{ joinEvent: IParticipant }>,
|
||||
@ -249,7 +256,7 @@ export default class ParticipationWithoutAccount extends Vue {
|
||||
data.joinEvent.metadata.cancellationToken
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (
|
||||
["TextEncoder is not defined", "crypto.subtle is undefined"].includes(
|
||||
e.message
|
||||
|
@ -130,6 +130,12 @@ import RouteName from "../../router/name";
|
||||
},
|
||||
config: CONFIG,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t("Unlogged participation") as string,
|
||||
meta: [{ name: "robots", content: "noindex" }],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class UnloggedParticipation extends Vue {
|
||||
@Prop({ type: String, required: true }) uuid!: string;
|
||||
|
@ -38,7 +38,12 @@
|
||||
</span>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
<b-button type="is-text" v-if="imageSrc" @click="removeOrClearPicture">
|
||||
<b-button
|
||||
type="is-text"
|
||||
v-if="imageSrc"
|
||||
@click="removeOrClearPicture"
|
||||
@keyup.enter="removeOrClearPicture"
|
||||
>
|
||||
{{ $t("Clear") }}
|
||||
</b-button>
|
||||
</div>
|
||||
@ -46,13 +51,14 @@
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/_mixins" as *;
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
figure.image {
|
||||
margin-right: 30px;
|
||||
@include margin-right(30px);
|
||||
max-height: 200px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
@ -94,7 +100,7 @@ figure.image {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
margin-right: 5px;
|
||||
@include margin-right(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
34
js/src/components/Post/MultiPostListItem.vue
Normal file
34
js/src/components/Post/MultiPostListItem.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="posts-wrapper">
|
||||
<post-list-item
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IPost } from "@/types/post.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import PostListItem from "./PostListItem.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
PostListItem,
|
||||
},
|
||||
})
|
||||
export default class MultiPostListItem extends Vue {
|
||||
@Prop({ type: Array as PropType<IPost[]>, required: true }) posts!: IPost[];
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
isCurrentActorMember!: boolean;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.posts-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template: 1fr;
|
||||
}
|
||||
</style>
|
@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="post-minimalist-card-wrapper"
|
||||
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
|
||||
>
|
||||
<div class="title-info-wrapper">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-96x96" v-if="post.picture">
|
||||
<img :src="post.picture.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="post" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="post-minimalist-title">{{ post.title }}</p>
|
||||
<div class="metadata">
|
||||
<b-tag type="is-warning" size="is-small" v-if="post.draft">{{
|
||||
$t("Draft")
|
||||
}}</b-tag>
|
||||
<small
|
||||
v-if="
|
||||
post.visibility === PostVisibility.PUBLIC &&
|
||||
isCurrentActorMember
|
||||
"
|
||||
class="has-text-grey"
|
||||
>
|
||||
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<b-icon icon="lock" size="is-small" />{{
|
||||
$t("Accessible only to members", {
|
||||
group: post.attributedTo.name,
|
||||
})
|
||||
}}</small
|
||||
>
|
||||
<small class="has-text-grey">{{
|
||||
$options.filters.formatDateTimeString(
|
||||
new Date(post.insertedAt),
|
||||
false
|
||||
)
|
||||
}}</small>
|
||||
<small class="has-text-grey" v-if="isCurrentActorMember">{{
|
||||
$t("Created by {username}", {
|
||||
username: `@${usernameWithDomain(post.author)}`,
|
||||
})
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { PostVisibility } from "@/types/enums";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import RouteName from "../../router/name";
|
||||
import { IPost } from "../../types/post.model";
|
||||
|
||||
@Component
|
||||
export default class PostElementItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) post!: IPost;
|
||||
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
isCurrentActorMember!: boolean;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.post-minimalist-card-wrapper {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: initial;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
align-items: center;
|
||||
|
||||
.title-info-wrapper {
|
||||
flex: 2;
|
||||
|
||||
.post-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
|
||||
serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.media .media-left {
|
||||
& > span.icon {
|
||||
height: 96px;
|
||||
width: 96px;
|
||||
}
|
||||
& > figure.image > img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
object-position: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
& > span.tag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
& > small:not(:last-child):after {
|
||||
content: "·";
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,55 +1,122 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="post-minimalist-card-wrapper"
|
||||
dir="auto"
|
||||
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
|
||||
>
|
||||
<div class="title-info-wrapper">
|
||||
<p class="post-minimalist-title">{{ post.title }}</p>
|
||||
<small class="has-text-grey-dark">{{
|
||||
formatDistanceToNow(new Date(post.publishAt || post.insertedAt), {
|
||||
locale: $dateFnsLocale,
|
||||
addSuffix: true,
|
||||
})
|
||||
}}</small>
|
||||
<lazy-image-wrapper
|
||||
:picture="post.picture"
|
||||
:rounded="true"
|
||||
style="height: 120px"
|
||||
/>
|
||||
<div class="title-info-wrapper has-text-grey-dark">
|
||||
<h3 class="post-minimalist-title" :lang="post.language">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
<p class="post-publication-date">
|
||||
<b-icon icon="clock" />
|
||||
<span dir="auto" class="has-text-grey-dark" v-if="isBeforeLastWeek">{{
|
||||
publishedAt | formatDateTimeString(undefined, false, "short")
|
||||
}}</span>
|
||||
<span v-else>{{
|
||||
formatDistanceToNow(publishedAt, {
|
||||
locale: $dateFnsLocale,
|
||||
addSuffix: true,
|
||||
})
|
||||
}}</span>
|
||||
</p>
|
||||
<b-taglist v-if="post.tags.length > 0" style="display: inline">
|
||||
<b-icon icon="tag" />
|
||||
<b-tag v-for="tag in post.tags" :key="tag.slug">{{ tag.title }}</b-tag>
|
||||
</b-taglist>
|
||||
<p class="post-publisher has-text-grey-dark" v-if="isCurrentActorMember">
|
||||
<b-icon icon="account-edit" />
|
||||
<i18n path="Published by {name}">
|
||||
<b class="has-text-weight-medium" slot="name">{{
|
||||
displayName(post.author)
|
||||
}}</b>
|
||||
</i18n>
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow, subWeeks, isBefore } from "date-fns";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import RouteName from "../../router/name";
|
||||
import { IPost } from "../../types/post.model";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import { displayName } from "@/types/actor";
|
||||
|
||||
@Component
|
||||
@Component({
|
||||
components: {
|
||||
LazyImageWrapper,
|
||||
},
|
||||
})
|
||||
export default class PostListItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) post!: IPost;
|
||||
@Prop({ required: false, type: Boolean, default: false })
|
||||
isCurrentActorMember!: boolean;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
formatDistanceToNow = formatDistanceToNow;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
get publishedAt(): Date {
|
||||
return new Date((this.post.publishAt || this.post.insertedAt) as Date);
|
||||
}
|
||||
|
||||
get isBeforeLastWeek(): boolean {
|
||||
return isBefore(this.publishedAt, subWeeks(new Date(), 1));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@import "~bulma/sass/utilities/mixins.sass";
|
||||
|
||||
.post-minimalist-card-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 5px 10px;
|
||||
grid-template-areas: "preview" "body";
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: initial;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
align-items: center;
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 200px 3fr;
|
||||
grid-template-areas: "preview body";
|
||||
}
|
||||
|
||||
.title-info-wrapper {
|
||||
flex: 2;
|
||||
|
||||
.post-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: Roboto, Helvetica, Arial, serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
::v-deep .icon {
|
||||
vertical-align: middle;
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
::v-deep .tags {
|
||||
display: inline;
|
||||
|
||||
span.tag {
|
||||
max-width: 200px;
|
||||
|
||||
& > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
219
js/src/components/Post/SharePostModal.vue
Normal file
219
js/src/components/Post/SharePostModal.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ $t("Share this post") }}</p>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body is-flex" v-if="post">
|
||||
<div class="container has-text-centered">
|
||||
<b-notification
|
||||
type="is-warning"
|
||||
v-if="post.visibility !== PostVisibility.PUBLIC"
|
||||
:closable="false"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"This post is accessible only through it's link. Be careful where you post this link."
|
||||
)
|
||||
}}
|
||||
</b-notification>
|
||||
<b-field :label="$t('Post URL')" label-for="post-url-text">
|
||||
<b-input
|
||||
id="post-url-text"
|
||||
ref="postURLInput"
|
||||
:value="postURL"
|
||||
expanded
|
||||
/>
|
||||
<p class="control">
|
||||
<b-tooltip
|
||||
:label="$t('URL copied to clipboard')"
|
||||
:active="showCopiedTooltip"
|
||||
always
|
||||
type="is-success"
|
||||
position="is-left"
|
||||
>
|
||||
<b-button
|
||||
type="is-primary"
|
||||
icon-right="content-paste"
|
||||
native-type="button"
|
||||
@click="copyURL"
|
||||
@keyup.enter="copyURL"
|
||||
:title="$t('Copy URL to clipboard')"
|
||||
/>
|
||||
</b-tooltip>
|
||||
</p>
|
||||
</b-field>
|
||||
<div>
|
||||
<a
|
||||
:href="twitterShareUrl"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Twitter"
|
||||
><b-icon icon="twitter" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
<a
|
||||
:href="mastodonShareUrl"
|
||||
class="mastodon"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Mastodon"
|
||||
>
|
||||
<mastodon-logo />
|
||||
</a>
|
||||
<a
|
||||
:href="facebookShareUrl"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Facebook"
|
||||
><b-icon icon="facebook" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
<a
|
||||
:href="whatsAppShareUrl"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="WhatsApp"
|
||||
><b-icon icon="whatsapp" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
<a
|
||||
:href="telegramShareUrl"
|
||||
class="telegram"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Telegram"
|
||||
>
|
||||
<telegram-logo />
|
||||
</a>
|
||||
<a
|
||||
:href="linkedInShareUrl"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="LinkedIn"
|
||||
><b-icon icon="linkedin" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
<a
|
||||
:href="diasporaShareUrl"
|
||||
class="diaspora"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Diaspora"
|
||||
>
|
||||
<diaspora-logo />
|
||||
</a>
|
||||
<a
|
||||
:href="emailShareUrl"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
title="Email"
|
||||
><b-icon icon="email" size="is-large" type="is-primary"
|
||||
/></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
|
||||
import { PostVisibility } from "@/types/enums";
|
||||
import { IPost } from "../../types/post.model";
|
||||
import DiasporaLogo from "../Share/DiasporaLogo.vue";
|
||||
import MastodonLogo from "../Share/MastodonLogo.vue";
|
||||
import TelegramLogo from "../Share/TelegramLogo.vue";
|
||||
import { PropType } from "vue";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DiasporaLogo,
|
||||
MastodonLogo,
|
||||
TelegramLogo,
|
||||
},
|
||||
})
|
||||
export default class SharePostModal extends Vue {
|
||||
@Prop({ type: Object as PropType<IPost>, required: true }) post!: IPost;
|
||||
|
||||
@Ref("postURLInput") readonly postURLInput!: any;
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
showCopiedTooltip = false;
|
||||
|
||||
get twitterShareUrl(): string {
|
||||
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
|
||||
this.postURL
|
||||
)}&text=${this.post.title}`;
|
||||
}
|
||||
|
||||
get facebookShareUrl(): string {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
|
||||
this.postURL
|
||||
)}`;
|
||||
}
|
||||
|
||||
get linkedInShareUrl(): string {
|
||||
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
|
||||
this.postURL
|
||||
)}&title=${this.post.title}`;
|
||||
}
|
||||
|
||||
get whatsAppShareUrl(): string {
|
||||
return `https://wa.me/?text=${encodeURIComponent(this.basicTextToEncode)}`;
|
||||
}
|
||||
|
||||
get telegramShareUrl(): string {
|
||||
return `https://t.me/share/url?url=${encodeURIComponent(
|
||||
this.postURL
|
||||
)}&text=${encodeURIComponent(this.post.title)}`;
|
||||
}
|
||||
|
||||
get emailShareUrl(): string {
|
||||
return `mailto:?to=&body=${this.postURL}&subject=${this.post.title}`;
|
||||
}
|
||||
|
||||
get diasporaShareUrl(): string {
|
||||
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(
|
||||
this.post.title
|
||||
)}&url=${encodeURIComponent(this.postURL)}`;
|
||||
}
|
||||
|
||||
get mastodonShareUrl(): string {
|
||||
return `https://toot.karamoff.dev/?text=${encodeURIComponent(
|
||||
this.basicTextToEncode
|
||||
)}`;
|
||||
}
|
||||
|
||||
get basicTextToEncode(): string {
|
||||
return `${this.post.title}\r\n${this.postURL}`;
|
||||
}
|
||||
|
||||
get postURL(): string {
|
||||
if (this.post.id) {
|
||||
return this.$router.resolve({
|
||||
name: RouteName.POST,
|
||||
params: { id: this.post.id },
|
||||
}).href;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
copyURL(): void {
|
||||
this.postURLInput.$refs.input.select();
|
||||
document.execCommand("copy");
|
||||
this.showCopiedTooltip = true;
|
||||
setTimeout(() => {
|
||||
this.showCopiedTooltip = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.diaspora,
|
||||
.mastodon,
|
||||
.telegram {
|
||||
::v-deep span svg {
|
||||
width: 2.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head" v-if="title">
|
||||
<p class="modal-card-title">{{ title }}</p>
|
||||
<h2 class="modal-card-title">{{ title }}</h2>
|
||||
</header>
|
||||
|
||||
<section
|
||||
@ -17,7 +17,7 @@
|
||||
<article class="media">
|
||||
<div class="media-left">
|
||||
<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="" />
|
||||
</figure>
|
||||
<b-icon
|
||||
class="media-left"
|
||||
@ -45,12 +45,16 @@
|
||||
</p>
|
||||
|
||||
<div class="control">
|
||||
<b-input
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
@keyup.enter="confirm"
|
||||
:placeholder="$t('Additional comments')"
|
||||
/>
|
||||
<b-field
|
||||
:label="$t('Additional comments')"
|
||||
label-for="additonal-comments"
|
||||
>
|
||||
<b-input
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
id="additonal-comments"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
|
||||
<div class="control" v-if="outsideDomain">
|
||||
@ -73,7 +77,12 @@
|
||||
<button class="button" ref="cancelButton" @click="close">
|
||||
{{ translatedCancelText }}
|
||||
</button>
|
||||
<button class="button is-primary" ref="confirmButton" @click="confirm">
|
||||
<button
|
||||
class="button is-primary"
|
||||
ref="confirmButton"
|
||||
@click="confirm"
|
||||
@keyup.enter="confirm"
|
||||
>
|
||||
{{ translatedConfirmText }}
|
||||
</button>
|
||||
</footer>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="resource-wrapper">
|
||||
<div class="resource-wrapper" dir="auto">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.RESOURCE_FOLDER,
|
||||
@ -61,7 +61,7 @@ export default class FolderItem extends Mixins(ResourceMixin) {
|
||||
list = [];
|
||||
|
||||
groupObject: Record<string, unknown> = {
|
||||
name: `folder-${this.resource.title}`,
|
||||
name: `folder-${this.resource?.title}`,
|
||||
pull: false,
|
||||
put: ["resources"],
|
||||
};
|
||||
@ -110,7 +110,7 @@ export default class FolderItem extends Mixins(ResourceMixin) {
|
||||
return undefined;
|
||||
}
|
||||
return data.updateResource;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="resource-wrapper">
|
||||
<div class="resource-wrapper" dir="auto">
|
||||
<a :href="resource.resourceUrl" target="_blank">
|
||||
<div class="preview">
|
||||
<div
|
||||
@ -82,6 +82,7 @@ export default class ResourceItem extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.resource-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@ -137,7 +138,7 @@ a {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
@include margin-right(6px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
@ -59,7 +59,7 @@
|
||||
{{ $t("No resources in this folder") }}
|
||||
</p>
|
||||
<b-pagination
|
||||
v-if="resource.children.total > RESOURCES_PER_PAGE"
|
||||
v-if="resource.children && resource.children.total > RESOURCES_PER_PAGE"
|
||||
:total="resource.children.total"
|
||||
v-model="page"
|
||||
size="is-small"
|
||||
|
@ -7,6 +7,7 @@
|
||||
icon="magnify"
|
||||
type="search"
|
||||
rounded
|
||||
dir="auto"
|
||||
:placeholder="defaultPlaceHolder"
|
||||
v-model="search"
|
||||
@keyup.native.enter="enter"
|
||||
|
@ -60,7 +60,7 @@ export default class NotificationsOnboarding extends mixins(Onboarding) {
|
||||
async updateSetting(variables: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
this.doUpdateSetting(variables);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
|
16
js/src/components/Share/TelegramLogo.vue
Normal file
16
js/src/components/Share/TelegramLogo.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<span class="icon has-text-primary is-large">
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Telegram</title>
|
||||
<path
|
||||
d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class TelegramLogo extends Vue {}
|
||||
</script>
|
@ -55,7 +55,7 @@ export default class Todo extends Vue {
|
||||
},
|
||||
});
|
||||
this.editMode = false;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
@ -66,7 +66,8 @@ export default class Todo extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
span.details {
|
||||
margin-left: 1rem;
|
||||
@include margin-left(1rem);
|
||||
}
|
||||
</style>
|
||||
|
@ -91,7 +91,7 @@ export default class Todo extends Vue {
|
||||
},
|
||||
});
|
||||
this.editMode = false;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
|
@ -32,6 +32,7 @@ export default class EmptyContent extends Vue {
|
||||
}
|
||||
&.inline {
|
||||
margin-top: 5vh;
|
||||
margin-bottom: 2vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
15
js/src/components/Utils/HomepageRedirectComponent.vue
Normal file
15
js/src/components/Utils/HomepageRedirectComponent.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div>a</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
@Component
|
||||
export default class HomepageRedirectComponent extends Vue {
|
||||
created(): void {
|
||||
this.$router.replace({ name: RouteName.HOME });
|
||||
}
|
||||
}
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user