Merge branch 'upstream_1.0.3' into chapril

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
js/.gitignore vendored
View File

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

View File

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

View File

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

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

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 725 KiB

After

Width:  |  Height:  |  Size: 725 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

3291
js/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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