Merge branch 'group' into 'master'

Introduce group basic federation, event new page and notifications

See merge request framasoft/mobilizon!433
This commit is contained in:
Thomas Citharel 2020-06-03 19:12:25 +02:00
commit c034175f43
420 changed files with 32281 additions and 16807 deletions

View File

@ -38,7 +38,7 @@ lint:
- mix format --check-formatted --dry-run || export EXITVALUE=1
- cd js
- yarn install
- yarn run lint || export EXITVALUE=1
#- yarn run lint || export EXITVALUE=1
- yarn run build
- cd ../
- exit $EXITVALUE
@ -77,7 +77,7 @@ exunit:
script:
- mix coveralls
#cypress:
# cypress:
# stage: test
# services:
# - name: mdillon/postgis:11
@ -102,7 +102,6 @@ exunit:
# - js/tests/e2e/screenshots/**/*.png
# - js/tests/e2e/videos/**/*.mp4
pages:
stage: deploy
script:

View File

@ -46,7 +46,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
],
secret_key_base: "1yOazsoE0Wqu4kXk3uC5gu3jDbShOimTCzyFL3OjCdBmOXMyHX87Qmf3+Tu9s0iM",
render_errors: [view: Mobilizon.Web.ErrorView, accepts: ~w(html json)],
pubsub: [name: Mobilizon.PubSub, adapter: Phoenix.PubSub.PG2],
pubsub_server: Mobilizon.PubSub,
cache_static_manifest: "priv/static/manifest.json"
# Upload configuration
@ -115,6 +115,8 @@ config :guardian, Guardian.DB,
# default: 60 minutes
sweep_interval: 60
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
config :geolix,
databases: [
%{
@ -204,7 +206,26 @@ config :mobilizon, :anonymous,
config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000},
queues: [default: 10, search: 20, background: 5]
queues: [default: 10, search: 5, mailers: 10, background: 5]
config :mobilizon, :rich_media,
parsers: [
Mobilizon.Service.RichMedia.Parsers.OEmbed,
Mobilizon.Service.RichMedia.Parsers.OGP,
Mobilizon.Service.RichMedia.Parsers.TwitterCard,
Mobilizon.Service.RichMedia.Parsers.Fallback
]
config :mobilizon, Mobilizon.Service.ResourceProviders,
types: [],
providers: %{}
config :mobilizon, :external_resource_providers, %{
"https://drive.google.com/" => :google_drive,
"https://docs.google.com/document/" => :google_docs,
"https://docs.google.com/presentation/" => :google_presentation,
"https://docs.google.com/spreadsheets/" => :google_spreadsheets
}
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.

View File

@ -19,7 +19,16 @@ config :mobilizon, Mobilizon.Web.Endpoint,
code_reloader: true,
check_origin: false,
watchers: [
yarn: ["run", "dev", cd: Path.expand("../js", __DIR__)]
# yarn: ["run", "dev", cd: Path.expand("../js", __DIR__)]
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch-stdin",
"--config",
"node_modules/@vue/cli-service/webpack.config.js",
cd: Path.expand("../js", __DIR__)
]
]
# ## SSL Support
@ -80,7 +89,7 @@ config :mobilizon, :instance,
email_reply_to: System.get_env("MOBILIZON_INSTANCE_EMAIL"),
registrations_open: System.get_env("MOBILIZON_INSTANCE_REGISTRATIONS_OPEN") == "true"
config :mobilizon, :activitypub, sign_object_fetches: false
# config :mobilizon, :activitypub, sign_object_fetches: false
require Logger

View File

@ -14,11 +14,24 @@ config :mobilizon, Mobilizon.Web.Endpoint,
debug_errors: true,
code_reloader: false,
check_origin: false,
# Somehow this can't be merged properly with the dev config some we got this…
# Somehow this can't be merged properly with the dev config so we got this…
watchers: [
yarn: [cd: Path.expand("../js", __DIR__)]
]
config :mobilizon, sql_sandbox: true
require Logger
config :mobilizon, Mobilizon.Storage.Repo, pool: Ecto.Adapters.SQL.Sandbox
cond do
System.get_env("INSTANCE_CONFIG") &&
File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") ->
import_config System.get_env("INSTANCE_CONFIG")
System.get_env("DOCKER", "false") == "false" && File.exists?("./config/e2e.secret.exs") ->
import_config "e2e.secret.exs"
System.get_env("DOCKER", "false") == "true" ->
Logger.info("Using environment configuration for Docker")
true ->
Logger.error("No configuration file found")
end

View File

@ -46,10 +46,12 @@ config :exvcr,
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock
config :mobilizon, Oban, queues: false, prune: :disabled
config :mobilizon, Oban, queues: false, prune: :disabled, crontab: false
config :mobilizon, Mobilizon.Web.Auth.Guardian, secret_key: "some secret"
config :mobilizon, :activitypub, sign_object_fetches: false
if System.get_env("DOCKER", "false") == "false" && File.exists?("./config/test.secret.exs") do
import_config "test.secret.exs"
end

View File

@ -1,11 +1,11 @@
FROM elixir:latest
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
ENV REFRESHED_AT=2019-12-17
ENV REFRESHED_AT=2020-06-03
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 python3-pip
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash && apt-get install nodejs -yq
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash && apt-get install nodejs -yq
RUN npm install -g yarn wait-on
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN mix local.hex --force && mix local.rebar --force
RUN pip3 install mkdocs mkdocs-material pymdown-extensions pygments mkdocs-git-revision-date-localized-plugin
RUN curl http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz --output GeoLite2-City.tar.gz -s && tar zxf GeoLite2-City.tar.gz && mkdir -p /usr/share/GeoIP && mv GeoLite2-City_*/GeoLite2-City.mmdb /usr/share/GeoIP/GeoLite2-City.mmdb
RUN pip3 install mkdocs mkdocs-material pymdown-extensions pygments mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin
RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/

View File

@ -2,47 +2,53 @@
Clone the repository:
```bash tab="HTTPS"
git clone https://framagit.org/framasoft/mobilizon && cd mobilizon
```
=== "HTTPS"
``` bash
git clone https://framagit.org/framasoft/mobilizon && cd mobilizon
```
```bash tab="SSH"
git clone git@framagit.org:framasoft/mobilizon.git && cd mobilizon
```
=== "SSH"
``` bash
git clone git@framagit.org:framasoft/mobilizon.git && cd mobilizon
```
Run Mobilizon:
* with Docker and Docker-Compose (**Recommended**)
* without Docker and Docker-Compose (This involves more work on your part, use Docker and Docker-Compose if you can)
+ with Docker and Docker-Compose (**Recommended**)
+ without Docker and Docker-Compose (This involves more work on your part, use Docker and Docker-Compose if you can)
## With Docker
* Install [Docker](https://docs.docker.com/install/#supported-platforms) and [Docker-Compose](https://docs.docker.com/compose/install/) for your system.
* Run `make start` to build, then launch a database container and an API container.
* Follow the progress of the build with `docker-compose logs -f`.
* Access `localhost:4000` in your browser once the containers are fully built and launched.
+ Install [Docker](https://docs.docker.com/install/#supported-platforms) and [Docker-Compose](https://docs.docker.com/compose/install/) for your system.
+ Run `make start` to build, then launch a database container and an API container.
+ Follow the progress of the build with `docker-compose logs -f` .
+ Access `localhost:4000` in your browser once the containers are fully built and launched.
## Without Docker
* Install dependencies:
* [Elixir (and Erlang)](https://elixir-lang.org/install.html)
* PostgreSQL >= 9.6 with PostGIS
* [Install NodeJS](https://nodejs.org/en/download/) (we guarantee support for the latest LTS and later) ![](https://img.shields.io/badge/node-%3E%3D%2012.0+-brightgreen.svg)
* Start services:
* Start postgres
* Setup services:
* Make sure the postgis extension is installed on your system.
* Create a postgres user with database creation capabilities, using the
following: `createuser -d -P mobilizon` and set `mobilizon` as the password.
* Install packages
* Fetch backend Elixir dependencies with `mix deps.get`.
* Go into the `cd js` directory, `yarn install` and then back `cd ../`
* Setup
* Create your database with `mix ecto.create`.
* Create the postgis extension on the database with a postgres user that has
superuser capabilities: `psql mobilizon_dev`
+ Install dependencies:
- [Elixir (and Erlang)](https://elixir-lang.org/install.html)
- PostgreSQL >= 9.6 with PostGIS
- [Install NodeJS](https://nodejs.org/en/download/) (we guarantee support for the latest LTS and later) ![](https://img.shields.io/badge/node-%3E%3D%2012.0+-brightgreen.svg)
+ Start services:
- Start postgres
+ Setup services:
- Make sure the postgis extension is installed on your system.
- Create a postgres user with database creation capabilities, using the
``` create extension if not exists postgis; ```
following: `createuser -d -P mobilizon` and set `mobilizon` as the password.
+ Install packages
- Fetch backend Elixir dependencies with `mix deps.get` .
- Go into the `cd js` directory, `yarn install` and then back `cd ../`
+ Setup
- Create your database with `mix ecto.create` .
- Create the postgis extension on the database with a postgres user that has
superuser capabilities: `psql mobilizon_dev`
```psql
create extension if not exists postgis;
```
* Run migrations: `mix ecto.migrate`.
* Generate a Guardian secret with `mix guardian.gen.secret`:
@ -59,6 +65,7 @@ Run Mobilizon:
secret_key: "TTRcgYH/Y0rk8ph5fqExVWRWjK03cqymfTa70leljmLMsBChtm+6MM+pRrL76Io3"
```
* Generate your first user with the `mix mobilizon.users.new` task
```bash
@ -69,9 +76,9 @@ Run Mobilizon:
- Role: user
The user will be prompted to create a new profile after login for the first time.
```
* Start Phoenix endpoint with `mix phx.server`. The client development server will also automatically be launched and will reload on file change.
Now you can visit [`localhost:4000`](http://localhost:4000) in your browser
* Start Phoenix endpoint with `mix phx.server`. The client development server will also automatically be launched and will reload on file change.
Now you can visit [ `localhost:4000` ](http://localhost:4000) in your browser
and see the website (server *and* client) in action.
## FAQ

3
js/.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

7
js/.editorconfig Normal file
View File

@ -0,0 +1,7 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

59
js/.eslintrc.js Normal file
View File

@ -0,0 +1,59 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
"plugin:vue/essential",
"@vue/airbnb",
"@vue/typescript/recommended",
"plugin:cypress/recommended",
"plugin:prettier/recommended",
"prettier",
"eslint:recommended",
"@vue/prettier",
"@vue/prettier/@typescript-eslint",
],
plugins: ["prettier"],
parserOptions: {
ecmaVersion: 2020,
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-underscore-dangle": [
"error",
{
allow: ["__typename"],
},
],
"@typescript-eslint/no-explicit-any": "off",
"cypress/no-unnecessary-waiting": "off",
"vue/max-len": [
"error",
{
ignoreStrings: true,
template: 170,
code: 100,
},
],
"prettier/prettier": "error",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-use-before-define": "off",
"import/prefer-default-export": "off",
},
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,
},
}],
};

11
js/.gitignore vendored
View File

@ -2,12 +2,12 @@
node_modules
/dist
/tests/e2e/reports/
/tests/e2e/screenshots/
/tests/e2e/videos/
selenium-debug.log
/tests/e2e/screenshots/
styleguide/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
@ -21,5 +21,4 @@ yarn-error.log*
*.ntvs*
*.njsproj
*.sln
*.sw*
*.sw?

View File

@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

41
js/README.md Normal file
View File

@ -0,0 +1,41 @@
# mobilizon
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Run your unit tests
```
yarn test:unit
```
### Run your end-to-end tests
```
yarn test:e2e
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

12
js/apollo.config.js Normal file
View File

@ -0,0 +1,12 @@
// apollo.config.js
module.exports = {
client: {
service: {
name: "Mobilizon",
// URL to the GraphQL API
url: "http://localhost:4000/api",
},
// Files processed by the extension
includes: ["src/**/*.vue", "src/**/*.js"],
},
};

View File

@ -1,7 +1,3 @@
module.exports = {
presets: [
[
"@vue/app", {useBuiltIns: "entry"}
]
]
presets: ["@vue/cli-plugin-babel/preset"],
};

View File

@ -1,19 +0,0 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import Buefy from 'buefy';
import 'bulma/css/bulma.min.css';
import 'buefy/dist/buefy.min.css';
import filters from '@/filters';
Vue.use(VueI18n);
Vue.use(Buefy);
Vue.use(filters);
Vue.component('router-link', {
props: {
tag: { type: String, default: 'a' }
},
render(createElement) {
return createElement(this.tag, {}, this.$slots.default)
}
});

View File

@ -1,18 +0,0 @@
import VueI18n from 'vue-i18n'
import messages from '@/i18n/index';
const language = (window.navigator).userLanguage || window.navigator.language;
const i18n = new VueI18n({
locale: language.replace('-', '_'), // set locale
messages, // set locale messages
});
export default previewComponent => {
// https://vuejs.org/v2/guide/render-function.html
return {
i18n,
render(createElement) {
return createElement(previewComponent)
}
}
}

View File

View File

@ -1 +0,0 @@
No directives right now.

View File

@ -1,3 +0,0 @@
# Introduction
This page presents the various Vue.js components used in the front-end for Mobilizon.

View File

@ -1,21 +1,39 @@
{"__schema":
{"types":[
{
"__schema": {
"types": [
{
"possibleTypes":[
{"name":"Person"},
{"name":"Group"}
],
"name":"Actor",
"kind":"INTERFACE"
"kind": "INTERFACE",
"name": "ActionLogObject",
"possibleTypes": [
{
"name": "Event"
},
{
"possibleTypes":[
{"name":"Event"},
{"name":"Person"},
{"name":"Group"}
],
"name":"SearchResult",
"kind":"UNION"}
"name": "Comment"
},
{
"name": "Report"
},
{
"name": "ReportNote"
}
]
},
{
"kind": "INTERFACE",
"name": "Actor",
"possibleTypes": [
{
"name": "Person"
},
{
"name": "Group"
},
{
"name": "Application"
}
]
}
]
}
}

View File

@ -1,9 +1,9 @@
const fetch = require('node-fetch');
const fs = require('fs');
const fetch = require("node-fetch");
const fs = require("fs");
fetch(`http://localhost:4001`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
fetch(`http://localhost:4000/api`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
variables: {},
query: `
@ -21,18 +21,16 @@ fetch(`http://localhost:4001`, {
`,
}),
})
.then(result => result.json())
.then(result => {
.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 => {
fs.writeFile("./fragmentTypes.json", JSON.stringify(result.data), (err) => {
if (err) {
console.error('Error writing fragmentTypes file', err);
console.error("Error writing fragmentTypes file", err);
} else {
console.log('Fragment types successfully extracted!');
console.log("Fragment types successfully extracted!");
}
});
});

View File

@ -1,98 +1,100 @@
{
"name": "mobilizon",
"version": "1.0.0-beta.1",
"license": "AGPL-3.0",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "vue-cli-service build --modern",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e",
"lint": "vue-cli-service lint",
"analyze-bundle": "yarn run build -- --report-json && webpack-bundle-analyzer ../priv/static/report.json",
"dev": "vue-cli-service build --watch",
"styleguide": "vue-cli-service styleguidist",
"styleguide:build": "vue-cli-service styleguidist:build",
"vue-i18n-extract": "vue-i18n-extract",
"graphql:get-schema": "graphql get-schema",
"i18n-extract": "vue-i18n-extract report -v './src/**/*.?(ts|vue)' -l './src/i18n/en_US.json' -o output.json"
"lint": "vue-cli-service lint"
},
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@mdi/font": "^4.5.95",
"@mdi/font": "^5.0.45",
"apollo-absinthe-upload-link": "^1.5.0",
"apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1",
"apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.16",
"apollo-cache": "^1.3.5",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
"apollo-link": "^1.2.14",
"apollo-link-error": "^1.1.13",
"apollo-link-http": "^1.5.17",
"apollo-link-ws": "^1.0.19",
"apollo-utilities": "^1.3.2",
"buefy": "^0.8.2",
"bulma-divider": "^0.2.0",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.1",
"intersection-observer": "^0.7.0",
"core-js": "^3.6.4",
"eslint-plugin-cypress": "^2.10.3",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",
"intersection-observer": "^0.10.0",
"javascript-time-ago": "^2.0.4",
"leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.70.0",
"leaflet.locatecontrol": "^0.72.0",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"phoenix": "^1.4.11",
"register-service-worker": "^1.6.2",
"register-service-worker": "^1.7.1",
"tippy.js": "4.3.5",
"tiptap": "^1.26.0",
"tiptap-extensions": "^1.28.0",
"vue": "^2.6.10",
"vue-apollo": "^3.0.0-rc.6",
"vue-class-component": "^7.0.2",
"v-tooltip": "2.0.2",
"vue": "^2.6.11",
"vue-apollo": "^3.0.3",
"vue-class-component": "^7.2.3",
"vue-i18n": "^8.14.0",
"vue-meta": "^2.3.1",
"vue-property-decorator": "^8.1.0",
"vue-router": "^3.0.6",
"vue-property-decorator": "^8.4.1",
"vue-router": "^3.1.6",
"vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3"
"vue2-leaflet": "^2.0.3",
"vuedraggable": "^2.23.2"
},
"devDependencies": {
"@types/chai": "^4.2.3",
"@types/chai": "^4.2.11",
"@types/javascript-time-ago": "^2.0.1",
"@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.60.7",
"@types/lodash": "^4.14.141",
"@types/mocha": "^7.0.1",
"@vue/cli-plugin-babel": "^4.0.3",
"@vue/cli-plugin-e2e-cypress": "^4.0.3",
"@vue/cli-plugin-pwa": "^4.0.3",
"@vue/cli-plugin-router": "^4.0.3",
"@vue/cli-plugin-typescript": "^4.0.3",
"@vue/cli-plugin-unit-mocha": "^4.0.3",
"@vue/cli-service": "^4.0.3",
"@vue/eslint-config-typescript": "^5.0.0",
"@vue/test-utils": "^1.0.0-beta.31",
"apollo-link-error": "^1.1.12",
"chai": "^4.2.0",
"dotenv-webpack": "^1.7.0",
"eslint": "^6.5.1",
"@types/mocha": "^5.2.4",
"@types/ngeohash": "^0.6.2",
"@types/prosemirror-inputrules": "^1.0.2",
"@types/prosemirror-model": "^1.7.2",
"@types/prosemirror-state": "^1.2.4",
"@types/prosemirror-view": "^1.11.4",
"@types/vuedraggable": "^2.23.0",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"@vue/cli-plugin-babel": "~4.4.1",
"@vue/cli-plugin-e2e-cypress": "~4.4.1",
"@vue/cli-plugin-eslint": "~4.4.1",
"@vue/cli-plugin-pwa": "~4.4.1",
"@vue/cli-plugin-router": "~4.4.1",
"@vue/cli-plugin-typescript": "~4.4.1",
"@vue/cli-plugin-unit-mocha": "~4.4.1",
"@vue/cli-service": "~4.4.1",
"@vue/eslint-config-airbnb": "^5.0.2",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/test-utils": "1.0.3",
"chai": "^4.1.2",
"eslint": "^6.7.2",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"graphql-cli": "^3.0.12",
"node-sass": "^4.11.0",
"sass-loader": "^8.0.0",
"tslint": "^6.0.0",
"tslint-config-airbnb": "^5.11.2",
"typescript": "^3.6.3",
"vue-cli-plugin-styleguidist": "^4.0.1",
"vue-cli-plugin-webpack-bundle-analyzer": "^2.0.0",
"node-sass": "^4.12.0",
"prettier": "2.0.5",
"prettier-eslint": "^10.1.1",
"sass-loader": "^8.0.2",
"typescript": "~3.9.3",
"vue-cli-plugin-styleguidist": "~4.24.0",
"vue-i18n-extract": "^1.0.2",
"vue-svg-inline-loader": "^1.3.0",
"vue-template-compiler": "^2.6.10",
"webpack": "^4.41.0"
},
"resolutions": {
"prosemirror-model": "1.8.2"
},
"browserslist": [
">0.25%",
"ie 11",
"not op_mini all"
],
"engines": {
"node": ">=10.0.0"
"vue-svg-loader": "^0.16.0",
"vue-template-compiler": "^2.6.11",
"webpack-cli": "^3.3.11"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,149 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,16.000000) scale(0.000320,-0.000320)"
fill="#000000" stroke="none">
<path d="M18 46618 c45 -75 122 -207 122 -211 0 -2 25 -45 55 -95 30 -50 55
-96 55 -102 0 -5 5 -10 10 -10 6 0 10 -4 10 -9 0 -5 73 -135 161 -288 89 -153
173 -298 187 -323 14 -25 32 -57 41 -72 88 -149 187 -324 189 -335 2 -7 8 -13
13 -13 5 0 9 -4 9 -10 0 -5 46 -89 103 -187 175 -302 490 -846 507 -876 8 -16
20 -36 25 -45 28 -46 290 -498 339 -585 13 -23 74 -129 136 -236 61 -107 123
-215 137 -240 14 -25 29 -50 33 -56 5 -5 23 -37 40 -70 18 -33 38 -67 44 -75
11 -16 21 -33 63 -109 14 -25 29 -50 33 -56 4 -5 21 -35 38 -65 55 -100 261
-455 269 -465 4 -5 14 -21 20 -35 15 -29 41 -75 103 -180 24 -41 52 -88 60
-105 9 -16 57 -100 107 -185 112 -193 362 -626 380 -660 8 -14 23 -38 33 -55
11 -16 23 -37 27 -45 4 -8 26 -46 48 -85 23 -38 53 -90 67 -115 46 -81 64
-113 178 -310 62 -107 121 -210 132 -227 37 -67 56 -99 85 -148 16 -27 32 -57
36 -65 4 -8 15 -27 25 -42 9 -15 53 -89 96 -165 44 -76 177 -307 296 -513 120
-206 268 -463 330 -570 131 -227 117 -203 200 -348 36 -62 73 -125 82 -140 10
-15 21 -34 25 -42 4 -8 20 -37 36 -65 17 -27 38 -65 48 -82 49 -85 64 -111 87
-153 13 -25 28 -49 32 -55 4 -5 78 -134 165 -285 87 -151 166 -288 176 -305
10 -16 26 -43 35 -59 9 -17 125 -217 257 -445 132 -229 253 -441 270 -471 17
-30 45 -79 64 -108 18 -29 33 -54 33 -57 0 -2 20 -37 44 -77 24 -40 123 -212
221 -383 97 -170 190 -330 205 -355 16 -25 39 -65 53 -90 13 -25 81 -144 152
-265 70 -121 137 -238 150 -260 12 -22 37 -65 55 -95 18 -30 43 -73 55 -95 12
-22 48 -85 80 -140 77 -132 163 -280 190 -330 13 -22 71 -123 130 -225 59
-102 116 -199 126 -217 10 -17 29 -50 43 -72 15 -22 26 -43 26 -45 0 -2 27
-50 60 -106 33 -56 60 -103 60 -105 0 -2 55 -98 90 -155 8 -14 182 -316 239
-414 13 -22 45 -79 72 -124 27 -46 49 -86 49 -89 0 -2 14 -24 30 -48 16 -24
30 -46 30 -49 0 -5 74 -135 100 -176 5 -8 24 -42 43 -75 50 -88 58 -101 262
-455 104 -179 199 -345 213 -370 14 -25 28 -49 32 -55 4 -5 17 -26 28 -45 10
-19 62 -109 114 -200 114 -197 133 -230 170 -295 16 -27 33 -57 38 -65 17 -28
96 -165 103 -180 4 -8 16 -28 26 -45 10 -16 77 -131 148 -255 72 -124 181
-313 243 -420 62 -107 121 -209 131 -227 35 -62 323 -560 392 -678 38 -66 83
-145 100 -175 16 -30 33 -59 37 -65 4 -5 17 -27 29 -47 34 -61 56 -100 90
-156 17 -29 31 -55 31 -57 0 -2 17 -32 39 -67 21 -35 134 -229 251 -433 117
-203 235 -407 261 -451 27 -45 49 -85 49 -88 0 -4 8 -19 19 -34 15 -21 200
-341 309 -533 10 -19 33 -58 51 -87 17 -29 31 -54 31 -56 0 -2 25 -44 55 -94
30 -50 55 -95 55 -98 0 -4 6 -15 14 -23 7 -9 27 -41 43 -71 17 -30 170 -297
342 -594 171 -296 311 -542 311 -547 0 -5 5 -9 10 -9 6 0 10 -4 10 -10 0 -5
22 -47 49 -92 27 -46 58 -99 68 -118 24 -43 81 -140 93 -160 5 -8 66 -114 135
-235 69 -121 130 -227 135 -235 12 -21 259 -447 283 -490 10 -19 28 -47 38
-62 11 -14 19 -29 19 -32 0 -3 37 -69 83 -148 99 -170 305 -526 337 -583 13
-22 31 -53 41 -70 11 -16 22 -37 26 -45 7 -14 82 -146 103 -180 14 -24 181
-311 205 -355 13 -22 46 -80 75 -130 29 -49 64 -110 78 -135 14 -25 51 -88 82
-140 31 -52 59 -102 63 -110 4 -8 18 -33 31 -55 205 -353 284 -489 309 -535
17 -30 45 -78 62 -106 18 -28 36 -60 39 -72 4 -12 12 -22 17 -22 5 0 9 -4 9
-10 0 -5 109 -197 241 -427 133 -230 250 -431 259 -448 51 -90 222 -385 280
-485 37 -63 78 -135 92 -160 14 -25 67 -117 118 -205 51 -88 101 -175 111
-193 34 -58 55 -95 149 -257 51 -88 101 -173 110 -190 9 -16 76 -131 147 -255
72 -124 140 -241 151 -260 61 -108 281 -489 355 -615 38 -66 77 -133 87 -150
35 -63 91 -161 100 -175 14 -23 99 -169 128 -220 54 -97 135 -235 142 -245 4
-5 20 -32 35 -60 26 -48 238 -416 276 -480 10 -16 26 -46 37 -65 30 -53 382
-661 403 -695 10 -16 22 -37 26 -45 4 -8 26 -48 50 -88 24 -41 43 -75 43 -77
0 -2 22 -40 50 -85 27 -45 50 -84 50 -86 0 -3 38 -69 83 -147 84 -142 302
-520 340 -587 10 -19 34 -60 52 -90 18 -30 44 -75 57 -100 14 -25 45 -79 70
-120 25 -41 56 -96 70 -121 14 -25 77 -133 138 -240 62 -107 122 -210 132
-229 25 -43 310 -535 337 -581 11 -19 26 -45 34 -59 17 -32 238 -414 266 -460
11 -19 24 -41 28 -49 3 -7 75 -133 160 -278 84 -146 153 -269 153 -274 0 -5 5
-9 10 -9 6 0 10 -4 10 -10 0 -5 82 -150 181 -322 182 -314 201 -346 240 -415
12 -21 80 -139 152 -263 71 -124 141 -245 155 -270 14 -25 28 -49 32 -55 6 -8
145 -248 220 -380 37 -66 209 -362 229 -395 11 -19 24 -42 28 -49 4 -8 67
-118 140 -243 73 -125 133 -230 133 -233 0 -2 15 -28 33 -57 19 -29 47 -78 64
-108 17 -30 53 -93 79 -139 53 -90 82 -141 157 -272 82 -142 115 -199 381
-659 142 -245 268 -463 281 -485 12 -22 71 -125 132 -230 60 -104 172 -298
248 -430 76 -132 146 -253 156 -270 11 -16 22 -36 26 -44 3 -8 30 -54 60 -103
29 -49 53 -91 53 -93 0 -3 18 -34 40 -70 22 -36 40 -67 40 -69 0 -2 37 -66 81
-142 45 -77 98 -168 119 -204 20 -36 47 -81 58 -100 12 -19 27 -47 33 -62 6
-16 15 -28 20 -28 5 0 9 -4 9 -9 0 -6 63 -118 140 -251 77 -133 140 -243 140
-245 0 -2 18 -33 41 -70 22 -37 49 -83 60 -101 10 -19 29 -51 40 -71 25 -45
109 -189 126 -218 7 -11 17 -29 22 -40 6 -11 22 -38 35 -60 14 -22 37 -62 52
-90 14 -27 35 -62 45 -77 11 -14 19 -29 19 -32 0 -3 18 -35 40 -71 22 -36 40
-67 40 -69 0 -2 19 -35 42 -72 23 -38 55 -94 72 -124 26 -47 139 -244 171
-298 6 -9 21 -36 34 -60 28 -48 37 -51 51 -19 6 12 19 36 29 52 10 17 27 46
38 65 11 19 104 181 208 360 103 179 199 345 213 370 14 25 42 74 64 109 21
34 38 65 38 67 0 2 18 33 40 69 22 36 40 67 40 69 0 3 177 310 199 346 16 26
136 234 140 244 2 5 25 44 52 88 27 44 49 81 49 84 0 2 18 34 40 70 22 36 40
67 40 69 0 2 20 36 43 77 35 58 169 289 297 513 9 17 50 86 90 155 40 69 86
150 103 180 16 30 35 62 41 70 6 8 16 24 22 35 35 64 72 129 167 293 59 100
116 199 127 220 11 20 30 53 41 72 43 72 1070 1850 1121 1940 14 25 65 113
113 195 48 83 96 166 107 185 10 19 28 50 38 68 11 18 73 124 137 235 64 111
175 303 246 427 71 124 173 299 225 390 52 91 116 202 143 248 27 45 49 85 49
89 0 4 6 14 14 22 7 9 28 43 46 76 26 47 251 436 378 655 11 19 29 51 40 70
11 19 101 176 201 348 99 172 181 317 181 323 0 5 5 9 10 9 6 0 10 5 10 11 0
6 8 23 18 37 11 15 32 52 49 82 16 30 130 228 253 440 122 212 234 405 248
430 13 25 39 70 57 100 39 65 69 117 130 225 25 44 50 87 55 95 12 19 78 134
220 380 61 107 129 224 150 260 161 277 222 382 246 425 15 28 47 83 71 123
24 41 43 78 43 83 0 5 4 9 8 9 4 0 13 12 19 28 7 15 23 45 36 67 66 110 277
478 277 483 0 3 6 13 14 21 7 9 27 41 43 71 17 30 45 80 63 110 34 57 375 649
394 685 6 11 16 27 22 35 6 8 26 42 44 75 18 33 41 74 51 90 10 17 24 41 32
55 54 97 72 128 88 152 11 14 19 28 19 30 0 3 79 141 175 308 96 167 175 305
175 308 0 3 6 13 14 21 7 9 26 39 41 66 33 60 276 483 338 587 24 40 46 80 50
88 4 8 13 24 20 35 14 23 95 163 125 215 11 19 52 91 92 160 40 69 80 139 90
155 9 17 103 179 207 360 105 182 200 346 211 365 103 181 463 802 489 845 7
11 15 27 19 35 4 8 29 51 55 95 64 110 828 1433 848 1470 9 17 24 41 33 55 9
14 29 48 45 77 15 28 52 93 82 145 30 51 62 107 71 123 17 30 231 398 400 690
51 88 103 179 115 202 12 23 26 48 32 55 6 7 24 38 40 68 17 30 61 107 98 170
37 63 84 144 103 180 19 36 41 72 48 81 8 8 14 18 14 21 0 4 27 51 59 106 32
55 72 124 89 154 16 29 71 125 122 213 51 88 104 180 118 205 13 25 28 50 32
55 4 6 17 26 28 45 11 19 45 80 77 135 31 55 66 116 77 135 11 19 88 152 171
295 401 694 620 1072 650 1125 11 19 87 152 170 295 83 143 158 273 166 288 9
16 21 36 26 45 6 9 31 52 55 96 25 43 54 94 66 115 11 20 95 164 186 321 91
157 173 299 182 315 9 17 26 46 37 65 12 19 66 114 121 210 56 96 108 186 117
200 8 14 24 40 34 59 24 45 383 664 412 713 5 9 17 29 26 45 15 28 120 210
241 419 36 61 68 117 72 125 4 8 12 23 19 34 35 57 245 420 262 453 11 20 35
61 53 90 17 29 32 54 32 56 0 3 28 51 62 108 33 57 70 119 80 138 10 19 23 42
28 50 5 8 32 53 59 100 27 47 149 258 271 470 122 212 234 405 248 430 30 53
62 108 80 135 6 11 15 27 19 35 4 8 85 150 181 315 96 165 187 323 202 350 31
56 116 202 130 225 5 8 25 42 43 75 19 33 92 159 162 280 149 257 157 271 202
350 19 33 38 67 43 75 9 14 228 392 275 475 12 22 55 96 95 165 40 69 80 139
90 155 24 42 202 350 221 383 9 15 27 47 41 72 14 25 75 131 136 236 61 106
121 210 134 232 99 172 271 470 279 482 5 8 23 40 40 70 18 30 81 141 142 245
60 105 121 210 135 235 14 25 71 124 127 220 56 96 143 247 194 335 51 88 96
167 102 175 14 24 180 311 204 355 23 43 340 590 356 615 5 8 50 87 101 175
171 301 517 898 582 1008 25 43 46 81 46 83 0 2 12 23 27 47 14 23 40 67 56
97 16 30 35 62 42 70 7 8 15 22 18 30 4 8 20 38 37 65 16 28 33 57 37 65 6 12
111 196 143 250 5 8 55 95 112 193 57 98 113 195 126 215 12 20 27 46 32 57 6
11 14 27 20 35 5 8 76 130 156 270 80 140 165 287 187 325 23 39 52 90 66 115
13 25 30 52 37 61 8 8 14 18 14 21 0 4 41 77 92 165 50 87 175 302 276 478
101 176 208 360 236 408 28 49 67 117 86 152 19 35 41 70 48 77 6 6 12 15 12
19 0 7 124 224 167 291 12 21 23 40 23 42 0 2 21 40 46 83 26 43 55 92 64 109
54 95 327 568 354 614 19 30 45 75 59 100 71 128 82 145 89 148 4 2 8 8 8 13
0 5 42 82 94 172 311 538 496 858 518 897 14 25 40 70 58 100 18 30 42 71 53
90 10 19 79 139 152 265 73 127 142 246 153 265 10 19 43 76 72 125 29 50 63
108 75 130 65 116 80 140 87 143 4 2 8 8 8 12 0 8 114 212 140 250 6 8 14 24
20 35 5 11 54 97 108 190 l100 170 -9611 3 c-5286 1 -9614 -1 -9618 -5 -5 -6
-419 -719 -619 -1068 -89 -155 -267 -463 -323 -560 -38 -66 -81 -140 -95 -165
-31 -56 -263 -457 -526 -910 -110 -190 -224 -388 -254 -440 -29 -52 -61 -109
-71 -125 -23 -39 -243 -420 -268 -465 -11 -19 -204 -352 -428 -740 -224 -388
-477 -826 -563 -975 -85 -148 -185 -322 -222 -385 -37 -63 -120 -207 -185
-320 -65 -113 -177 -306 -248 -430 -72 -124 -172 -297 -222 -385 -51 -88 -142
-245 -202 -350 -131 -226 -247 -427 -408 -705 -65 -113 -249 -432 -410 -710
-160 -278 -388 -673 -506 -877 -118 -205 -216 -373 -219 -373 -3 0 -52 82
-109 183 -58 100 -144 250 -192 332 -95 164 -402 696 -647 1120 -85 149 -228
396 -317 550 -212 365 -982 1700 -1008 1745 -10 19 -43 76 -72 125 -29 50 -64
110 -77 135 -14 25 -63 110 -110 190 -47 80 -96 165 -110 190 -14 25 -99 171
-188 325 -89 154 -174 300 -188 325 -13 25 -64 113 -112 195 -48 83 -140 242
-205 355 -65 113 -183 317 -263 454 -79 137 -152 264 -163 282 -50 89 -335
583 -354 614 -12 19 -34 58 -50 85 -15 28 -129 226 -253 440 -124 215 -235
408 -247 430 -12 22 -69 121 -127 220 -58 99 -226 389 -373 645 -148 256 -324
561 -392 678 -67 117 -134 232 -147 255 -13 23 -33 59 -46 80 l-22 37 -9615 0
-9615 0 20 -32z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,20 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<%= VUE_APP_INJECT_COMMENT %>
</head>
<body>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<!-- <%= VUE_APP_INJECT_COMMENT %> -->
<meta name="server-injected-data" />
</head>
<body>
<noscript>
<strong>We're sorry but mobilizon doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</body>
</html>

View File

@ -1,20 +0,0 @@
{
"name": "Mobilizon",
"short_name": "mobilizon",
"icons": [
{
"src": "/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#FAB12D",
"theme_color": "#424056"
}

View File

@ -1,2 +1,2 @@
User-agent: *
Allow: /
Disallow:

1
js/src/@types/v-tooltip/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "v-tooltip";

57
js/src/@types/vuedraggable/index.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
declare module "vuedraggable" {
import Vue, { ComponentOptions } from "vue";
export interface DraggedContext<T> {
index: number;
futureIndex: number;
element: T;
}
export interface DropContext<T> {
index: number;
component: Vue;
element: T;
}
export interface Rectangle {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
export interface MoveEvent<T> {
originalEvent: DragEvent;
dragged: Element;
draggedContext: DraggedContext<T>;
draggedRect: Rectangle;
related: Element;
relatedContext: DropContext<T>;
relatedRect: Rectangle;
from: Element;
to: Element;
willInsertAfter: boolean;
isTrusted: boolean;
}
export interface ChangeEvent<T> {
added: {
newIndex: number;
element: T;
};
removed: {
oldIndex: number;
element: T;
};
moved: {
newIndex: number;
oldIndex: number;
};
}
const draggableComponent: ComponentOptions<Vue>;
export default draggableComponent;
}

View File

@ -2,12 +2,39 @@
<div id="mobilizon">
<NavBar />
<div class="container" v-if="config && config.demoMode">
<b-message type="is-danger" :title="$t('Warning').toLocaleUpperCase()" closable aria-close-label="Close">
<p v-html="`${$t('This is a demonstration site to test the beta version of Mobilizon.')} ${$t('<b>Please do not use it in any real way.</b>')}`" />
<b-message
type="is-danger"
:title="$t('Warning').toLocaleUpperCase()"
closable
aria-close-label="Close"
>
<p
v-html="
`${$t('This is a demonstration site to test the beta version of Mobilizon.')} ${$t(
'<b>Please do not use it in any real way.</b>'
)}`
"
/>
<p>
<span v-html="$t('Mobilizon is under development, we will add new features to this site during regular updates, until the release of <b>version 1 of the software in the first half of 2020</b>.')" />
<i18n path="In the meantime, please consider that the software is not (yet) finished. More information {onBlog}.">
<a slot="onBlog" :href="$i18n.locale === 'fr' ? 'https://framablog.org/?p=18268' : 'https://framablog.org/?p=18299'">{{ $t('on our blog') }}</a>
<span
v-html="
$t(
'Mobilizon is under development, we will add new features to this site during regular updates, until the release of <b>version 1 of the software in the first half of 2020</b>.'
)
"
/>
<i18n
path="In the meantime, please consider that the software is not (yet) finished. More information {onBlog}."
>
<a
slot="onBlog"
:href="
$i18n.locale === 'fr'
? 'https://framablog.org/?p=18268'
: 'https://framablog.org/?p=18299'
"
>{{ $t("on our blog") }}</a
>
</i18n>
</p>
</b-message>
@ -22,20 +49,15 @@
</template>
<script lang="ts">
import NavBar from '@/components/NavBar.vue';
import { Component, Vue } from 'vue-property-decorator';
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';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
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 Footer from "./components/Footer.vue";
import Logo from "./components/Logo.vue";
import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model";
@Component({
apollo: {
currentUser: {
@ -46,7 +68,7 @@ import { IConfig } from '@/types/config.model';
components: {
Logo,
NavBar,
'mobilizon-footer': Footer,
"mobilizon-footer": Footer,
},
})
export default class App extends Vue {
@ -65,7 +87,7 @@ export default class App extends Vue {
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
return await this.$apollo.mutate({
return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
@ -85,7 +107,7 @@ export default class App extends Vue {
/* Bulma imports */
@import "~bulma/bulma";
@import '~bulma-divider';
@import "~bulma-divider";
/* Buefy imports */
@import "~buefy/src/scss/buefy";
@ -94,24 +116,27 @@ export default class App extends Vue {
$mdi-font-path: "~@mdi/font/fonts";
@import "~@mdi/font/scss/materialdesignicons";
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to {
.fade-enter,
.fade-leave-to {
opacity: 0;
}
body {
body {
// background: #f7f8fa;
background: $body-background-color;
font-family: BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif;
font-family: BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans",
"Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
/*main {*/
/* margin: 1rem auto 0;*/
/*}*/
}
}
#mobilizon > .container > .message {
#mobilizon > .container > .message {
margin: 1rem auto auto;
.message-header {
button.delete {
@ -120,5 +145,5 @@ $mdi-font-path: "~@mdi/font/fonts";
color: #111;
}
}
}
</style>

View File

@ -1,19 +1,19 @@
import { ApolloCache } from 'apollo-cache';
import { NormalizedCacheObject } from 'apollo-cache-inmemory';
import { ICurrentUserRole } from '@/types/current-user.model';
import { ApolloCache } from "apollo-cache";
import { NormalizedCacheObject } from "apollo-cache-inmemory";
import { ICurrentUserRole } from "@/types/current-user.model";
export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
export default function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
cache.writeData({
data: {
currentUser: {
__typename: 'CurrentUser',
__typename: "CurrentUser",
id: null,
email: null,
isLoggedIn: false,
role: ICurrentUserRole.USER,
},
currentActor: {
__typename: 'CurrentActor',
__typename: "CurrentActor",
id: null,
preferredUsername: null,
name: null,
@ -24,31 +24,49 @@ export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObjec
return {
Mutation: {
updateCurrentUser: (_, { id, email, isLoggedIn, role }, { cache }) => {
updateCurrentUser: (
_: any,
{
id,
email,
isLoggedIn,
role,
}: { id: string; email: string; isLoggedIn: boolean; role: string },
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentUser: {
id,
email,
isLoggedIn,
role,
__typename: 'CurrentUser',
__typename: "CurrentUser",
},
};
cache.writeData({ data });
localCache.writeData({ data });
},
updateCurrentActor: (_, { id, preferredUsername, avatar, name }, { cache }) => {
updateCurrentActor: (
_: any,
{
id,
preferredUsername,
avatar,
name,
}: { id: string; preferredUsername: string; avatar: string; name: string },
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentActor: {
id,
preferredUsername,
avatar,
name,
__typename: 'CurrentActor',
__typename: "CurrentActor",
},
};
cache.writeData({ data });
localCache.writeData({ data });
},
},
};

63
js/src/apollo/utils.ts Normal file
View File

@ -0,0 +1,63 @@
import { IntrospectionFragmentMatcher, NormalizedCacheObject } from "apollo-cache-inmemory";
import { IError, errors, defaultError, refreshSuggestion } from "@/utils/errors";
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants";
import { REFRESH_TOKEN } from "@/graphql/auth";
import { saveTokenData } from "@/utils/auth";
import { ApolloClient } from "apollo-client";
export const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
__schema: {
types: [
{
kind: "UNION",
name: "SearchResult",
possibleTypes: [{ name: "Event" }, { name: "Person" }, { name: "Group" }],
},
{
kind: "INTERFACE",
name: "Actor",
possibleTypes: [{ name: "Person" }, { name: "Group" }],
},
],
},
},
});
export const computeErrorMessage = (message: any) => {
const error: IError = errors.reduce((acc, errorLocal) => {
if (RegExp(errorLocal.match).test(message)) {
return errorLocal;
}
return acc;
}, defaultError);
if (error.value === null) return null;
return error.suggestRefresh === false ? error.value : `${error.value}<br>${refreshSuggestion}`;
};
export async function refreshAccessToken(
apolloClient: ApolloClient<NormalizedCacheObject>
): Promise<boolean> {
// Remove invalid access token, so the next request is not authenticated
localStorage.removeItem(AUTH_ACCESS_TOKEN);
const refreshToken = localStorage.getItem(AUTH_REFRESH_TOKEN);
console.log("Refreshing access token.");
try {
const res = await apolloClient.mutate({
mutation: REFRESH_TOKEN,
variables: {
refreshToken,
},
});
saveTokenData(res.data.refreshToken);
return true;
} catch (err) {
return false;
}
}

View File

@ -1 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 248.16 46.78"><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>
<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.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,109 @@
<template>
<b-autocomplete
:data="baseData"
:placeholder="$t('Actor')"
v-model="name"
field="preferredUsername"
:loading="$apollo.loading"
check-infinite-scroll
@typing="getAsyncData"
@select="handleSelect"
@infinite-scroll="getAsyncData"
>
<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="" />
<b-icon v-else icon="account-circle" />
</div>
<div class="media-content">
<span v-if="props.option.name">
{{ props.option.name }}
<br />
<small>{{ `@${props.option.preferredUsername}` }}</small>
<small v-if="props.option.domain">{{ `@${props.option.domain}` }}</small>
</span>
<span v-else>
{{ `@${props.option.preferredUsername}` }}
</span>
</div>
</div>
</template>
<template slot="footer">
<span class="has-text-grey" v-show="page > totalPages">
Thats it! No more movies found.
</span>
</template>
</b-autocomplete>
</template>
<script lang="ts">
import { Component, Model, Vue, Watch } from "vue-property-decorator";
import { debounce } from "lodash";
import { IPerson } from "@/types/actor";
import { SEARCH_PERSONS } from "@/graphql/search";
import { Paginate } from "@/types/paginate";
const SEARCH_PERSON_LIMIT = 10;
@Component
export default class ActorAutoComplete extends Vue {
@Model("change", { type: Object }) readonly defaultSelected!: IPerson | null;
baseData: IPerson[] = [];
selected: IPerson | null = this.defaultSelected;
name: string = this.defaultSelected ? this.defaultSelected.preferredUsername : "";
page = 1;
totalPages = 1;
mounted() {
this.selected = this.defaultSelected;
}
data() {
return {
getAsyncData: debounce(this.doGetAsyncData, 500),
};
}
@Watch("defaultSelected")
updateDefaultSelected(defaultSelected: IPerson) {
console.log("update defaultSelected", defaultSelected);
this.selected = defaultSelected;
this.name = defaultSelected.preferredUsername;
}
handleSelect(selected: IPerson) {
this.selected = selected;
this.$emit("change", selected);
}
async doGetAsyncData(name: string) {
this.baseData = [];
if (this.name !== name) {
this.name = name;
this.page = 1;
}
if (!name.length) {
this.page = 1;
this.totalPages = 1;
return;
}
const {
data: { searchPersons },
} = await this.$apollo.query<{ searchPersons: Paginate<IPerson> }>({
query: SEARCH_PERSONS,
variables: {
searchText: this.name,
page: this.page,
limit: SEARCH_PERSON_LIMIT,
},
});
this.totalPages = Math.ceil(searchPersons.total / SEARCH_PERSON_LIMIT);
this.baseData.push(...searchPersons.elements);
}
}
</script>

View File

@ -0,0 +1,152 @@
<template>
<div class="clickable">
<div class="media" style="align-items: top;">
<div class="media-left">
<figure class="image is-32x32" v-if="actor.avatar">
<img class="is-rounded" :src="actor.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="media-content">
<p>
{{ actor.name || `@${usernameWithDomain(actor)}` }}
</p>
<p class="has-text-grey" v-if="actor.name">@{{ usernameWithDomain(actor) }}</p>
<p v-if="full">{{ actor.summary }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor, usernameWithDomain } from "../../types/actor";
@Component
export default class ActorCard extends Vue {
@Prop({ required: true, type: Object }) actor!: IActor;
@Prop({ required: false, type: Boolean, default: false }) full!: boolean;
@Prop({ required: false, type: Boolean, default: true }) popover!: boolean;
usernameWithDomain = usernameWithDomain;
}
</script>
<style lang="scss" scoped>
.clickable {
cursor: pointer;
}
</style>
<style lang="scss">
.tooltip {
display: block !important;
z-index: 10000;
.tooltip-inner {
background: black;
color: white;
border-radius: 16px;
padding: 5px 10px 4px;
}
.tooltip-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: black;
z-index: 1;
}
&[x-placement^="top"] {
margin-bottom: 5px;
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: 5px;
.tooltip-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
margin-left: 5px;
.tooltip-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[x-placement^="left"] {
margin-right: 5px;
.tooltip-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&.popover {
$color: #f9f9f9;
.popover-inner {
background: $color;
color: black;
padding: 24px;
border-radius: 5px;
box-shadow: 0 5px 30px rgba(black, 0.1);
}
.popover-arrow {
border-color: $color;
}
}
&[aria-hidden="true"] {
visibility: hidden;
opacity: 0;
transition: opacity 0.15s, visibility 0.15s;
}
&[aria-hidden="false"] {
visibility: visible;
opacity: 1;
transition: opacity 0.15s;
}
}
</style>

View File

@ -1,76 +0,0 @@
<docs>
A simple link to an actor, local or remote link
```vue
<template>
<ActorLink :actor="localActor">
<template>
<span>{{ localActor.preferredUsername }}</span>
</template>
</ActorLink>
</template>
<script>
export default {
data() {
return {
localActor: {
domain: null,
preferredUsername: 'localActor'
},
}
}
}
</script>
```
```vue
<template>
<ActorLink :actor="remoteActor">
<template>
<span>{{ remoteActor.preferredUsername }}</span>
</template>
</ActorLink>
</template>
<script>
export default {
data() {
return {
remoteActor: {
domain: 'mobilizon.org',
url: 'https://mobilizon.org/@Framasoft',
preferredUsername: 'Framasoft'
},
}
}
}
</script>
```
</docs>
<template>
<span>
<span v-if="actor.domain === null"
:to="{name: 'Profile', params: { name: actor.preferredUsername } }"
>
<!-- @slot What to put inside the link -->
<slot></slot>
</span>
<a v-else :href="actor.url">
<!-- @slot What to put inside the link -->
<slot></slot>
</a>
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor } from '@/types/actor';
@Component
export default class ActorLink extends Vue {
/**
* The actor you want to make a link to
*/
@Prop({ required: true }) actor!: IActor;
}
</script>

View File

@ -1,18 +1,19 @@
<template>
<section>
<h1 class="title">
{{ $t('My identities') }}
{{ $t("My identities") }}
</h1>
<ul class="identities">
<li v-for="identity in identities" :key="identity.id">
<router-link
:to="{ name: 'UpdateIdentity', params: { identityName: identity.preferredUsername } }"
class="media identity" v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"
class="media identity"
v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"
>
<div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url">
<img class="is-rounded" :src="identity.avatar.url" />
</figure>
</div>
@ -23,24 +24,24 @@
</li>
</ul>
<router-link :to="{ name: 'CreateIdentity' }" class="button create-identity is-primary" >
{{ $t('Create a new identity') }}
<router-link :to="{ name: 'CreateIdentity' }" class="button create-identity is-primary">
{{ $t("Create a new identity") }}
</router-link>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IDENTITIES } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor';
import { Component, Prop, Vue } from "vue-property-decorator";
import { IDENTITIES } from "../../graphql/actor";
import { IPerson, Person } from "../../types/actor";
@Component({
apollo: {
identities: {
query: IDENTITIES,
update (result) {
return result.identities.map(i => new Person(i));
update(result) {
return result.identities.map((i: IPerson) => new Person(i));
},
},
},
@ -49,6 +50,7 @@ export default class Identities extends Vue {
@Prop({ type: String }) currentIdentityName!: string;
identities: Person[] = [];
errors: string[] = [];
isCurrentIdentity(identity: IPerson) {
@ -58,13 +60,13 @@ export default class Identities extends Vue {
</script>
<style lang="scss" scoped>
.identities {
.identities {
border-right: 1px solid grey;
padding: 15px 0;
}
}
.media.identity {
.media.identity {
align-items: center;
font-size: 1.3rem;
padding-bottom: 0;
@ -74,9 +76,9 @@ export default class Identities extends Vue {
&.is-current-identity {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
.title {
.title {
margin-bottom: 30px;
}
}
</style>

View File

@ -25,32 +25,58 @@
</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>
<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>
<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 { Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { Component, Prop, Vue } from "vue-property-decorator";
import { Person } from "../../types/actor";
import { IParticipant, ParticipantRole } from "../../types/event.model";
@Component
export default class ParticipantCard extends Vue {
@Prop({ required: true }) participant!: IParticipant;
@Prop({ type: Function }) accept;
@Prop({ type: Function }) reject;
@Prop({ type: Function }) exclude;
@Prop({ type: Function }) accept!: Function;
@Prop({ type: Function }) reject!: Function;
@Prop({ type: Function }) exclude!: Function;
ParticipantRole = ParticipantRole;
@ -58,13 +84,12 @@ export default class ParticipantCard extends Vue {
const actor = new Person(this.participant.actor);
return actor.displayName();
}
}
</script>
<style lang="scss">
@import "../../variables.scss";
.card-footer-item {
@import "../../variables.scss";
.card-footer-item {
height: $control-height;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<v-popover offset="16" trigger="hover" :class="{ inline }" class="clickable">
<slot></slot>
<template slot="popover" class="popover">
<actor-card :full="true" :actor="actor" :popover="false" />
</template>
</v-popover>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor } from "../../types/actor";
import ActorCard from "./ActorCard.vue";
@Component({
components: {
ActorCard,
},
})
export default class PopoverActorCard extends Vue {
@Prop({ required: true, type: Object }) actor!: IActor;
@Prop({ required: false, type: Boolean, default: false }) inline!: boolean;
}
</script>
<style lang="scss" scoped>
.inline {
display: inline;
}
.clickable {
cursor: pointer;
}
</style>

View File

@ -14,37 +14,38 @@
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
checkbox-position="left"
>
<template slot-scope="props">
<b-table-column field="actor.id" label="ID" width="40" numeric>
{{ props.row.actor.id }}
</b-table-column>
<b-table-column field="actor.id" label="ID" width="40" numeric>{{
props.row.actor.id
}}</b-table-column>
<b-table-column field="actor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.actor)" />
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<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>
<a @click="toggle(props.row)" v-if="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>
<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="actor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
<b-table-column field="actor.updatedAt" :label="$t('Date')" sortable>{{
props.row.updatedAt | formatDateTimeString
}}</b-table-column>
</template>
<template slot="detail" slot-scope="props">
@ -53,7 +54,7 @@
<strong>{{ props.row.actor.domain }}</strong>
<small>@{{ props.row.actor.preferredUsername }}</small>
<small>31m</small>
<br>
<br />
<p v-html="props.row.actor.summary" />
</div>
</article>
@ -61,44 +62,62 @@
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button @click="acceptRelays" type="is-success" v-if="checkedRowsHaveAtLeastOneToApprove">
{{ $tc('No instance to approve|Approve instance|Approve {number} instances', checkedRows.length, { number: checkedRows.length }) }}
<b-button
@click="acceptRelays"
type="is-success"
v-if="checkedRowsHaveAtLeastOneToApprove"
>
{{
$tc(
"No instance to approve|Approve instance|Approve {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button @click="rejectRelays" type="is-danger">
{{ $tc('No instance to reject|Reject instance|Reject {number} instances', checkedRows.length, { number: checkedRows.length }) }}
{{
$tc(
"No instance to reject|Reject instance|Reject {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">
{{ $t("No instance follows your instance yet.") }}
</b-message>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">{{
$t("No instance follows your instance yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
import RelayMixin from '@/mixins/relay';
import { Component, Mixins } from "vue-property-decorator";
import { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from "../../graphql/admin";
import { Paginate } from "../../types/paginate";
import { IFollower } from "../../types/actor/follower.model";
import RelayMixin from "../../mixins/relay";
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
fetchPolicy: "cache-and-network",
},
},
metaInfo() {
return {
title: this.$t('Followers') as string,
titleTemplate: '%s | Mobilizon',
title: this.$t("Followers") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followers extends Mixins(RelayMixin) {
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
RelayMixin = RelayMixin;
async acceptRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
@ -111,7 +130,7 @@ export default class Followers extends Mixins(RelayMixin) {
});
}
async acceptRelay(address: String) {
async acceptRelay(address: string) {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
@ -122,7 +141,7 @@ export default class Followers extends Mixins(RelayMixin) {
this.checkedRows = [];
}
async rejectRelay(address: String) {
async rejectRelay(address: string) {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
@ -134,7 +153,7 @@ export default class Followers extends Mixins(RelayMixin) {
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some(checkedRow => !checkedRow.approved);
return this.checkedRows.some((checkedRow) => !checkedRow.approved);
}
}
</script>

View File

@ -7,7 +7,7 @@
<b-input v-model="newRelayAddress" :placeholder="$t('Ex: test.mobilizon.org')" />
</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>
@ -27,37 +27,38 @@
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
checkbox-position="left"
>
<template slot-scope="props">
<b-table-column field="targetActor.id" label="ID" width="40" numeric>
{{ props.row.targetActor.id }}
</b-table-column>
<b-table-column field="targetActor.id" label="ID" width="40" numeric>{{
props.row.targetActor.id
}}</b-table-column>
<b-table-column field="targetActor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.targetActor)" />
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.targetActor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<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>
<a @click="toggle(props.row)" v-if="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>
<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>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
<b-table-column field="targetActor.updatedAt" :label="$t('Date')" sortable>{{
props.row.updatedAt | formatDateTimeString
}}</b-table-column>
</template>
<template slot="detail" slot-scope="props">
@ -66,7 +67,7 @@
<strong>{{ props.row.targetActor.domain }}</strong>
<small>@{{ props.row.targetActor.preferredUsername }}</small>
<small>31m</small>
<br>
<br />
<p v-html="props.row.targetActor.summary" />
</div>
</article>
@ -74,42 +75,50 @@
<template slot="bottom-left" v-if="checkedRows.length > 0">
<b-button @click="removeRelays" type="is-danger">
{{ $tc('No instance to remove|Remove instance|Remove {number} instances', checkedRows.length, { number: checkedRows.length }) }}
{{
$tc(
"No instance to remove|Remove instance|Remove {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowings.elements.length === 0">
{{ $t("You don't follow any instances yet.") }}
</b-message>
<b-message type="is-danger" v-if="relayFollowings.elements.length === 0">{{
$t("You don't follow any instances yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from '@/graphql/admin';
import { IFollower } from '@/types/actor/follower.model';
import { Paginate } from '@/types/paginate';
import RelayMixin from '@/mixins/relay';
import { Component, Mixins } from "vue-property-decorator";
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import { Paginate } from "../../types/paginate";
import RelayMixin from "../../mixins/relay";
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
fetchPolicy: "cache-and-network",
},
},
metaInfo() {
return {
title: this.$t('Followings') as string,
titleTemplate: '%s | Mobilizon',
title: this.$t("Followings") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followings extends Mixins(RelayMixin) {
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
newRelayAddress: String = '';
async followRelay(e) {
newRelayAddress = "";
RelayMixin = RelayMixin;
async followRelay(e: Event) {
e.preventDefault();
await this.$apollo.mutate({
mutation: ADD_RELAY,
@ -119,7 +128,7 @@ export default class Followings extends Mixins(RelayMixin) {
// TODO: Handle cache update properly without refreshing
});
await this.$apollo.queries.relayFollowings.refetch();
this.newRelayAddress = '';
this.newRelayAddress = "";
}
async removeRelays() {
@ -128,7 +137,7 @@ export default class Followings extends Mixins(RelayMixin) {
});
}
async removeRelay(address: String) {
async removeRelay(address: string) {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {

View File

@ -1,9 +1,13 @@
<template>
<li :class="{ reply: comment.inReplyToComment }">
<article class="media" :class="{ selected: commentSelected, organizer: commentFromOrganizer }" :id="commentId">
<article
class="media"
:class="{ selected: commentSelected, organizer: commentFromOrganizer }"
:id="commentId"
>
<figure class="media-left" v-if="!comment.deletedAt && comment.actor.avatar">
<p class="image is-48x48">
<img :src="comment.actor.avatar.url" alt="">
<img :src="comment.actor.avatar.url" alt="" />
</p>
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
@ -11,49 +15,58 @@
<div class="content">
<span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong>
<small v-if="comment.actor.domain">@{{ comment.actor.preferredUsername }}@{{ comment.actor.domain }}</small>
<small v-if="comment.actor.domain"
>@{{ comment.actor.preferredUsername }}@{{ comment.actor.domain }}</small
>
<small v-else>@{{ comment.actor.preferredUsername }}</small>
<a class="comment-link has-text-grey" :href="commentURL">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a>
</span>
<a v-else class="comment-link has-text-grey" :href="commentURL">
<span>{{ $t('[deleted]') }}</span>
<span>{{ $t("[deleted]") }}</span>
</a>
<span class="icons" v-if="!comment.deletedAt">
<span v-if="comment.actor.id === currentActor.id"
@click="$emit('delete-comment', comment)">
<b-icon
icon="delete"
size="is-small"
/>
<button
v-if="comment.actor.id === currentActor.id"
@click="$emit('delete-comment', comment)"
>
<b-icon icon="delete" size="is-small" aria-hidden="true" />
<span class="visually-hidden">{{ $t("Delete") }}</span>
</button>
<button @click="reportModal()">
<b-icon icon="alert" size="is-small" />
<span class="visually-hidden">{{ $t("Report") }}</span>
</button>
</span>
<span @click="reportModal()">
<b-icon
icon="alert"
size="is-small"
/>
</span>
</span>
<br>
<br />
<div v-if="!comment.deletedAt" v-html="comment.text" />
<div v-else>{{ $t('[This comment has been deleted]') }}</div>
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
<span class="load-replies" v-if="comment.totalReplies">
<span v-if="!showReplies" @click="fetchReplies">
{{ $tc('View a reply', comment.totalReplies, { totalReplies: comment.totalReplies }) }}
{{
$tc("View a reply", comment.totalReplies, { totalReplies: comment.totalReplies })
}}
</span>
<span v-else-if="comment.totalReplies && showReplies" @click="showReplies = false">
{{ $t('Hide replies') }}
{{ $t("Hide replies") }}
</span>
</span>
</div>
<nav class="reply-action level is-mobile" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED">
<nav
class="reply-action level is-mobile"
v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED"
>
<div class="level-left">
<span style="cursor: pointer" class="level-item" @click="createReplyToComment(comment)">
<span
style="cursor: pointer;"
class="level-item"
@click="createReplyToComment(comment)"
>
<span class="icon is-small">
<b-icon icon="reply" />
</span>
{{ $t('Reply') }}
{{ $t("Reply") }}
</span>
</div>
</nav>
@ -63,20 +76,25 @@
<article class="media reply">
<figure class="media-left" v-if="currentActor.avatar">
<p class="image is-48x48">
<img :src="currentActor.avatar.url" alt="">
<img :src="currentActor.avatar.url" alt="" />
</p>
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span class="first-line">
<strong>{{ currentActor.name}}</strong>
<strong>{{ currentActor.name }}</strong>
<small>@{{ currentActor.preferredUsername }}</small>
</span>
<br>
<br />
<span class="editor-line">
<editor class="editor" ref="commenteditor" v-model="newComment.text" mode="comment" />
<b-button :disabled="newComment.text.trim().length === 0" native-type="submit" type="is-info">{{ $t('Post a reply') }}</b-button>
<editor class="editor" ref="commentEditor" v-model="newComment.text" mode="comment" />
<b-button
:disabled="newComment.text.trim().length === 0"
native-type="submit"
type="is-info"
>{{ $t("Post a reply") }}</b-button
>
</span>
</div>
</div>
@ -90,23 +108,23 @@
:comment="reply"
:event="event"
@create-comment="$emit('create-comment', $event)"
@delete-comment="$emit('delete-comment', $event)" />
@delete-comment="$emit('delete-comment', $event)"
/>
</transition-group>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CommentModel, IComment } from '@/types/comment.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { Refs } from '@/shims-vue';
import EditorComponent from '@/components/Editor.vue';
import TimeAgo from 'javascript-time-ago';
import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from '@/graphql/comment';
import { IEvent, CommentModeration } from '@/types/event.model';
import ReportModal from '@/components/Report/ReportModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import EditorComponent from "@/components/Editor.vue";
import TimeAgo from "javascript-time-ago";
import { CommentModel, IComment } from "../../types/comment.model";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from "../../graphql/comment";
import { IEvent, CommentModeration } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
@Component({
apollo: {
@ -115,23 +133,29 @@ import { CREATE_REPORT } from '@/graphql/report';
},
},
components: {
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
comment: () => import(/* webpackChunkName: "comment" */ './Comment.vue'),
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
},
})
export default class Comment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
@Prop({ required: true, type: Object }) event!: IEvent;
$refs!: Refs<{
commenteditor: EditorComponent,
}>;
// 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 };
currentActor!: IPerson;
newComment: IComment = new CommentModel();
replyTo: boolean = false;
showReplies: boolean = false;
timeAgoInstance = null;
replyTo = false;
showReplies = false;
timeAgoInstance: TimeAgo | null = null;
CommentModeration = CommentModeration;
async mounted() {
@ -140,7 +164,7 @@ export default class Comment extends Vue {
TimeAgo.addLocale(locale);
this.timeAgoInstance = new TimeAgo(localeName);
const hash = this.$route.hash;
const { hash } = this.$route;
if (hash.includes(`#comment-${this.comment.uuid}`)) {
this.fetchReplies();
}
@ -156,15 +180,14 @@ export default class Comment extends Vue {
// this.newComment.inReplyToComment = comment;
await this.$nextTick();
await this.$nextTick(); // For some reason commenteditor needs two $nextTick() to fully render
const commentEditor = this.$refs.commenteditor;
commentEditor.replyToComment(comment);
this.commentEditor.replyToComment(comment);
}
replyToComment() {
this.newComment.inReplyToComment = this.comment;
this.newComment.originComment = this.comment.originComment || this.comment;
this.newComment.actor = this.currentActor;
this.$emit('create-comment', this.newComment);
this.$emit("create-comment", this.newComment);
this.newComment = new CommentModel();
this.replyTo = false;
}
@ -188,7 +211,7 @@ 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;
@ -201,12 +224,11 @@ export default class Comment extends Vue {
this.showReplies = true;
}
timeago(dateTime): String {
timeago(dateTime: Date): string {
if (this.timeAgoInstance != null) {
// @ts-ignore
return this.timeAgoInstance.format(dateTime);
}
return '';
return "";
}
get commentSelected(): boolean {
@ -214,15 +236,20 @@ export default class Comment extends Vue {
}
get commentFromOrganizer(): boolean {
return this.event.organizerActor !== undefined && this.comment.actor && this.comment.actor.id === this.event.organizerActor.id;
return (
this.event.organizerActor !== undefined &&
this.comment.actor &&
this.comment.actor.id === this.event.organizerActor.id
);
}
get commentId(): String {
if (this.comment.originComment) return `#comment-${this.comment.originComment.uuid}:${this.comment.uuid}`;
get commentId(): string {
if (this.comment.originComment)
return `#comment-${this.comment.originComment.uuid}:${this.comment.uuid}`;
return `#comment-${this.comment.uuid}`;
}
get commentURL(): String {
get commentURL(): string {
if (!this.comment.local && this.comment.url) return this.comment.url;
return this.commentId;
}
@ -232,7 +259,7 @@ export default class Comment extends Vue {
parent: this,
component: ReportModal,
props: {
title: this.$t('Report this comment'),
title: this.$t("Report this comment"),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
@ -240,7 +267,7 @@ export default class Comment extends Vue {
});
}
async reportComment(content: String, forward: boolean) {
async reportComment(content: string, forward: boolean) {
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
@ -254,9 +281,11 @@ export default class Comment extends Vue {
},
});
this.$buefy.notification.open({
message: this.$t('Comment from @{username} reported', { username: this.comment.actor.preferredUsername }) as string,
type: 'is-success',
position: 'is-bottom-right',
message: this.$t("Comment from @{username} reported", {
username: this.comment.actor.preferredUsername,
}) as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
} catch (error) {
@ -266,15 +295,19 @@ export default class Comment extends Vue {
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
@import "@/variables.scss";
.first-line {
form.reply {
padding-bottom: 1rem;
}
.first-line {
* {
padding: 0 5px 0 0;
}
}
}
.editor-line {
.editor-line {
display: flex;
max-width: calc(80rem - 64px);
@ -283,18 +316,17 @@ export default class Comment extends Vue {
padding-right: 10px;
margin-bottom: 0;
}
}
}
.comment-link small:hover {
.comment-link small:hover {
color: hsl(0, 0%, 21%);
}
}
.root-comment .comment-replies > .reply {
.root-comment .comment-replies > .reply {
padding-left: 3rem;
}
.media .media-content {
}
.media .media-content {
.content .editor-line {
display: flex;
align-items: center;
@ -303,18 +335,23 @@ export default class Comment extends Vue {
.icons {
display: none;
}
}
}
.media:hover .media-content .icons {
.media:hover .media-content .icons {
display: inline;
cursor: pointer;
}
.load-replies {
button {
cursor: pointer;
border: none;
background: none;
}
}
article {
.load-replies {
cursor: pointer;
}
article {
border-radius: 4px;
&.selected {
@ -323,36 +360,40 @@ export default class Comment extends Vue {
&.organizer:not(.selected) {
background-color: lighten($primary, 50%);
}
}
}
.comment-replies-enter-active,
.comment-replies-leave-active,
.comment-replies-move {
.comment-replies-enter-active,
.comment-replies-leave-active,
.comment-replies-move {
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
transition-property: opacity, transform;
}
}
.comment-replies-enter {
.comment-replies-enter {
opacity: 0;
transform: translateX(50px) scaleY(0.5);
}
}
.comment-replies-enter-to {
.comment-replies-enter-to {
opacity: 1;
transform: translateX(0) scaleY(1);
}
}
.comment-replies-leave-active {
.comment-replies-leave-active {
position: absolute;
}
}
.comment-replies-leave-to {
.comment-replies-leave-to {
opacity: 0;
transform: scaleY(0);
transform-origin: center top;
}
}
.reply-action .icon {
.reply-action .icon {
padding-right: 0.4rem;
}
}
.visually-hidden {
display: none;
}
</style>

View File

@ -1,7 +1,11 @@
<template>
<div class="columns">
<div class="column is-two-thirds-desktop">
<form class="new-comment" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED" @submit.prevent="createCommentForEvent(newComment)" @keyup.ctrl.enter="createCommentForEvent(newComment)">
<div>
<form
class="new-comment"
v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED"
@submit.prevent="createCommentForEvent(newComment)"
@keyup.ctrl.enter="createCommentForEvent(newComment)"
>
<article class="media">
<figure class="media-left">
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
@ -13,48 +17,50 @@
</p>
</div>
<div class="send-comment">
<b-button native-type="submit" type="is-info">{{ $t('Post a comment') }}</b-button>
<b-button native-type="submit" type="is-info">{{ $t("Post a comment") }}</b-button>
</div>
</div>
</article>
</form>
<b-notification v-else-if="event.options.commentModeration === CommentModeration.CLOSED" :closable="false">
{{ $t('Comments have been closed.') }}
</b-notification>
<b-notification
v-else-if="event.options.commentModeration === CommentModeration.CLOSED"
:closable="false"
>{{ $t("Comments have been closed.") }}</b-notification
>
<transition name="comment-empty-list" mode="out-in">
<transition-group name="comment-list" v-if="comments.length" class="comment-list" tag="ul">
<comment
class="root-comment"
:comment="comment"
:event="event"
v-for="comment in orderedComments"
v-if="!comment.deletedAt || comment.totalReplies > 0"
v-for="comment in filteredOrderedComments"
:key="comment.id"
@create-comment="createCommentForEvent"
@delete-comment="deleteComment"
/>
</transition-group>
<div v-else class="no-comments">
<span>{{ $t('No comments yet') }}</span>
<img src="../../assets/undraw_just_saying.svg" alt="" />
<span>{{ $t("No comments yet") }}</span>
<img src="../../assets/undraw_just_saying.svg" alt />
</div>
</transition>
</div>
</div>
</template>
<script lang="ts">
import { Prop, Vue, Component, Watch } from 'vue-property-decorator';
import { CommentModel, IComment } from '@/types/comment.model';
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
import Comment from "@/components/Comment/Comment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { CommentModel, IComment } from "../../types/comment.model";
import {
CREATE_COMMENT_FROM_EVENT,
DELETE_COMMENT, COMMENTS_THREADS, FETCH_THREAD_REPLIES,
} from '@/graphql/comment';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import Comment from '@/components/Comment/Comment.vue';
import { IEvent, CommentModeration } from '@/types/event.model';
import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
DELETE_COMMENT,
COMMENTS_THREADS,
FETCH_THREAD_REPLIES,
} from "../../graphql/comment";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { IEvent, CommentModeration } from "../../types/event.model";
@Component({
apollo: {
@ -69,7 +75,7 @@ import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
};
},
update(data) {
return data.event.comments.map((comment) => new CommentModel(comment));
return data.event.comments.map((comment: IComment) => new CommentModel(comment));
},
skip() {
return !this.event.uuid;
@ -79,18 +85,21 @@ import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
components: {
Comment,
IdentityPickerWrapper,
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class CommentTree extends Vue {
@Prop({ required: false, type: Object }) event!: IEvent;
newComment: IComment = new CommentModel();
currentActor!: IPerson;
comments: IComment[] = [];
CommentModeration = CommentModeration;
@Watch('currentActor')
@Watch("currentActor")
watchCurrentActor(currentActor: IPerson) {
this.newComment.actor = currentActor;
}
@ -123,10 +132,13 @@ export default class CommentTree extends Vue {
const { event } = commentThreadsData;
const { comments: oldComments } = event;
// if it's no a root comment, we first need to find existing replies and add the new reply to it
if (comment.originComment) {
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
// if it's no a root comment, we first need to find
// existing replies and add the new reply to it
if (comment.originComment !== undefined) {
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
let oldReplyList: IComment[] = [];
@ -204,15 +216,15 @@ export default class CommentTree extends Vue {
if (comment.originComment) {
// we have deleted a reply to a thread
const data = store.readQuery<{ thread: IComment[] }>({
const localData = store.readQuery<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: comment.originComment.id,
},
});
if (!data) return;
const { thread: oldReplyList } = data;
const replies = oldReplyList.filter(reply => reply.id !== deletedCommentId);
if (!localData) return;
const { thread: oldReplyList } = localData;
const replies = oldReplyList.filter((reply) => reply.id !== deletedCommentId);
store.writeQuery({
query: FETCH_THREAD_REPLIES,
variables: {
@ -221,8 +233,11 @@ export default class CommentTree extends Vue {
data: { thread: replies },
});
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;
parentComment.totalReplies -= 1;
@ -230,7 +245,7 @@ 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,
@ -245,18 +260,26 @@ export default class CommentTree extends Vue {
}
get orderedComments(): IComment[] {
return this.comments.filter((comment => comment.inReplyToComment == null)).sort((a, b) => {
return this.comments
.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);
}
}
</script>
<style lang="scss" scoped>
.new-comment {
form.new-comment {
padding-bottom: 1rem;
.media-content {
display: flex;
align-items: center;
@ -268,9 +291,9 @@ export default class CommentTree extends Vue {
margin-bottom: 0;
}
}
}
}
.no-comments {
.no-comments {
display: flex;
flex-direction: column;
@ -283,46 +306,46 @@ export default class CommentTree extends Vue {
max-width: 250px;
align-self: center;
}
}
}
ul.comment-list li {
ul.comment-list li {
margin-bottom: 16px;
}
}
.comment-list-enter-active,
.comment-list-leave-active,
.comment-list-move {
.comment-list-enter-active,
.comment-list-leave-active,
.comment-list-move {
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
transition-property: opacity, transform;
}
}
.comment-list-enter {
.comment-list-enter {
opacity: 0;
transform: translateX(50px) scaleY(0.5);
}
}
.comment-list-enter-to {
.comment-list-enter-to {
opacity: 1;
transform: translateX(0) scaleY(1);
}
}
.comment-list-leave-active,
.comment-empty-list-active {
.comment-list-leave-active,
.comment-empty-list-active {
position: absolute;
}
}
.comment-list-leave-to,
.comment-empty-list-leave-to {
.comment-list-leave-to,
.comment-empty-list-leave-to {
opacity: 0;
transform: scaleY(0);
transform-origin: center top;
}
}
/*.comment-empty-list-enter-active {*/
/* transition: opacity .5s;*/
/*}*/
/*.comment-empty-list-enter-active {*/
/* transition: opacity .5s;*/
/*}*/
/*.comment-empty-list-enter {*/
/* opacity: 0;*/
/*}*/
/*.comment-empty-list-enter {*/
/* opacity: 0;*/
/*}*/
</style>

View File

@ -0,0 +1,110 @@
<template>
<article class="comment">
<div class="avatar">
<figure class="image is-48x48" v-if="comment.actor.avatar">
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="body">
<div class="meta">
<div class="name">
<span>@{{ comment.actor.preferredUsername }}</span>
</div>
<div class="post-infos">
<span>{{ comment.updatedAt | formatDateTimeString }}</span>
</div>
</div>
<div class="description-content" v-html="comment.text"></div>
</div>
</article>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IComment } from "../../types/comment.model";
@Component
export default class ConversationComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
article.comment {
display: flex;
border-top: 1px solid #e9e9e9;
div.body {
flex: 2;
margin-bottom: 2rem;
padding-top: 1rem;
.meta {
display: flex;
align-items: center;
padding: 0 1rem 0.3em;
.name {
margin-right: auto;
flex: 1 1 auto;
overflow: hidden;
span {
color: #3c376e;
}
}
}
div.description-content {
padding: 0 1rem 0.3rem;
/deep/ h1 {
font-size: 2rem;
}
/deep/ h2 {
font-size: 1.5rem;
}
/deep/ h3 {
font-size: 1.25rem;
}
/deep/ ul {
list-style-type: disc;
}
/deep/ li {
margin: 10px auto 10px 2rem;
}
/deep/ blockquote {
border-left: 0.2em solid #333;
display: block;
padding-left: 1em;
}
/deep/ p {
margin: 10px auto;
a {
display: inline-block;
padding: 0.3rem;
background: $secondary;
color: #111;
&:empty {
display: none;
}
}
}
}
}
div.avatar {
padding-top: 1rem;
flex: 0;
}
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<router-link
class="conversation-minimalist-card-wrapper"
:to="{ name: RouteName.CONVERSATION, params: { slug: conversation.slug, id: conversation.id } }"
>
<div class="media-left">
<figure class="image is-32x32" v-if="conversation.lastComment.actor.avatar">
<img class="is-rounded" :src="conversation.lastComment.actor.avatar.url" alt />
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="title-info-wrapper">
<p class="conversation-minimalist-title">{{ conversation.title }}</p>
<div class="has-text-grey">{{ htmlTextEllipsis }}</div>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IConversation } from "../../types/conversations";
import RouteName from "../../router/name";
@Component
export default class ConversationListItem extends Vue {
@Prop({ required: true, type: Object }) conversation!: IConversation;
RouteName = RouteName;
get htmlTextEllipsis() {
const element = document.createElement("div");
element.innerHTML = this.conversation.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
return element.innerText;
}
}
</script>
<style lang="scss" scoped>
.conversation-minimalist-card-wrapper {
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.calendar-icon {
margin-right: 1rem;
}
.title-info-wrapper {
flex: 2;
.conversation-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.25rem;
font-weight: 700;
}
div.has-text-grey {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
</style>

View File

@ -1,9 +1,17 @@
<template>
<div v-if="editor">
<div class="editor" :class="{ mode_description: isDescriptionMode }" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
<editor-menu-bar v-if="isDescriptionMode" :editor="editor" v-slot="{ commands, isActive, focused }">
<div
class="editor"
:class="{ mode_description: isDescriptionMode }"
id="tiptab-editor"
:data-actor-id="currentActor && currentActor.id"
>
<editor-menu-bar
v-if="isDescriptionMode"
:editor="editor"
v-slot="{ commands, isActive, focused }"
>
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
<button
class="menubar__button"
:class="{ 'is-active': isActive.bold() }"
@ -67,11 +75,7 @@
<b-icon icon="link" />
</button>
<button
class="menubar__button"
@click="showImagePrompt(commands.image)"
type="button"
>
<button class="menubar__button" @click="showImagePrompt(commands.image)" type="button">
<b-icon icon="image" />
</button>
@ -102,32 +106,27 @@
<b-icon icon="format-quote-close" />
</button>
<button
class="menubar__button"
@click="commands.undo"
type="button"
>
<button class="menubar__button" @click="commands.undo" type="button">
<b-icon icon="undo" />
</button>
<button
class="menubar__button"
@click="commands.redo"
type="button"
>
<button class="menubar__button" @click="commands.redo" type="button">
<b-icon icon="redo" />
</button>
</div>
</editor-menu-bar>
<editor-menu-bubble v-if="isCommentMode" :editor="editor" :keep-in-bounds="true" v-slot="{ commands, isActive, menu }">
<editor-menu-bubble
v-if="isCommentMode"
:editor="editor"
:keep-in-bounds="true"
v-slot="{ commands, isActive, menu }"
>
<div
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<button
class="menububble__button"
:class="{ 'is-active': isActive.bold() }"
@ -135,6 +134,7 @@
type="button"
>
<b-icon icon="format-bold" />
<span class="visually-hidden">{{ $t("Bold") }}</span>
</button>
<button
@ -144,6 +144,7 @@
type="button"
>
<b-icon icon="format-italic" />
<span class="visually-hidden">{{ $t("Italic") }}</span>
</button>
</div>
</editor-menu-bubble>
@ -162,16 +163,14 @@
{{ actor.name }}
</div>
</template>
<div v-else class="suggestion-list__item is-empty">
{{ $t('No actors found') }}
</div>
<div v-else class="suggestion-list__item is-empty">{{ $t("No actors found") }}</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { Editor, EditorContent, EditorMenuBar, EditorMenuBubble } from 'tiptap';
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { Editor, EditorContent, EditorMenuBar, EditorMenuBubble } from "tiptap";
import {
Blockquote,
HardBreak,
@ -189,15 +188,15 @@ import {
History,
Placeholder,
Mention,
} from 'tiptap-extensions';
import tippy, { Instance } from 'tippy.js';
import { SEARCH_PERSONS } from '@/graphql/search';
import { Actor, IActor, IPerson } from '@/types/actor';
import Image from '@/components/Editor/Image';
import { UPLOAD_PICTURE } from '@/graphql/upload';
import { listenFileUpload } from '@/utils/upload';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IComment } from '@/types/comment.model';
} from "tiptap-extensions";
import tippy, { Instance } from "tippy.js";
import { SEARCH_PERSONS } from "../graphql/search";
import { Actor, IActor, IPerson } from "../types/actor";
import Image from "./Editor/Image";
import { UPLOAD_PICTURE } from "../graphql/upload";
import { listenFileUpload } from "../utils/upload";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IComment } from "../types/comment.model";
@Component({
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
@ -209,39 +208,48 @@ import { IComment } from '@/types/comment.model';
})
export default class EditorComponent extends Vue {
@Prop({ required: true }) value!: string;
@Prop({ required: false, default: 'description' }) mode!: string;
@Prop({ required: false, default: "description" }) mode!: string;
currentActor!: IPerson;
editor: Editor|null = null;
editor: Editor | null = null;
/**
* Editor Suggestions
*/
query!: string|null;
query!: string | null;
filteredActors: IActor[] = [];
suggestionRange!: object|null;
navigatedActorIndex: number = 0;
popup!: Instance|null;
suggestionRange!: object | null;
navigatedActorIndex = 0;
popup!: Instance | null;
get isDescriptionMode() {
return this.mode === 'description';
return this.mode === "description";
}
get isCommentMode() {
return this.mode === 'comment';
return this.mode === "comment";
}
get hasResults() {
return this.filteredActors.length;
}
get showSuggestions() {
return this.query || this.hasResults;
}
insertMention: Function = () => {};
observer!: MutationObserver|null;
// eslint-disable-next-line
insertMention(obj: { range: any; attrs: any }) {
console.log("initialize Mention");
}
observer!: MutationObserver | null;
mounted() {
this.editor = new Editor({
@ -252,7 +260,19 @@ export default class EditorComponent extends Vue {
new Heading({ levels: [1, 2, 3] }),
new Mention({
items: () => [],
onEnter: ({ items, query, range, command, virtualNode }) => {
onEnter: ({
items,
query,
range,
command,
virtualNode,
}: {
items: any;
query: any;
range: any;
command: any;
virtualNode: any;
}) => {
this.query = query;
this.filteredActors = items;
this.suggestionRange = range;
@ -265,7 +285,17 @@ export default class EditorComponent extends Vue {
/**
* is called when a suggestion has changed
*/
onChange: ({ items, query, range, virtualNode }) => {
onChange: ({
items,
query,
range,
virtualNode,
}: {
items: any;
query: any;
range: any;
virtualNode: any;
}) => {
this.query = query;
this.filteredActors = items;
this.suggestionRange = range;
@ -288,7 +318,7 @@ export default class EditorComponent extends Vue {
/**
* is called on every keyDown event while a suggestion is active
*/
onKeyDown: ({ event }) => {
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler();
@ -306,7 +336,7 @@ export default class EditorComponent extends Vue {
}
return false;
},
onFilter: async (items, query) => {
onFilter: async (items: any, query: string) => {
if (!query) {
return items;
}
@ -332,20 +362,20 @@ export default class EditorComponent extends Vue {
new Underline(),
new History(),
new Placeholder({
emptyClass: 'is-empty',
emptyNodeText: this.$t('Write something…'),
emptyEditorClass: "is-empty",
emptyNodeText: this.$t("Write something…") as string,
showOnlyWhenEditable: false,
}),
new Image(),
],
onUpdate: ({ getHTML }) => {
this.$emit('input', getHTML());
onUpdate: ({ getHTML }: { getHTML: Function }) => {
this.$emit("input", getHTML());
},
});
this.editor.setContent(this.value);
}
@Watch('value')
@Watch("value")
onValueChanged(val: string) {
if (!this.editor) return;
if (val !== this.editor.getHTML()) {
@ -353,15 +383,14 @@ export default class EditorComponent extends Vue {
}
}
showLinkMenu(command, active: boolean) {
if (!this.editor) return;
showLinkMenu(command: Function, active: boolean): Function | undefined {
if (!this.editor) return undefined;
if (active) return command({ href: null });
this.$buefy.dialog.prompt({
message: this.$t('Enter the link URL') as string,
message: this.$t("Enter the link URL") as string,
inputAttrs: {
type: 'url',
type: "url",
},
// @ts-ignore https://github.com/buefy/buefy/commit/62539ac4026c8610509850a3a973fc283bac50ef#diff-02b38ee0a78d8316f075e520b3a442ae
trapFocus: true,
onConfirm: (value) => {
command({ href: value });
@ -369,10 +398,12 @@ export default class EditorComponent extends Vue {
this.editor.focus();
},
});
return undefined;
}
upHandler() {
this.navigatedActorIndex = ((this.navigatedActorIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
this.navigatedActorIndex =
(this.navigatedActorIndex + this.filteredActors.length - 1) % this.filteredActors.length;
}
/**
@ -401,7 +432,8 @@ export default class EditorComponent extends Vue {
range: this.suggestionRange,
attrs: {
id: actorModel.id,
label: actorModel.usernameWithDomain().substring(1), // usernameWithDomain returns with a @ prefix and tiptap adds one itself
// usernameWithDomain returns with a @ prefix and tiptap adds one itself
label: actorModel.usernameWithDomain().substring(1),
},
});
if (!this.editor) return;
@ -412,7 +444,10 @@ export default class EditorComponent extends Vue {
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
console.log(this.editor.commands);
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
this.editor.commands.mention({
id: actorModel.id,
label: actorModel.usernameWithDomain().substring(1),
});
this.editor.focus();
}
@ -421,22 +456,21 @@ export default class EditorComponent extends Vue {
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
* @param node
*/
renderPopup(node) {
renderPopup(node: any) {
if (this.popup) {
return;
}
this.popup = tippy(node, {
content: this.$refs.suggestions as HTMLElement,
trigger: 'mouseenter',
trigger: "mouseenter",
interactive: true,
theme: 'dark',
placement: 'top-start',
theme: "dark",
placement: "top-start",
inertia: true,
duration: [400, 200],
// @ts-ignore for some reason
showOnInit: true,
arrow: true,
arrowType: 'round',
arrowType: "round",
}) as Instance;
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
@ -467,7 +501,7 @@ export default class EditorComponent extends Vue {
* Show a file prompt, upload picture and insert it into editor
* @param command
*/
async showImagePrompt(command) {
async showImagePrompt(command: Function) {
const image = await listenFileUpload();
const { data } = await this.$apollo.mutate({
mutation: UPLOAD_PICTURE,
@ -489,13 +523,12 @@ export default class EditorComponent extends Vue {
}
</script>
<style lang="scss">
@import "@/variables.scss";
@import "@/variables.scss";
$color-black: #000;
$color-white: #eee;
.menubar {
$color-black: #000;
$color-white: #eee;
.menubar {
margin-bottom: 1rem;
transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
@ -518,9 +551,9 @@ export default class EditorComponent extends Vue {
background-color: rgba($color-black, 0.1);
}
}
}
}
.editor {
.editor {
position: relative;
p.is-empty:first-child::before {
@ -603,9 +636,9 @@ export default class EditorComponent extends Vue {
border-radius: 3px;
}
}
}
}
.menububble {
.menububble {
position: absolute;
display: flex;
z-index: 20;
@ -657,8 +690,8 @@ export default class EditorComponent extends Vue {
background: transparent;
color: $color-white;
}
}
.mention {
}
.mention {
background: rgba($color-black, 0.1);
color: rgba($color-black, 0.6);
font-size: 0.9rem;
@ -666,11 +699,11 @@ export default class EditorComponent extends Vue {
border-radius: 5px;
padding: 0.2rem 0.5rem;
white-space: nowrap;
}
.mention-suggestion {
}
.mention-suggestion {
color: rgba($color-black, 0.6);
}
.suggestion-list {
}
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-black, 0.1);
font-size: 0.8rem;
@ -694,8 +727,8 @@ export default class EditorComponent extends Vue {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
}
.tippy-tooltip.dark-theme {
background-color: $color-black;
padding: 0;
font-size: 1rem;
@ -708,18 +741,21 @@ export default class EditorComponent extends Vue {
.tippy-roundarrow {
fill: $color-black;
}
.tippy-popper[x-placement^=top] & .tippy-arrow {
.tippy-popper[x-placement^="top"] & .tippy-arrow {
border-top-color: $color-black;
}
.tippy-popper[x-placement^=bottom] & .tippy-arrow {
.tippy-popper[x-placement^="bottom"] & .tippy-arrow {
border-bottom-color: $color-black;
}
.tippy-popper[x-placement^=left] & .tippy-arrow {
.tippy-popper[x-placement^="left"] & .tippy-arrow {
border-left-color: $color-black;
}
.tippy-popper[x-placement^=right] & .tippy-arrow {
.tippy-popper[x-placement^="right"] & .tippy-arrow {
border-right-color: $color-black;
}
}
}
.visually-hidden {
display: none;
}
</style>

View File

@ -1,16 +1,21 @@
import { Node, Plugin } from 'tiptap';
import { UPLOAD_PICTURE } from '@/graphql/upload';
import { apolloProvider } from '@/vue-apollo';
import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { Node } from "tiptap";
import { UPLOAD_PICTURE } from "@/graphql/upload";
import apolloProvider from "@/vue-apollo";
import ApolloClient from "apollo-client";
import { NormalizedCacheObject } from "apollo-cache-inmemory";
import { NodeType, NodeSpec } from "prosemirror-model";
import { EditorState, Plugin, TextSelection } from "prosemirror-state";
import { DispatchFn } from "tiptap-commands";
import { EditorView } from "prosemirror-view";
/* eslint-disable class-methods-use-this */
export default class Image extends Node {
get name() {
return 'image';
return "image";
}
get schema() {
get schema(): NodeSpec {
return {
inline: true,
attrs: {
@ -22,25 +27,25 @@ export default class Image extends Node {
default: null,
},
},
group: 'inline',
group: "inline",
draggable: true,
parseDOM: [
{
tag: 'img[src]',
getAttrs: dom => ({
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'),
tag: "img[src]",
getAttrs: (dom: any) => ({
src: dom.getAttribute("src"),
title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"),
}),
},
],
toDOM: node => ['img', node.attrs],
toDOM: (node: any) => ["img", node.attrs],
};
}
commands({ type }) {
return attrs => (state, dispatch) => {
const { selection } = state;
commands({ type }: { type: NodeType }): any {
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 node = type.create(attrs);
const transaction = state.tr.insert(position, node);
@ -53,28 +58,39 @@ export default class Image extends Node {
new Plugin({
props: {
handleDOMEvents: {
async drop(view, event: DragEvent) {
if (!(event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length)) {
return;
drop(view: EditorView<any>, event: Event) {
const realEvent = event as DragEvent;
if (
!(
realEvent.dataTransfer &&
realEvent.dataTransfer.files &&
realEvent.dataTransfer.files.length
)
) {
return false;
}
const images = Array
.from(event.dataTransfer.files)
.filter((file: any) => (/image/i).test(file.type));
const images = Array.from(realEvent.dataTransfer.files).filter((file: any) =>
/image/i.test(file.type)
);
if (images.length === 0) {
return;
return false;
}
event.preventDefault();
realEvent.preventDefault();
const { schema } = view.state;
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
const client = apolloProvider.defaultClient as ApolloClient<InMemoryCache>;
const editorElem = document.getElementById('tiptab-editor');
const coordinates = view.posAtCoords({
left: realEvent.clientX,
top: realEvent.clientY,
});
if (!coordinates) return false;
const client = apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
const editorElem = document.getElementById("tiptab-editor");
const actorId = editorElem && editorElem.dataset.actorId;
for (const image of images) {
images.forEach(async (image) => {
const { data } = await client.mutate({
mutation: UPLOAD_PICTURE,
variables: {
@ -86,12 +102,12 @@ export default class Image extends Node {
const node = schema.nodes.image.create({ src: data.uploadPicture.url });
const transaction = view.state.tr.insert(coordinates.pos, node);
view.dispatch(transaction);
}
});
return true;
},
},
},
}),
];
}
}

View File

@ -2,9 +2,14 @@
<div class="address-autocomplete">
<b-field expanded>
<template slot="label">
{{ $t('Find an address') }}
<b-button v-if="!gettingLocation" size="is-small" icon-right="map-marker" @click="locateMe" />
<span v-else>{{ $t('Getting location') }}</span>
{{ $t("Find an address") }}
<b-button
v-if="!gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
/>
<span v-else>{{ $t("Getting location") }}</span>
</template>
<b-autocomplete
:data="addressData"
@ -15,21 +20,26 @@
@typing="fetchAsyncData"
icon="map-marker"
expanded
@select="updateSelected">
<template slot-scope="{option}">
@select="updateSelected"
>
<template slot-scope="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b><br />
<b>{{ option.poiInfos.name }}</b
><br />
<small>{{ option.poiInfos.alternativeName }}</small>
</template>
<template slot="empty">
<span v-if="isFetching">{{ $t('Searching') }}</span>
<span v-if="isFetching">{{ $t("Searching") }}</span>
<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', { queryText }) }}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
<span>{{
$t("You can try another search term or drag and drop the marker on the map", {
queryText,
})
}}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
</div>
</template>
</b-autocomplete>
@ -37,97 +47,109 @@
<div class="map" v-if="selected && selected.geom">
<map-leaflet
:coords="selected.geom"
:marker="{ text: [selected.poiInfos.name, selected.poiInfos.alternativeName], icon: selected.poiInfos.poiIcon.icon}"
:marker="{
text: [selected.poiInfos.name, selected.poiInfos.alternativeName],
icon: selected.poiInfos.poiIcon.icon,
}"
:updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }"
:readOnly="false"
/>
</div>
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
<!-- <div class="modal-card" style="width: auto">-->
<!-- <header class="modal-card-head">-->
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
<!-- </header>-->
<!-- <section class="modal-card-body">-->
<!-- <form>-->
<!-- <b-field :label="$t('Name')">-->
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
<!-- </b-field>-->
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
<!-- <div class="modal-card" style="width: auto">-->
<!-- <header class="modal-card-head">-->
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
<!-- </header>-->
<!-- <section class="modal-card-body">-->
<!-- <form>-->
<!-- <b-field :label="$t('Name')">-->
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Street')">-->
<!-- <b-input v-model="selected.street" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Street')">-->
<!-- <b-input v-model="selected.street" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Postal Code')">-->
<!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Postal Code')">-->
<!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Locality')">-->
<!-- <b-input v-model="selected.locality" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Locality')">-->
<!-- <b-input v-model="selected.locality" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Region')">-->
<!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Region')">-->
<!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Country')">-->
<!-- <b-input v-model="selected.country" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- </form>-->
<!-- </section>-->
<!-- <footer class="modal-card-foot">-->
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
<!-- </footer>-->
<!-- </div>-->
<!-- </b-modal>-->
<!-- <b-field :label="$t('Country')">-->
<!-- <b-input v-model="selected.country" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- </form>-->
<!-- </section>-->
<!-- <footer class="modal-card-foot">-->
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
<!-- </footer>-->
<!-- </div>-->
<!-- </b-modal>-->
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { Address, IAddress } from '@/types/address.model';
import { ADDRESS, REVERSE_GEOCODE } from '@/graphql/address';
import { Modal } from 'buefy/dist/components/dialog';
import { LatLng } from 'leaflet';
import { debounce } from 'lodash';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet";
import { debounce } from "lodash";
import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
@Component({
components: {
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
Modal,
"map-leaflet": () => import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: {
config: CONFIG,
},
})
export default class AddressAutoComplete extends Vue {
@Prop({ required: true }) value!: IAddress;
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching: boolean = false;
queryText: string = (this.value && (new Address(this.value)).fullName) || '';
addressModalActive: boolean = false;
private gettingLocation: boolean = false;
isFetching = false;
queryText: string = (this.value && new Address(this.value).fullName) || "";
addressModalActive = false;
private gettingLocation = false;
private location!: Position;
private gettingLocationError: any;
private mapDefaultZoom: number = 15;
private mapDefaultZoom = 15;
config!: IConfig;
// We put this in data because of issues like https://github.com/vuejs/vue-class-component/issues/263
fetchAsyncData!: Function;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data() {
return {
fetchAsyncData: debounce(this.asyncData, 200),
};
}
async asyncData(query: String) {
async asyncData(query: string) {
if (!query.length) {
this.addressData = [];
this.selected = new Address();
@ -142,27 +164,27 @@ export default class AddressAutoComplete extends Vue {
this.isFetching = true;
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: 'network-only',
fetchPolicy: "network-only",
variables: {
query,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.searchAddress.map(address => new Address(address));
this.addressData = result.data.searchAddress.map((address: IAddress) => new Address(address));
this.isFetching = false;
}
@Watch('config')
@Watch("config")
watchConfig(config: IConfig) {
if (!config.geocoding.autocomplete) {
// If autocomplete is disabled, we put a larger debounce value so that we don't request with incomplete address
// @ts-ignore
// If autocomplete is disabled, we put a larger debounce value
// so that we don't request with incomplete address
this.fetchAsyncData = debounce(this.asyncData, 2000);
}
}
@Watch('value')
@Watch("value")
updateEditing() {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
@ -170,10 +192,10 @@ export default class AddressAutoComplete extends Vue {
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
}
updateSelected(option) {
updateSelected(option: IAddress) {
if (option == null) return;
this.selected = option;
this.$emit('input', this.selected);
this.$emit("input", this.selected);
}
resetPopup() {
@ -185,8 +207,8 @@ export default class AddressAutoComplete extends Vue {
this.addressModalActive = true;
}
async reverseGeoCode(e: LatLng, zoom: Number) {
// If the position has been updated through autocomplete selection, no need to geocode it !
async reverseGeoCode(e: LatLng, zoom: number) {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
@ -198,58 +220,61 @@ export default class AddressAutoComplete extends Vue {
},
});
this.addressData = result.data.reverseGeocode.map(address => new Address(address));
this.addressData = result.data.reverseGeocode.map((address: IAddress) => new Address(address));
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit('input', this.selected);
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
checkCurrentPosition(e: LatLng) {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(';')[1]);
const lon = parseFloat(this.selected.geom.split(';')[0]);
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.gettingLocation = false;
this.location = await this.getLocation();
this.location = await AddressAutoComplete.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(new LatLng(this.location.coords.latitude, this.location.coords.longitude), 12);
this.reverseGeoCode(
new LatLng(this.location.coords.latitude, this.location.coords.longitude),
12
);
} catch (e) {
this.gettingLocation = false;
this.gettingLocationError = e.message;
}
}
async getLocation(): Promise<Position> {
static async getLocation(): Promise<Position> {
return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new Error('Geolocation is not available.'));
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available."));
}
navigator.geolocation.getCurrentPosition(pos => {
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
}, err => {
},
(err) => {
reject(err);
});
}
);
});
}
}
</script>
<style lang="scss">
.address-autocomplete {
.address-autocomplete {
margin-bottom: 0.75rem;
}
}
.autocomplete {
.autocomplete {
.dropdown-menu {
z-index: 2000;
}
@ -258,14 +283,14 @@ export default class AddressAutoComplete extends Vue {
opacity: 1 !important;
cursor: auto;
}
}
}
.read-only {
.read-only {
cursor: pointer;
}
}
.map {
.map {
height: 400px;
width: 100%;
}
}
</style>

View File

@ -18,7 +18,7 @@
</time>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class DateCalendarIcon extends Vue {
@ -32,19 +32,19 @@ export default class DateCalendarIcon extends Vue {
}
get month() {
return this.dateObj.toLocaleString(undefined, { month: 'short' });
return this.dateObj.toLocaleString(undefined, { month: "short" });
}
get day() {
return this.dateObj.toLocaleString(undefined, { day: 'numeric' });
return this.dateObj.toLocaleString(undefined, { day: "numeric" });
}
}
</script>
<style lang="scss" scoped>
time.datetime-container {
time.datetime-container {
background: #f6f7f8;
border: 1px solid rgba(46,62,72,.12);
border: 1px solid rgba(46, 62, 72, 0.12);
border-radius: 8px;
display: flex;
flex-direction: column;
@ -71,5 +71,5 @@ export default class DateCalendarIcon extends Vue {
line-height: 20px;
}
}
}
}
</style>

View File

@ -26,7 +26,7 @@
:max-date="maxDatetime"
v-model="dateWithTime"
:placeholder="$t('Click to select')"
:years-range="[-2,10]"
:years-range="[-2, 10]"
icon="calendar"
class="is-narrow"
/>
@ -37,15 +37,16 @@
:min-time="minDatetime"
:max-time="maxDatetime"
size="is-small"
inline>
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';
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { localeMonthNames, localeShortWeekDayNames } from "@/utils/datetime";
@Component
export default class DateTimePicker extends Vue {
@ -58,7 +59,7 @@ export default class DateTimePicker extends Vue {
/**
* What's shown besides the picker
*/
@Prop({ required: false, type: String, default: 'Datetime' }) label!: string;
@Prop({ required: false, type: String, default: "Datetime" }) label!: string;
/**
* The step for the time input
@ -78,14 +79,15 @@ export default class DateTimePicker extends Vue {
dateWithTime: Date = this.value;
localeShortWeekDayNamesProxy = localeShortWeekDayNames();
localeMonthNamesProxy = localeMonthNames();
@Watch('value')
@Watch("value")
updateValue() {
this.dateWithTime = this.value;
}
@Watch('dateWithTime')
@Watch("dateWithTime")
updateDateWithTimeWatcher() {
this.updateDateTime();
}
@ -96,21 +98,21 @@ export default class DateTimePicker extends Vue {
*
* @type {Date}
*/
this.$emit('input', this.dateWithTime);
this.$emit("input", this.dateWithTime);
}
}
</script>
<style lang="scss" scoped>
.timepicker {
.timepicker {
/deep/ .dropdown-content {
padding: 0;
}
}
}
.calendar-picker {
.calendar-picker {
/deep/ .dropdown-menu {
z-index: 200;
}
}
}
</style>

View File

@ -29,9 +29,16 @@ A simple card for an event
<template>
<router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }">
<div class="card-image">
<figure class="image is-16by9" :style="`background-image: url('${event.picture ? event.picture.url : '/img/mobilizon_default_card.png'}')`">
<figure
class="image is-16by9"
:style="`background-image: url('${
event.picture ? event.picture.url : '/img/mobilizon_default_card.png'
}')`"
>
<div class="tag-container" v-if="event.tags">
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-light">{{ tag.title }}</b-tag>
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-light">{{
tag.title
}}</b-tag>
</div>
</figure>
</div>
@ -43,7 +50,7 @@ A simple card for an event
<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>-->
<!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>-->
<span>
{{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}
</span>
@ -51,49 +58,49 @@ A simple card for an event
</div>
</div>
</div>
<!-- <div class="date-and-title">-->
<!-- <div class="date-component">-->
<!-- <date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" />-->
<!-- </div>-->
<!-- <div class="title-wrapper">-->
<!-- <h4>{{ event.title }}</h4>-->
<!-- <div class="organizer-place-wrapper has-text-grey">-->
<!-- <span>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</span>-->
<!-- ·-->
<!-- <span v-if="event.physicalAddress">-->
<!-- {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}-->
<!-- </span>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
<!-- <div v-if="event.participants.length > 0 &&-->
<!-- mergedOptions.loggedPerson &&-->
<!-- event.participants[0].actor.id === mergedOptions.loggedPerson.id">-->
<!-- <b-tag type="is-info"><translate>Organizer</translate></b-tag>-->
<!-- </div>-->
<!-- <div v-else-if="event.participants.length === 1">-->
<!-- <translate-->
<!-- :translate-params="{name: event.participants[0].actor.preferredUsername}"-->
<!-- >{name} organizes this event</translate>-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <span v-for="participant in event.participants" :key="participant.actor.uuid">-->
<!-- {{ participant.actor.preferredUsername }}-->
<!-- <span v-if="participant.role === ParticipantRole.CREATOR">(organizer)</span>,-->
<!-- &lt;!&ndash; <translate-->
<!-- :translate-params="{name: participant.actor.preferredUsername}"-->
<!-- >&nbsp;{name} is in,</translate>&ndash;&gt;-->
<!-- </span>-->
<!-- </div>-->
<!-- <div class="date-and-title">-->
<!-- <div class="date-component">-->
<!-- <date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" />-->
<!-- </div>-->
<!-- <div class="title-wrapper">-->
<!-- <h4>{{ event.title }}</h4>-->
<!-- <div class="organizer-place-wrapper has-text-grey">-->
<!-- <span>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</span>-->
<!-- ·-->
<!-- <span v-if="event.physicalAddress">-->
<!-- {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}-->
<!-- </span>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
<!-- <div v-if="event.participants.length > 0 &&-->
<!-- mergedOptions.loggedPerson &&-->
<!-- event.participants[0].actor.id === mergedOptions.loggedPerson.id">-->
<!-- <b-tag type="is-info"><translate>Organizer</translate></b-tag>-->
<!-- </div>-->
<!-- <div v-else-if="event.participants.length === 1">-->
<!-- <translate-->
<!-- :translate-params="{name: event.participants[0].actor.preferredUsername}"-->
<!-- >{name} organizes this event</translate>-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <span v-for="participant in event.participants" :key="participant.actor.uuid">-->
<!-- {{ participant.actor.preferredUsername }}-->
<!-- <span v-if="participant.role === ParticipantRole.CREATOR">(organizer)</span>,-->
<!-- &lt;!&ndash; <translate-->
<!-- :translate-params="{name: participant.actor.preferredUsername}"-->
<!-- >&nbsp;{name} is in,</translate>&ndash;&gt;-->
<!-- </span>-->
<!-- </div>-->
</router-link>
</template>
<script lang="ts">
import { IEvent, IEventCardOptions, ParticipantRole } 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 { IEvent, IEventCardOptions, ParticipantRole } 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";
@Component({
components: {
@ -102,6 +109,7 @@ import { Actor, Person } from '@/types/actor';
})
export default class EventCard extends Vue {
@Prop({ required: true }) event!: IEvent;
@Prop({ required: false }) options!: IEventCardOptions;
ParticipantRole = ParticipantRole;
@ -118,16 +126,18 @@ export default class EventCard extends Vue {
}
get actor(): Actor {
return Object.assign(new Person(), this.event.organizerActor || this.mergedOptions.organizerActor);
return Object.assign(
new Person(),
this.event.organizerActor || this.mergedOptions.organizerActor
);
}
}
</script>
<style lang="scss" scoped>
@import "../../variables";
@import "../../variables";
a.card {
a.card {
display: block;
background: $secondary;
@ -214,6 +224,5 @@ export default class EventCard extends Vue {
}
}
}
}
}
</style>

View File

@ -20,53 +20,85 @@
<template>
<span v-if="!endsOn">{{ beginsOn | formatDateTimeString(showStartTime) }}</span>
<span v-else-if="isSameDay() && showStartTime && showEndTime">
{{ $t('On {date} from {startTime} to {endTime}', {date: formatDate(beginsOn), startTime: formatTime(beginsOn), endTime: formatTime(endsOn)}) }}
{{
$t("On {date} from {startTime} to {endTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endTime: formatTime(endsOn),
})
}}
</span>
<span v-else-if="isSameDay() && !showStartTime && showEndTime">
{{ $t('On {date} ending at {endTime}', {date: formatDate(beginsOn), endTime: formatTime(endsOn)}) }}
{{
$t("On {date} ending at {endTime}", {
date: formatDate(beginsOn),
endTime: formatTime(endsOn),
})
}}
</span>
<span v-else-if="isSameDay() && showStartTime && !showEndTime">
{{ $t('On {date} starting at {startTime}', {date: formatDate(beginsOn), startTime: formatTime(beginsOn)}) }}
</span>
<span v-else-if="isSameDay()">
{{ $t('On {date}', {date: formatDate(beginsOn)}) }}
{{
$t("On {date} starting at {startTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
})
}}
</span>
<span v-else-if="isSameDay()">{{ $t("On {date}", { date: formatDate(beginsOn) }) }}</span>
<span v-else-if="endsOn && showStartTime && showEndTime">
{{ $t('From the {startDate} at {startTime} to the {endDate} at {endTime}',
{startDate: formatDate(beginsOn), startTime: formatTime(beginsOn), endDate: formatDate(endsOn), endTime: formatTime(endsOn)}) }}
{{
$t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn),
})
}}
</span>
<span v-else-if="endsOn && showStartTime">
{{ $t('From the {startDate} at {startTime} to the {endDate}',
{startDate: formatDate(beginsOn), startTime: formatTime(beginsOn), endDate: formatDate(endsOn)}) }}
{{
$t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
})
}}
</span>
<span v-else-if="endsOn">
{{ $t('From the {startDate} to the {endDate}',
{startDate: formatDate(beginsOn), endDate: formatDate(endsOn)}) }}
{{
$t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn),
endDate: formatDate(endsOn),
})
}}
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class EventFullDate extends Vue {
@Prop({ required: true }) beginsOn!: string;
@Prop({ required: false }) endsOn!: string;
@Prop({ required: false, default: true }) showStartTime!: boolean;
@Prop({ required: false, default: true }) showEndTime!: boolean;
formatDate(value) {
if (!this.$options.filters) return;
formatDate(value: Date): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateString(value);
}
formatTime(value) {
if (!this.$options.filters) return;
formatTime(value: Date): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatTimeString(value);
}
isSameDay() {
const sameDay = ((new Date(this.beginsOn)).toDateString()) === ((new Date(this.endsOn)).toDateString());
return this.endsOn && sameDay;
isSameDay(): boolean {
const sameDay = new Date(this.beginsOn).toDateString() === new Date(this.endsOn).toDateString();
return this.endsOn !== undefined && sameDay;
}
}
</script>

View File

@ -1,55 +1,3 @@
<docs>
A simple card for a participation (we should rename it)
```vue
<template>
<div>
<EventListCard
:participation="participation"
/>
</div>
</template>
<script>
export default {
data() {
return {
participation: {
event: {
title: 'Vue Styleguidist first meetup: learn the basics!',
id: 5,
uuid: 'some uuid',
beginsOn: new Date(),
organizerActor: {
preferredUsername: 'tcit',
name: 'Some Random Dude',
domain: null,
id: 4,
displayName() { return 'Some random dude' }
},
options: {
maximumAttendeeCapacity: 4
},
participantStats: {
approved: 1,
notApproved: 2
}
},
actor: {
preferredUsername: 'tcit',
name: 'Some Random Dude',
domain: null,
id: 4,
displayName() { return 'Some random dude' }
},
role: 'CREATOR',
}
}
}
}
</script>
```
</docs>
<template>
<article class="box">
<div class="columns">
@ -58,37 +6,72 @@ export default {
<div class="date-component">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }"><h2 class="title">{{participation.event.title }}</h2></router-link>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }">
<h2 class="title">{{ participation.event.title }}</h2>
</router-link>
</div>
<div class="participation-actor has-text-grey">
<span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span>
<span
v-if="
participation.event.physicalAddress && participation.event.physicalAddress.locality
"
>{{ participation.event.physicalAddress.locality }} -</span
>
<span>
<span>{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span>
<span v-if="participation.role === ParticipantRole.PARTICIPANT">{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span>
<span>
{{
$t("Organized by {name}", {
name: participation.event.organizerActor.displayName(),
})
}}
</span>
<span v-if="participation.role === ParticipantRole.PARTICIPANT">
{{ $t("Going as {name}", { name: participation.actor.displayName() }) }}
</span>
</span>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if="participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock-open" v-if="participation.event.visibility === EventVisibility.UNLISTED" />
<b-icon
icon="lock-open"
v-if="participation.event.visibility === EventVisibility.UNLISTED"
/>
<b-icon icon="lock" v-if="participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column is-narrow participant-stats">
<span v-if="participation.event.options.maximumAttendeeCapacity !== 0">
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.participant, total: participation.event.options.maximumAttendeeCapacity }) }}
<!-- <b-progress-->
<!-- v-if="participation.event.options.maximumAttendeeCapacity > 0"-->
<!-- size="is-medium"-->
<!-- :value="participation.event.participantStats.participant * 100 / participation.event.options.maximumAttendeeCapacity">-->
<!-- </b-progress>-->
{{
$t("{approved} / {total} seats", {
approved: participation.event.participantStats.participant,
total: participation.event.options.maximumAttendeeCapacity,
})
}}
</span>
<span v-else>
{{ $tc('{count} participants', participation.event.participantStats.participant, { count: 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">
<b-button type="is-text" @click="gotToWithCheck(participation, { name: RouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } })">
{{ $tc('{count} requests waiting', participation.event.participantStats.notApproved, { count: participation.event.participantStats.notApproved })}}
<span v-if="participation.event.participantStats.notApproved > 0">
<b-button
type="is-text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
params: { eventId: participation.event.uuid },
})
"
>
{{
$tc(
"{count} requests waiting",
participation.event.participantStats.notApproved,
{ count: participation.event.participantStats.notApproved }
)
}}
</b-button>
</span>
</span>
@ -96,37 +79,62 @@ export default {
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<li
v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(
participation.role
)
"
>
<b-button
type="is-text"
@click="gotToWithCheck(participation, { name: RouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } })"
@click="
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT,
params: { eventId: participation.event.uuid },
})
"
icon-left="pencil"
>{{ $t("Edit") }}</b-button
>
{{ $t('Edit') }}
</b-button>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))" @click="openDeleteEventModalWrapper">
<b-button type="is-text" icon-left="delete">
{{ $t('Delete') }}
</b-button>
<li
v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(
participation.role
)
"
@click="openDeleteEventModalWrapper"
>
<b-button type="is-text" icon-left="delete">{{ $t("Delete") }}</b-button>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<li
v-if="
![ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(
participation.role
)
"
>
<b-button
type="is-text"
@click="gotToWithCheck(participation, { name: RouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } })"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
params: { eventId: participation.event.uuid },
})
"
icon-left="account-multiple-plus"
>{{ $t("Manage participations") }}</b-button
>
{{ $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>
:to="{ name: RouteName.EVENT, params: { uuid: participation.event.uuid } }"
>{{ $t("View event page") }}</b-button
>
</li>
</ul>
</div>
@ -135,17 +143,22 @@ export default {
</template>
<script lang="ts">
import { IParticipant, ParticipantRole, EventVisibility, IEventCardOptions } from '@/types/event.model';
import { Component, Prop } from 'vue-property-decorator';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import { IPerson } from '@/types/actor';
import { mixins } from 'vue-class-component';
import ActorMixin from '@/mixins/actor';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import EventMixin from '@/mixins/event';
import { RouteName } from '@/router';
import { changeIdentity } from '@/utils/auth';
import { Route } from 'vue-router';
import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { mixins } from "vue-class-component";
import { RawLocation } from "vue-router";
import {
IParticipant,
ParticipantRole,
EventVisibility,
IEventCardOptions,
} from "../../types/event.model";
import { IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import EventMixin from "../../mixins/event";
import RouteName from "../../router/name";
import { changeIdentity } from "../../utils/auth";
const defaultOptions: IEventCardOptions = {
hideDate: true,
@ -169,15 +182,19 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
* The participation associated
*/
@Prop({ required: true }) participation!: IParticipant;
/**
* Options are merged with default options
*/
@Prop({ required: false, default: () => defaultOptions }) options!: IEventCardOptions;
@Prop({ required: false, default: () => defaultOptions })
options!: IEventCardOptions;
currentActor!: IPerson;
ParticipantRole = ParticipantRole;
EventVisibility = EventVisibility;
RouteName = RouteName;
get mergedOptions(): IEventCardOptions {
@ -191,29 +208,31 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
await this.openDeleteEventModal(this.participation.event, this.currentActor);
}
async gotToWithCheck(participation: IParticipant, route: Route) {
async gotToWithCheck(participation: IParticipant, route: RawLocation) {
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({
message: this.$t('Current identity has been changed to {identityName} in order to manage this event.', {
message: this.$t(
"Current identity has been changed to {identityName} in order to manage this event.",
{
identityName: organizer.preferredUsername,
}) as string,
type: 'is-info',
position: 'is-bottom-right',
}
) as string,
type: "is-info",
position: "is-bottom-right",
duration: 5000,
});
}
return await this.$router.push(route);
return this.$router.push(route);
}
}
</script>
<style lang="scss" scoped>
@import "../../variables";
@import "../../variables";
article.box {
article.box {
div.tag-container {
position: absolute;
top: 10px;
@ -238,7 +257,8 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
div.content {
padding: 5px;
.participation-actor span, .participant-stats span {
.participation-actor span,
.participant-stats span {
padding: 0 5px;
button {
@ -299,6 +319,5 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
}
}
}
}
}
</style>

View File

@ -1,48 +1,3 @@
<docs>
A simple card for an event
```vue
<template>
<div>
<EventListViewCard
:event="event"
/>
</div>
</template>
<script>
export default {
data() {
return {
event: {
title: 'Vue Styleguidist first meetup: learn the basics!',
id: 5,
uuid: 'some uuid',
beginsOn: new Date(),
organizerActor: {
preferredUsername: 'tcit',
name: 'Some Random Dude',
domain: null,
id: 4,
displayName() {
return 'Some random dude'
}
},
options: {
maximumAttendeeCapacity: 4
},
participantStats: {
approved: 1,
notApproved: 2
}
}
}
}
}
}
</script>
```
</docs>
<template>
<article class="box">
<div class="columns">
@ -51,12 +6,18 @@ export default {
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" />
</div>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"><h2 class="title">{{ event.title }}</h2></router-link>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }">
<h2 class="title">{{ event.title }}</h2>
</router-link>
</div>
<div class="participation-actor has-text-grey">
<span v-if="event.physicalAddress && event.physicalAddress.locality">{{ event.physicalAddress.locality }}</span>
<span v-if="event.physicalAddress && event.physicalAddress.locality">
{{ event.physicalAddress.locality }}
</span>
<span>
<span>{{ $t('Organized by {name}', { name: event.organizerActor.displayName() } ) }}</span>
<span>
{{ $t("Organized by {name}", { name: event.organizerActor.displayName() }) }}
</span>
</span>
</div>
<div class="columns">
@ -67,10 +28,19 @@ export default {
</span>
<span class="column is-narrow participant-stats">
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{ $t('{approved} / {total} seats', {approved: event.participantStats.participant, total: event.options.maximumAttendeeCapacity }) }}
{{
$t("{approved} / {total} seats", {
approved: event.participantStats.participant,
total: event.options.maximumAttendeeCapacity,
})
}}
</span>
<span v-else>
{{ $tc('{count} participants', event.participantStats.participant, { count: event.participantStats.participant })}}
{{
$tc("{count} participants", event.participantStats.participant, {
count: event.participantStats.participant,
})
}}
</span>
</span>
</div>
@ -80,17 +50,22 @@ export default {
</template>
<script lang="ts">
import { IParticipant, ParticipantRole, EventVisibility, IEventCardOptions } from '@/types/event.model';
import { Component, Prop } from 'vue-property-decorator';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import { IPerson } from '@/types/actor';
import { mixins } from 'vue-class-component';
import ActorMixin from '@/mixins/actor';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import EventMixin from '@/mixins/event';
import { RouteName } from '@/router';
import { changeIdentity } from '@/utils/auth';
import { Route } from 'vue-router';
import {
IParticipant,
ParticipantRole,
EventVisibility,
IEventCardOptions,
} from "@/types/event.model";
import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { IPerson } from "@/types/actor";
import { mixins } from "vue-class-component";
import ActorMixin from "@/mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import EventMixin from "@/mixins/event";
import { changeIdentity } from "@/utils/auth";
import { Route } from "vue-router";
import RouteName from "../../router/name";
const defaultOptions: IEventCardOptions = {
hideDate: true,
@ -114,28 +89,32 @@ export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
* The participation associated
*/
@Prop({ required: true }) event!: IParticipant;
/**
* Options are merged with default options
*/
@Prop({ required: false, default: () => defaultOptions }) options!: IEventCardOptions;
@Prop({ required: false, default: () => defaultOptions })
options!: IEventCardOptions;
currentActor!: IPerson;
ParticipantRole = ParticipantRole;
EventVisibility = EventVisibility;
RouteName = RouteName;
EventVisibility = EventVisibility;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
@import "../../variables";
@import "../../variables";
article.box {
article.box {
div.content {
padding: 5px;
.participation-actor span, .participant-stats span {
.participation-actor span,
.participant-stats span {
padding: 0 5px;
button {
@ -166,6 +145,5 @@ export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
}
}
}
}
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div>
<h2>{{ title }}</h2>
<div class="eventMetadataBlock">
<b-icon v-if="icon" :icon="icon" size="is-medium" />
<p :class="{ 'padding-left': icon }">
<slot></slot>
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class EventMetadataBlock extends Vue {
@Prop({ required: false, type: String }) icon!: string;
@Prop({ required: true, type: String }) title!: string;
}
</script>
<style lang="scss" scoped>
h2 {
font-size: 1.8rem;
font-weight: 500;
color: #f7ba30;
}
div.eventMetadataBlock {
display: flex;
align-items: center;
margin-bottom: 1.75rem;
p {
flex: 1;
&.padding-left {
padding-left: 20px;
}
}
}
</style>

View File

@ -0,0 +1,55 @@
<template>
<router-link
class="event-minimalist-card-wrapper"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<date-calendar-icon class="calendar-icon" :date="event.beginsOn" />
<div class="title-info-wrapper">
<p class="event-minimalist-title">{{ event.title }}</p>
<p v-if="event.physicalAddress" class="has-text-grey">
{{ event.physicalAddress.description }}
</p>
<p v-else>3 demandes de participation à traiter</p>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IEvent } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import RouteName from "../../router/name";
@Component({
components: {
DateCalendarIcon,
},
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
.event-minimalist-card-wrapper {
display: flex;
width: 100%;
color: initial;
align-items: flex-start;
.calendar-icon {
margin-right: 1rem;
}
.title-info-wrapper {
flex: 2;
.event-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.25rem;
font-weight: 700;
}
}
}
</style>

View File

@ -25,18 +25,26 @@ A button to set your participation
<template>
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<b-dropdown
aria-role="list"
position="is-bottom-left"
v-if="participation && participation.role === ParticipantRole.PARTICIPANT"
>
<button class="button is-success is-large" type="button" slot="trigger">
<b-icon icon="check" />
<template>
<span>{{ $t('I participate') }}</span>
<span>{{ $t("I participate") }}</span>
</template>
<b-icon icon="menu-down" />
</button>
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave" class="has-text-danger">
{{ $t('Cancel my participation…')}}
</b-dropdown-item>
<b-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
class="has-text-danger"
>{{ $t("Cancel my participation…") }}</b-dropdown-item
>
</b-dropdown>
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
@ -44,7 +52,7 @@ A button to set your participation
<button class="button is-success is-large" type="button" slot="trigger">
<b-icon icon="timer-sand-empty" />
<template>
<span>{{ $t('I participate') }}</span>
<span>{{ $t("I participate") }}</span>
</template>
<b-icon icon="menu-down" />
</button>
@ -53,22 +61,33 @@ A button to set your participation
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave" class="has-text-danger">
{{ $t('Cancel my participation request…')}}
</b-dropdown-item>
<b-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
class="has-text-danger"
>{{ $t("Cancel my participation request…") }}</b-dropdown-item
>
</b-dropdown>
<small>{{ $t('Participation requested!')}}</small><br />
<small>{{ $t('Waiting for organization team approval.')}}</small>
<small>{{ $t("Participation requested!") }}</small>
<br />
<small>{{ $t("Waiting for organization team approval.") }}</small>
</div>
<div v-else-if="participation && participation.role === ParticipantRole.REJECTED">
<span>{{ $t('Unfortunately, your participation request was rejected by the organizers.')}}</span>
<span>
{{ $t("Unfortunately, your participation request was rejected by the organizers.") }}
</span>
</div>
<b-dropdown aria-role="list" position="is-bottom-left" v-else-if="!participation && currentActor.id">
<b-dropdown
aria-role="list"
position="is-bottom-left"
v-else-if="!participation && currentActor.id"
>
<button class="button is-primary is-large" type="button" slot="trigger">
<template>
<span>{{ $t('Participate') }}</span>
<span>{{ $t("Participate") }}</span>
</template>
<b-icon icon="menu-down" />
</button>
@ -77,33 +96,59 @@ A button to set your participation
<div class="media">
<div class="media-left">
<figure class="image is-32x32" v-if="currentActor.avatar">
<img class="is-rounded" :src="currentActor.avatar.url" alt="" />
<img class="is-rounded" :src="currentActor.avatar.url" alt />
</figure>
</div>
<div class="media-content">
<span>{{ $t('as {identity}', {identity: currentActor.name || `@${currentActor.preferredUsername}` }) }}</span>
<span>
{{
$t("as {identity}", {
identity: currentActor.name || `@${currentActor.preferredUsername}`,
})
}}
</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal" v-if="identities.length > 1">
{{ $t('with another identity…')}}
</b-dropdown-item>
<b-dropdown-item
:value="false"
aria-role="listitem"
@click="joinModal"
v-if="identities.length > 1"
>{{ $t("with another identity…") }}</b-dropdown-item
>
</b-dropdown>
<b-button tag="router-link" :to="{ name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT, params: { uuid: event.uuid } }" v-else-if="!participation && hasAnonymousParticipationMethods" type="is-primary" size="is-large" native-type="button">{{ $t('Participate') }}</b-button>
<b-button tag="router-link" :to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT, params: { uuid: event.uuid } }" v-else-if="!currentActor.id" type="is-primary" size="is-large" native-type="button">{{ $t('Participate') }}</b-button>
<b-button
tag="router-link"
:to="{ name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT, params: { uuid: event.uuid } }"
v-else-if="!participation && hasAnonymousParticipationMethods"
type="is-primary"
size="is-large"
native-type="button"
>{{ $t("Participate") }}</b-button
>
<b-button
tag="router-link"
:to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT, params: { uuid: event.uuid } }"
v-else-if="!currentActor.id"
type="is-primary"
size="is-large"
native-type="button"
>{{ $t("Participate") }}</b-button
>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } 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';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { RouteName } from '@/router';
import { Component, Prop, Vue } from "vue-property-decorator";
import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } 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";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import RouteName from "../../router/name";
@Component({
apollo: {
@ -114,7 +159,8 @@ import { RouteName } from '@/router';
config: CONFIG,
identities: {
query: IDENTITIES,
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
update: ({ identities }) =>
identities ? identities.map((identity: IPerson) => new Person(identity)) : [],
skip() {
return this.currentUser.isLoggedIn === false;
},
@ -123,28 +169,33 @@ import { RouteName } from '@/router';
})
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) event!: IEvent;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
identities: IPerson[] = [];
config!: IConfig;
RouteName = RouteName;
joinEvent(actor: IPerson) {
if (this.event.joinOptions === EventJoinOptions.RESTRICTED) {
this.$emit('joinEventWithConfirmation', actor);
this.$emit("joinEventWithConfirmation", actor);
} else {
this.$emit('joinEvent', actor);
this.$emit("joinEvent", actor);
}
}
joinModal() {
this.$emit('joinModal');
this.$emit("joinModal");
}
confirmLeave() {
this.$emit('confirmLeave');
this.$emit("confirmLeave");
}
get hasAnonymousParticipationMethods(): boolean {
@ -154,7 +205,7 @@ export default class ParticipationButton extends Vue {
</script>
<style lang="scss" scoped>
.participation-button {
.participation-button {
.dropdown {
display: flex;
justify-content: flex-end;
@ -163,11 +214,11 @@ export default class ParticipationButton extends Vue {
opacity: 0.5;
}
}
}
}
.anonymousParticipationModal {
.anonymousParticipationModal {
/deep/ .animation-content {
z-index: 1;
}
}
}
</style>

View File

@ -6,10 +6,8 @@
detail-key="id"
:checked-rows.sync="checkedRows"
checkable
:is-row-checkable="row => row.role !== ParticipantRole.CREATOR"
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
default-sort="insertedAt"
default-sort-direction="asc"
:show-detail-icon="false"
:loading="this.$apollo.loading"
paginated
@ -23,49 +21,67 @@
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="page => $emit('page-change', page)"
@page-change="(page) => $emit('page-change', page)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="insertedAt" :label="$t('Date')" sortable>
<b-tag type="is-success" class="has-text-centered">{{ props.row.insertedAt | formatDateString }}<br>{{ props.row.insertedAt | formatTimeString }}</b-tag>
<b-tag type="is-success" class="has-text-centered"
>{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}</b-tag
>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" sortable v-if="showRole">
<span v-if="props.row.role === ParticipantRole.CREATOR">
{{ $t('Organizer') }}
{{ $t("Organizer") }}
</span>
<span v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ $t('Participant') }}
{{ $t("Participant") }}
</span>
</b-table-column>
<b-table-column field="actor.preferredUsername" :label="$t('Participant')" sortable>
<article class="media">
<figure class="media-left" v-if="props.row.actor.avatar">
<p class="image is-48x48">
<img :src="props.row.actor.avatar.url" alt="">
<img :src="props.row.actor.avatar.url" alt="" />
</p>
</figure>
<b-icon class="media-left" v-else-if="props.row.actor.preferredUsername === 'anonymous'" size="is-large" icon="incognito" />
<b-icon
class="media-left"
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
size="is-large"
icon="incognito"
/>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span><br />
<span class="is-size-7 has-text-grey">@{{ props.row.actor.preferredUsername }}</span>
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ props.row.actor.preferredUsername }}</span
>
</span>
<span v-else>
{{ $t('Anonymous participant') }}
{{ $t("Anonymous participant") }}
</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="metadata.message" :label="$t('Message')">
<span @click="toggleQueueDetails(props.row)" :class="{ 'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH }" v-if="props.row.metadata && props.row.metadata.message">
<span
@click="toggleQueueDetails(props.row)"
:class="{
'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
}"
v-if="props.row.metadata && props.row.metadata.message"
>
{{ props.row.metadata.message | ellipsize }}
</span>
<span v-else class="has-text-grey">
{{ $t('No message') }}
{{ $t("No message") }}
</span>
</b-table-column>
</template>
@ -74,50 +90,75 @@
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button @click="acceptParticipants(checkedRows)" type="is-success" v-if="canAcceptParticipants">
{{ $tc('No participant to approve|Approve participant|Approve {number} participants', checkedRows.length, { number: checkedRows.length }) }}
<b-button
@click="acceptParticipants(checkedRows)"
type="is-success"
v-if="canAcceptParticipants"
>
{{
$tc(
"No participant to approve|Approve participant|Approve {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button @click="refuseParticipants(checkedRows)" type="is-danger" v-if="canRefuseParticipants">
{{ $tc('No participant to reject|Reject participant|Reject {number} participants', checkedRows.length, { number: checkedRows.length }) }}
<b-button
@click="refuseParticipants(checkedRows)"
type="is-danger"
v-if="canRefuseParticipants"
>
{{
$tc(
"No participant to reject|Reject participant|Reject {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { Refs } from '@/shims-vue';
import { nl2br } from '@/utils/html';
import { asyncForEach } from '@/utils/asyncForEach';
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import { IParticipant, ParticipantRole } from "../../types/event.model";
import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach";
const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({
filters: {
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'),
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat("…"),
},
})
export default class ParticipationTable extends Vue {
@Prop({ required: true, type: Array }) data!: IParticipant[];
@Prop({ required: true, type: Number }) total!: number;
@Prop({ required: true, type: Function }) acceptParticipant;
@Prop({ required: true, type: Function }) refuseParticipant;
@Prop({ required: false, type: Boolean, default: false }) showRole;
@Prop({ required: false, type: Number, default: 20 }) perPage;
@Prop({ required: true, type: Function }) acceptParticipant!: Function;
@Prop({ required: true, type: Function }) refuseParticipant!: Function;
@Prop({ required: false, type: Boolean, default: false }) showRole!: boolean;
@Prop({ required: false, type: Number, default: 20 }) perPage!: number;
@Ref("queueTable") readonly queueTable!: any;
checkedRows: IParticipant[] = [];
MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
nl2br = nl2br;
ParticipantRole = ParticipantRole;
$refs!: Refs<{
queueTable: any,
}>;
nl2br = nl2br;
ParticipantRole = ParticipantRole;
toggleQueueDetails(row: IParticipant) {
if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return;
this.$refs.queueTable.toggleDetails(row);
this.queueTable.toggleDetails(row);
}
async acceptParticipants(participants: IParticipant[]) {
@ -138,8 +179,8 @@ export default class ParticipationTable extends Vue {
* We can accept participants if at least one of them is not approved
*/
get canAcceptParticipants(): boolean {
return this.checkedRows.some(
(participant: IParticipant) => [ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role),
return this.checkedRows.some((participant: IParticipant) =>
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role)
);
}
@ -147,18 +188,20 @@ export default class ParticipationTable extends Vue {
* We can refuse participants if at least one of them is something different than not approved
*/
get canRefuseParticipants(): boolean {
return this.checkedRows.some((participant: IParticipant) => participant.role !== ParticipantRole.REJECTED);
return this.checkedRows.some(
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
);
}
}
</script>
<style lang="scss" scoped>
.ellipsed-message {
.ellipsed-message {
cursor: pointer;
}
}
.table {
.table {
span.tag {
height: initial;
}
}
}
</style>

View File

@ -1,34 +1,11 @@
<docs>
### Tag input
A special input to manage event tags
```vue
<tag-input :value="[{ title: 'toto' }]" path="title" />
```
```vue
<template>
<tag-input v-model="tags" :data="sourceTags" path="title" />
</template>
<script>
export default {
data() {
return {
sourceTags: [{ title: 'my tag'}, { title: 'my second tag' }, { title: 'another example'}],
tags: []
}
}
}
</script>
```
</docs>
<template>
<b-field>
<template slot="label">
{{ $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')">
{{ $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')"
>
<b-icon size="is-small" icon="help-circle-outline"></b-icon>
</b-tooltip>
</template>
@ -48,9 +25,9 @@ export default {
</b-field>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { get, differenceBy } from 'lodash';
import { ITag } from '@/types/tag.model';
import { Component, Prop, Vue } from "vue-property-decorator";
import { get, differenceBy } from "lodash";
import { ITag } from "../../types/tag.model";
@Component({
computed: {
@ -59,32 +36,30 @@ import { ITag } from '@/types/tag.model';
return this.$props.value.map((tag: ITag) => tag.title);
},
set(tagStrings) {
const tagEntities = tagStrings.map((tag) => {
if (TagInput.isTag(tag)) {
const tagEntities = tagStrings.map((tag: string | ITag) => {
if (!(tag instanceof String)) {
return tag;
}
return { title: tag, slug: tag } as ITag;
});
this.$emit('input', tagEntities);
this.$emit("input", tagEntities);
},
},
},
})
export default class TagInput extends Vue {
@Prop({ required: false, default: () => [] }) data!: ITag[];
@Prop({ required: true, default: 'value' }) path!: string;
@Prop({ required: true, default: "value" }) path!: string;
@Prop({ required: true }) value!: ITag[];
filteredTags: ITag[] = [];
getFilteredTags(text) {
this.filteredTags = differenceBy(this.data, this.value, 'id').filter((option) => {
return get(option, this.path)
.toString()
.toLowerCase()
.indexOf(text.toLowerCase()) >= 0;
});
getFilteredTags(text: string) {
this.filteredTags = differenceBy(this.data, this.value, "id").filter(
(option) => get(option, this.path).toString().toLowerCase().indexOf(text.toLowerCase()) >= 0
);
}
static isTag(x: any): x is ITag {

View File

@ -3,23 +3,38 @@
<mobilizon-logo :invert="true" class="logo" />
<img src="../assets/footer.png" :alt="$t('World map')" />
<ul>
<li><a href="https://joinmobilizon.org">{{ $t('About') }}</a></li>
<li><router-link :to="{ name: RouteName.TERMS }">{{ $t('Terms') }}</router-link></li>
<li><a href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE">{{ $t('License') }}</a></li>
<li>
<a href="https://joinmobilizon.org">{{ $t("About") }}</a>
</li>
<li>
<router-link :to="{ name: RouteName.TERMS }">{{ $t("Terms") }}</router-link>
</li>
<li>
<a href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE">
{{ $t("License") }}
</a>
</li>
</ul>
<div class="content has-text-centered">
<span>{{ $t('© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks', { date: new Date().getFullYear()}) }}</span>
<span>
{{
$t(
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
{ date: new Date().getFullYear() }
)
}}
</span>
</div>
</footer>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import Logo from './Logo.vue';
import { RouteName } from '@/router';
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../router/name";
import Logo from "./Logo.vue";
@Component({
components: {
'mobilizon-logo': Logo,
"mobilizon-logo": Logo,
},
})
export default class Footer extends Vue {
@ -27,9 +42,9 @@ export default class Footer extends Vue {
}
</script>
<style lang="scss" scoped>
@import "../variables.scss";
@import "../variables.scss";
footer.footer {
footer.footer {
color: $secondary;
display: flex;
flex-direction: column;
@ -38,6 +53,7 @@ export default class Footer extends Vue {
.logo {
fill: $secondary;
flex: 1;
max-width: 300px;
}
div.content {
@ -55,5 +71,5 @@ export default class Footer extends Vue {
text-decoration-color: $secondary;
}
}
}
}
</style>

View File

@ -1,32 +1,50 @@
<template>
<div class="card">
<div class="card-image" v-if="!group.banner">
<figure class="image is-4by3">
<img src="https://picsum.photos/g/400/200/">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image" />
</figure>
</div>
<div class="card-content">
<div class="content">
<router-link :to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }">
<h2 class="title">{{ group.displayName() }}</h2>
<div class="media-content">
<router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: groupFullUsername } }"
>
<h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey">
<span v-if="member.parent.domain">{{
`@${member.parent.preferredUsername}@${member.parent.domain}`
}}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
</p>
<b-tag type="is-info">{{ member.role }}</b-tag>
</router-link>
</div>
<div>
<p>{{ group.summary }}</p>
</div>
<div class="content">
<p>{{ member.parent.summary }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Group } from '@/types/actor';
import { RouteName } from '@/router';
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember } from "@/types/actor";
import RouteName from "../../router/name";
@Component
export default class GroupCard extends Vue {
@Prop({ required: true }) group!: Group;
@Prop({ required: true }) member!: IMember;
RouteName = RouteName;
get groupFullUsername() {
if (this.member.parent.domain) {
return `${this.member.parent.preferredUsername}@${this.member.parent.domain}`;
}
return this.member.parent.preferredUsername;
}
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Pick a group") }}</p>
</header>
<section class="modal-card-body">
<div class="list is-hoverable">
<a
class="list-item"
v-for="groupMembership in groupMemberships.elements"
:class="{ 'is-active': groupMembership.parent.id === currentGroup.id }"
@click="changeCurrentGroup(groupMembership.parent)"
:key="groupMembership.id"
>
<div class="media">
<img
class="media-left image is-48x48"
v-if="groupMembership.parent.avatar"
:src="groupMembership.parent.avatar.url"
alt=""
/>
<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>
</div>
</div>
</a>
<a class="list-item" @click="changeCurrentGroup(new Group())" v-if="currentGroup.id">
<h3>{{ $t("Unset group") }}</h3>
</a>
</div>
</section>
<slot name="footer" />
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember, IPerson, Group } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Paginate } from "@/types/paginate";
@Component({
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: (data) => data.person.memberships,
skip() {
return !this.identity.id;
},
},
},
})
export default class GroupPicker extends Vue {
@Prop() value!: IGroup;
@Prop() identity!: IPerson;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
currentGroup: IGroup = this.value;
Group = Group;
changeCurrentGroup(group: IGroup) {
this.currentGroup = group;
this.$emit("input", group);
}
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<div class="group-picker">
<div
class="no-group box"
v-if="!currentGroup.id && groupMemberships.total > 0"
@click="isComponentModalActive = true"
>
<p class="is-4">{{ $t("Add a group") }}</p>
<p class="is-6 is-size-6 has-text-grey">
{{ $t("The event will show the group as organizer.") }}
</p>
</div>
<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">
<img class="image" :src="currentGroup.avatar.url" :alt="currentGroup.avatar.alt" />
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</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>
</div>
<div class="media-content" v-else>
{{ `@${currentGroup.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</b-button>
</div>
</div>
<span v-else-if="currentGroup.id" class="block" @click="isComponentModalActive = true">
<img
class="image is-48x48"
v-if="currentGroup.avatar"
:src="currentGroup.avatar.url"
:alt="currentGroup.avatar.alt"
/>
<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-6 is-size-6 has-text-grey">
{{ $t("You need to create the group before you create an event.") }}
</p>
</div>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<group-picker v-model="currentGroup" :identity.sync="identity" @input="relay" />
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IGroup, IMember, IPerson } from "../../types/actor";
import GroupPicker from "./GroupPicker.vue";
import { PERSON_MEMBERSHIPS } from "../../graphql/actor";
import { Paginate } from "../../types/paginate";
@Component({
components: { GroupPicker },
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: (data) => data.person.memberships,
skip() {
return !this.identity.id;
},
},
},
})
export default class GroupPickerWrapper extends Vue {
@Prop({ type: Object, required: true }) value!: IGroup;
@Prop({ default: true, type: Boolean }) inline!: boolean;
@Prop({ type: Object, required: true }) identity!: IPerson;
isComponentModalActive = false;
currentGroup: IGroup = this.value;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
@Watch("value")
updateCurrentGroup(value: IGroup) {
this.currentGroup = value;
}
relay(group: IGroup) {
this.currentGroup = group;
this.$emit("input", group);
this.isComponentModalActive = false;
}
}
</script>
<style lang="scss" scoped>
.group-picker {
.block,
.no-group,
.inline {
cursor: pointer;
}
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="media">
<div class="media-content">
<div class="content">
<p>
{{
$t("You have been invited by {invitedBy} to the following group:", {
invitedBy: member.invitedBy.name,
})
}}
</p>
</div>
<div class="media subfield">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image" />
</figure>
</div>
<div class="media-content">
<div class="level">
<div class="level-left">
<div class="level-item">
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: member.parent.preferredUsername },
}"
>
<h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey">
<span v-if="member.parent.domain">
{{ `@${member.parent.preferredUsername}@${member.parent.domain}` }}
</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
</p>
</router-link>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-button type="is-success" @click="$emit('accept', member.id)">
{{ $t("Accept") }}
</b-button>
</div>
<div class="level-item">
<b-button type="is-danger" @click="$emit('decline', member.id)">
{{ $t("Decline") }}
</b-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember } from "@/types/actor";
import RouteName from "../../router/name";
@Component
export default class InvitationCard extends Vue {
@Prop({ required: true }) member!: IMember;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.media:not(.subfield) {
background: lighten($primary, 40%);
padding: 10px;
}
</style>

View File

@ -1,23 +1,30 @@
<template>
<img svg-inline src="../assets/mobilizon_logo.svg" alt="Mobilizon" :class="{invert: invert}" height="40px">
<!-- <img src="../assets/mobilizon_logo.svg" alt="Mobilizon" :class="{ invert: invert }" height="40" /> -->
<MobilizonLogo />
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
import { Component, Prop, Vue } from "vue-property-decorator";
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import MobilizonLogo from "../assets/mobilizon_logo.svg";
@Component({
components: {
MobilizonLogo,
},
})
export default class Logo extends Vue {
@Prop({ type: Boolean, required: false, default: false }) invert!: boolean;
}
</script>
<style lang="scss" scoped>
@import "../variables.scss";
@import "../variables.scss";
svg {
svg {
fill: $primary;
&.invert {
fill: $secondary;
}
}
}
</style>

View File

@ -8,14 +8,14 @@
@click="clickMap"
@update:zoom="updateZoom"
>
<l-tile-layer
:url="config.maps.tiles.endpoint"
:attribution="attribution"
<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]"
@add="openPopup"
@update:latLng="updateDraggableMarkerPosition"
:draggable="!readOnly"
>
</l-tile-layer>
<v-locatecontrol :options="{icon: 'mdi mdi-map-marker'}"/>
<l-marker :lat-lng="[lat, lon]" @add="openPopup" @update:latLng="updateDraggableMarkerPosition" :draggable="!readOnly">
<l-popup v-if="popupMultiLine">
<span v-for="line in popupMultiLine" :key="line">{{ line }}<br /></span>
</l-popup>
@ -25,53 +25,68 @@
</template>
<script lang="ts">
import { Icon, LatLng, LeafletMouseEvent } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from 'vue2-leaflet';
import Vue2LeafletLocateControl from '@/components/Map/Vue2LeafletLocateControl.vue';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { Icon, LatLng, LeafletMouseEvent, LeafletEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { Component, Prop, Vue } from "vue-property-decorator";
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet";
import Vue2LeafletLocateControl from "@/components/Map/Vue2LeafletLocateControl.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
@Component({
components: { LTileLayer, LMap, LMarker, LPopup, LIcon, 'v-locatecontrol': Vue2LeafletLocateControl },
components: {
LTileLayer,
LMap,
LMarker,
LPopup,
LIcon,
"v-locatecontrol": Vue2LeafletLocateControl,
},
apollo: {
config: CONFIG,
},
})
export default class Map extends Vue {
@Prop({ type: Boolean, required: false, default: true }) readOnly!: boolean;
@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: Function, required: false, default: () => {} }) updateDraggableMarkerCallback!: Function;
@Prop({ type: Function, required: false })
updateDraggableMarkerCallback!: Function;
defaultOptions: {
zoom: Number;
height: String;
width: String;
zoom: number;
height: string;
width: string;
} = {
zoom: 15,
height: '100%',
width: '100%',
height: "100%",
width: "100%",
};
zoom = this.defaultOptions.zoom;
config!: IConfig;
/* eslint-disable */
mounted() {
// this part resolve an issue where the markers would not appear
// @ts-ignore
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
}
/* eslint-enable */
openPopup(event) {
openPopup(event: LeafletEvent) {
this.$nextTick(() => {
event.target.openPopup();
});
@ -81,8 +96,13 @@ export default class Map extends Vue {
return { ...this.defaultOptions, ...this.options };
}
get lat() { return this.$props.coords.split(';')[1]; }
get lon() { return this.$props.coords.split(';')[0]; }
get lat() {
return this.$props.coords.split(";")[1];
}
get lon() {
return this.$props.coords.split(";")[0];
}
get popupMultiLine() {
if (Array.isArray(this.marker.text)) {
@ -99,22 +119,22 @@ export default class Map extends Vue {
this.updateDraggableMarkerCallback(e, this.zoom);
}
updateZoom(zoom: Number) {
updateZoom(zoom: number) {
this.zoom = zoom;
}
get attribution() {
return this.config.maps.tiles.attribution || this.$t('© The OpenStreetMap Contributors');
return this.config.maps.tiles.attribution || this.$t("© The OpenStreetMap Contributors");
}
}
</script>
<style lang="scss" scoped>
div.map-container {
div.map-container {
height: 100%;
width: 100%;
.leaflet-map {
z-index: 20;
}
}
}
</style>

View File

@ -6,25 +6,31 @@
<script lang="ts">
/**
* Fork of https://github.com/domoritz/leaflet-locatecontrol to try to trigger location manually (not done ATM)
* Fork of https://github.com/domoritz/leaflet-locatecontrol
* to try to trigger location manually (not done ATM)
*/
import L, { DomEvent } from 'leaflet';
import { findRealParent, propsBinder } from 'vue2-leaflet';
import 'leaflet.locatecontrol';
import { Component, Prop, Vue } from 'vue-property-decorator';
import L, { DomEvent } from "leaflet";
import { findRealParent, propsBinder } from "vue2-leaflet";
import "leaflet.locatecontrol";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component({
beforeDestroy() {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
this.parentContainer.removeLayer(this);
},
})
export default class Vue2LeafletLocateControl extends Vue {
@Prop({ type: Object, default: () => { return {}; } }) options;
@Prop({ type: Object, default: () => ({}) }) options!: object;
@Prop({ type: Boolean, default: true }) visible = true;
ready: boolean = false;
ready = false;
mapObject!: any;
parentContainer: any;
mounted() {
@ -43,5 +49,5 @@ export default class Vue2LeafletLocateControl extends Vue {
</script>
<style>
@import "~leaflet.locatecontrol/dist/L.Control.Locate.css";
@import "~leaflet.locatecontrol/dist/L.Control.Locate.css";
</style>

View File

@ -1,13 +1,24 @@
<template>
<b-navbar type="is-secondary" wrapper-class="container">
<template slot="brand">
<b-navbar-item tag="router-link" :to="{ name: RouteName.HOME }"><logo /></b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.HOME }" :aria-label="$t('Home')">
<logo />
</b-navbar-item>
</template>
<template slot="start">
<b-navbar-item tag="router-link" :to="{ name: RouteName.EXPLORE }">{{ $t('Explore') }}</b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.MY_EVENTS }">{{ $t('My events') }}</b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.EXPLORE }">{{
$t("Explore")
}}</b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.MY_EVENTS }">{{
$t("My events")
}}</b-navbar-item>
<b-navbar-item tag="router-link" :to="{ name: RouteName.MY_GROUPS }">{{
$t("My groups")
}}</b-navbar-item>
<b-navbar-item tag="span">
<b-button tag="router-link" :to="{ name: RouteName.CREATE_EVENT }" type="is-success">{{ $t('Create') }}</b-button>
<b-button tag="router-link" :to="{ name: RouteName.CREATE_EVENT }" type="is-success">{{
$t("Create")
}}</b-button>
</b-navbar-item>
</template>
<template slot="end">
@ -18,56 +29,72 @@
<b-navbar-dropdown v-if="currentActor.id && currentUser.isLoggedIn" right>
<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>
<b-navbar-item tag="span" v-for="identity in identities" v-if="identities.length > 1" :active="identity.id === currentActor.id" :key="identity.id">
<!-- No identities dropdown if no identities -->
<span v-if="identities.length <= 1" />
<b-navbar-item
tag="span"
v-for="identity in identities"
v-else
:active="identity.id === currentActor.id"
:key="identity.id"
>
<span @click="setIdentity(identity)">
<div class="media-left">
<figure class="image is-32x32" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url" alt="" />
<img class="is-rounded" :src="identity.avatar.url" alt />
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="media-content">
<span>{{ identity.displayName() }}</span>
<span class="has-text-grey" v-if="identity.name">
@{{ identity.preferredUsername }}
</span>
<span class="has-text-grey" v-if="identity.name"
>@{{ identity.preferredUsername }}</span
>
</div>
</span>
<hr class="navbar-divider">
<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') }}-->
<!-- </b-navbar-item>-->
<!-- <b-navbar-item tag="router-link" :to="{ name: RouteName.CREATE_GROUP }">-->
<!-- {{ $t('Create group') }}-->
<!-- </b-navbar-item>-->
<b-navbar-item v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR" tag="router-link" :to="{ name: RouteName.ADMIN_DASHBOARD }">
{{ $t('Administration') }}
</b-navbar-item>
<b-navbar-item
v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR"
tag="router-link"
:to="{ name: RouteName.ADMIN_DASHBOARD }"
>{{ $t("Administration") }}</b-navbar-item
>
<b-navbar-item tag="span">
<span @click="logout">{{ $t('Log out') }}</span>
<span @click="logout">{{ $t("Log out") }}</span>
</b-navbar-item>
</b-navbar-dropdown>
<b-navbar-item v-else tag="div">
<div class="buttons">
<router-link class="button is-primary" v-if="config && config.registrationsOpen" :to="{ name: RouteName.REGISTER }">
<strong>{{ $t('Sign up') }}</strong>
<router-link
class="button is-primary"
v-if="config && config.registrationsOpen"
:to="{ name: RouteName.REGISTER }"
>
<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>
@ -75,18 +102,18 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { changeIdentity, logout } from '@/utils/auth';
import { CURRENT_ACTOR_CLIENT, IDENTITIES } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { ICurrentUser, ICurrentUserRole } from '@/types/current-user.model';
import Logo from '@/components/Logo.vue';
import SearchField from '@/components/SearchField.vue';
import { RouteName } from '@/router';
import { GraphQLError } from 'graphql';
import { Component, Vue, Watch } from "vue-property-decorator";
import Logo from "@/components/Logo.vue";
import { GraphQLError } from "graphql";
import { CURRENT_USER_CLIENT } from "../graphql/user";
import { changeIdentity, logout } from "../utils/auth";
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 } from "../types/current-user.model";
import SearchField from "./SearchField.vue";
import RouteName from "../router/name";
@Component({
apollo: {
@ -98,11 +125,14 @@ import { GraphQLError } from 'graphql';
},
identities: {
query: IDENTITIES,
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
update: ({ identities }) =>
identities ? identities.map((identity: IPerson) => new Person(identity)) : [],
skip() {
return this.currentUser.isLoggedIn === false;
},
error({ graphQLErrors }) { this.handleErrors(graphQLErrors); },
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
config: {
query: CONFIG,
@ -115,34 +145,45 @@ import { GraphQLError } from 'graphql';
})
export default class NavBar extends Vue {
currentActor!: IPerson;
config!: IConfig;
currentUser!: ICurrentUser;
ICurrentUserRole = ICurrentUserRole;
identities: IPerson[] = [];
RouteName = RouteName;
@Watch('currentActor')
@Watch("currentActor")
async initializeListOfIdentities() {
if (!this.currentUser.isLoggedIn) return;
const { data } = await this.$apollo.query<{ identities: IPerson[] }>({
query: IDENTITIES,
});
if (data) {
this.identities = data.identities.map(identity => new Person(identity));
this.identities = data.identities.map((identity) => new Person(identity));
// If we don't have any identities, the user has validated their account,
// is logging for the first time but didn't create an identity somehow
if (this.identities.length === 0) {
await this.$router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: this.currentUser.email, userAlreadyActivated: 'true' },
params: {
email: this.currentUser.email,
userAlreadyActivated: "true",
},
});
}
}
}
async handleErrors(errors: GraphQLError[]) {
if (errors.length > 0 && errors[0].message === 'You need to be logged-in to view your list of identities') {
if (
errors.length > 0 &&
errors[0].message === "You need to be logged-in to view your list of identities"
) {
await this.logout();
}
}
@ -150,9 +191,9 @@ export default class NavBar extends Vue {
async logout() {
await logout(this.$apollo.provider.defaultClient);
this.$buefy.notification.open({
message: this.$t('You have been disconnected') as string,
type: 'is-success',
position: 'is-bottom-right',
message: this.$t("You have been disconnected") as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
@ -161,7 +202,13 @@ export default class NavBar extends Vue {
}
async setIdentity(identity: IPerson) {
return await changeIdentity(this.$apollo.provider.defaultClient, identity);
await this.$apollo.mutate({
mutation: UPDATE_DEFAULT_ACTOR,
variables: {
preferredUsername: identity.preferredUsername,
},
});
return changeIdentity(this.$apollo.provider.defaultClient, identity);
}
}
</script>
@ -169,6 +216,10 @@ export default class NavBar extends Vue {
@import "../variables.scss";
nav {
.navbar-item svg {
height: 1.75rem;
}
.navbar-dropdown .navbar-item {
cursor: pointer;

View File

@ -1,33 +1,34 @@
<template>
<section class="container">
<h1 class="title" v-if="loading">
{{ $t('Your participation is being validated') }}
</h1>
<h1 class="title" v-if="loading">{{ $t("Your participation is being validated") }}</h1>
<div v-else>
<div v-if="failed">
<b-message :title="$t('Error while validating participation')" type="is-danger">
{{ $t('Either the participation has already been validated, either the validation token is incorrect.') }}
{{
$t(
"Either the participation has already been validated, either the validation token is incorrect."
)
}}
</b-message>
</div>
<h1 class="title" v-else>
{{ $t('Your participation has been validated') }}
</h1>
<h1 class="title" v-else>{{ $t("Your participation has been validated") }}</h1>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { IParticipant } from '@/types/event.model';
import { CONFIRM_PARTICIPATION } from '@/graphql/event';
import { confirmLocalAnonymousParticipation } from '@/services/AnonymousParticipationStorage';
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IParticipant } from "../../types/event.model";
import { CONFIRM_PARTICIPATION } from "../../graphql/event";
import { confirmLocalAnonymousParticipation } from "../../services/AnonymousParticipationStorage";
@Component
export default class ConfirmParticipation extends Vue {
@Prop({ type: String, required: true }) token!: string;
loading = true;
failed = false;
async created() {
@ -36,7 +37,9 @@ export default class ConfirmParticipation extends Vue {
async validateAction() {
try {
const { data } = await this.$apollo.mutate<{ confirmParticipation: IParticipant }>({
const { data } = await this.$apollo.mutate<{
confirmParticipation: IParticipant;
}>({
mutation: CONFIRM_PARTICIPATION,
variables: {
token: this.token,
@ -46,7 +49,10 @@ export default class ConfirmParticipation extends Vue {
if (data) {
const { confirmParticipation: participation } = data;
await confirmLocalAnonymousParticipation(participation.event.uuid);
await this.$router.replace({ name: RouteName.EVENT, params: { uuid: data.confirmParticipation.event.uuid } } );
await this.$router.replace({
name: RouteName.EVENT,
params: { uuid: data.confirmParticipation.event.uuid },
});
}
} catch (err) {
console.error(err);

View File

@ -4,24 +4,33 @@
<div class="container">
<div class="columns">
<div class="column">
<b-button type="is-primary" size="is-medium" tag="router-link" :to="{ name: RouteName.LOGIN }">{{ $t('Login on {instance}', { instance: host }) }}</b-button>
<b-button
type="is-primary"
size="is-medium"
tag="router-link"
:to="{ name: RouteName.LOGIN }"
>{{ $t("Login on {instance}", { instance: host }) }}</b-button
>
</div>
<vertical-divider :content="$t('Or')" />
<div class="column">
<subtitle>{{ $t('I have an account on another Mobilizon instance.')}}</subtitle>
<p>{{ $t('Other software may also support this.') }}</p>
<p>{{ $t('We will redirect you to your instance in order to interact with this event') }}</p>
<subtitle>{{ $t("I have an account on another Mobilizon instance.") }}</subtitle>
<p>{{ $t("Other software may also support this.") }}</p>
<p>
{{ $t("We will redirect you to your instance in order to interact with this event") }}
</p>
<form @submit.prevent="redirectToInstance">
<b-field :label="$t('Your federated identity')">
<b-field>
<b-input
expanded
autocapitalize="none" autocorrect="off"
autocapitalize="none"
autocorrect="off"
v-model="remoteActorAddress"
:placeholder="$t('profile@instance')">
</b-input>
: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>
@ -29,26 +38,28 @@
</div>
</div>
<div class="has-text-centered">
<b-button tag="a" type="is-text" @click="$router.go(-1)">
{{ $t('Back to previous page') }}
</b-button>
<b-button tag="a" type="is-text" @click="$router.go(-1)">{{
$t("Back to previous page")
}}</b-button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import VerticalDivider from '@/components/Utils/VerticalDivider.vue';
import Subtitle from '@/components/Utils/Subtitle.vue';
import { Component, Prop, Vue } from "vue-property-decorator";
import VerticalDivider from "@/components/Utils/VerticalDivider.vue";
import Subtitle from "@/components/Utils/Subtitle.vue";
import RouteName from "../../router/name";
@Component({
components: { Subtitle, VerticalDivider },
})
export default class ParticipationWithAccount extends Vue {
@Prop({ type: String, required: true }) uuid!: string;
remoteActorAddress: string = '';
remoteActorAddress = "";
RouteName = RouteName;
get host() {
@ -56,29 +67,39 @@ export default class ParticipationWithAccount extends Vue {
}
get uri(): string {
return `${window.location.origin}${this.$router.resolve({ name: RouteName.EVENT, params: { uuid: this.uuid } }).href}`;
return `${window.location.origin}${
this.$router.resolve({
name: RouteName.EVENT,
params: { uuid: this.uuid },
}).href
}`;
}
async redirectToInstance() {
let res;
const [_, host] = res = this.remoteActorAddress.split('@', 2);
const [_, host] = (res = this.remoteActorAddress.split("@", 2));
const remoteInteractionURI = await this.webFingerFetch(host, this.remoteActorAddress);
window.open(remoteInteractionURI);
}
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}`)).json());
const scheme = process.env.NODE_ENV === "production" ? "https" : "http";
const data = await (
await fetch(`${scheme}://${hostname}/.well-known/webfinger?resource=acct:${identity}`)
).json();
if (data && Array.isArray(data.links)) {
const link: { template: string } = data.links.find((link: any) => {
return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe';
});
const link: { template: string } = data.links.find(
(link: any) =>
link &&
typeof link.template === "string" &&
link.rel === "http://ostatus.org/schema/1.0/subscribe"
);
if (link && link.template.includes('{uri}')) {
return link.template.replace('{uri}', encodeURIComponent(this.uri));
if (link && link.template.includes("{uri}")) {
return link.template.replace("{uri}", encodeURIComponent(this.uri));
}
}
throw new Error('No interaction path found in webfinger data');
throw new Error("No interaction path found in webfinger data");
}
}
</script>

View File

@ -3,32 +3,50 @@
<div class="hero-body">
<div class="container">
<form @submit.prevent="joinEvent">
<p>{{ $t('This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.') }}</p>
<b-message type="is-info">{{ $t("Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.") }}</b-message>
<p>
{{
$t(
"This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation."
)
}}
</p>
<b-message type="is-info">
{{
$t(
"Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer."
)
}}
</b-message>
<b-message type="is-danger" v-if="error">{{ error }}</b-message>
<b-field :label="$t('Email')">
<b-input
type="email"
v-model="anonymousParticipation.email"
placeholder="Your email"
required>
</b-input>
required
></b-input>
</b-field>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">{{ $t("The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.") }}</p>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">
{{
$t(
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event."
)
}}
</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"
v-model="anonymousParticipation.message"
minlength="10"
:required="event.joinOptions === EventJoinOptions.RESTRICTED">
</b-input>
:required="event.joinOptions === EventJoinOptions.RESTRICTED"
></b-input>
</b-field>
<b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button>
<b-button 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>
@ -36,13 +54,19 @@
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { EventModel, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model';
import { FETCH_EVENT, JOIN_EVENT } from '@/graphql/event';
import { IConfig } from '@/types/config.model';
import { CONFIG } from '@/graphql/config';
import { addLocalUnconfirmedAnonymousParticipation } from '@/services/AnonymousParticipationStorage';
import { RouteName } from '@/router';
import { Component, Prop, Vue } from "vue-property-decorator";
import {
EventModel,
IEvent,
IParticipant,
ParticipantRole,
EventJoinOptions,
} from "@/types/event.model";
import { FETCH_EVENT, JOIN_EVENT } from "@/graphql/event";
import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config";
import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import RouteName from "../../router/name";
@Component({
apollo: {
@ -53,7 +77,9 @@ import { RouteName } from '@/router';
uuid: this.uuid,
};
},
skip() { return !this.uuid; },
skip() {
return !this.uuid;
},
update: (data) => new EventModel(data.event),
},
config: CONFIG,
@ -61,10 +87,18 @@ import { RouteName } from '@/router';
})
export default class ParticipationWithoutAccount extends Vue {
@Prop({ type: String, required: true }) uuid!: string;
anonymousParticipation: { email: String, message: String } = { email: '', message: '' };
anonymousParticipation: { email: string; message: string } = {
email: "",
message: "",
};
event!: IEvent;
config!: IConfig;
error: String|boolean = false;
error: string | boolean = false;
EventJoinOptions = EventJoinOptions;
async joinEvent() {
@ -88,7 +122,7 @@ export default class ParticipationWithoutAccount extends Vue {
if (cachedData == null) 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;
}
@ -99,17 +133,29 @@ export default class ParticipationWithoutAccount extends Vue {
event.participantStats.participant = event.participantStats.participant + 1;
}
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.event.uuid }, data: { event } });
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: this.event.uuid },
data: { event },
});
},
});
if (data && data.joinEvent.metadata.cancellationToken) {
await addLocalUnconfirmedAnonymousParticipation(this.event, data.joinEvent.metadata.cancellationToken);
return this.$router.push({ name: RouteName.EVENT, params: { uuid: this.event.uuid } });
await addLocalUnconfirmedAnonymousParticipation(
this.event,
data.joinEvent.metadata.cancellationToken
);
return this.$router.push({
name: RouteName.EVENT,
params: { uuid: this.event.uuid },
});
}
} catch (e) {
console.log(JSON.stringify(e));
if (e.message === 'GraphQL error: You are already a participant of this event') {
this.error = this.$t('This email is already registered as participant for this event') as string;
if (e.message === "GraphQL error: You are already a participant of this event") {
this.error = this.$t(
"This email is already registered as participant for this event"
) as string;
}
}
}

View File

@ -2,7 +2,7 @@
<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">
@ -10,18 +10,37 @@
<figure class="image is-128x128">
<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.', {instance: host })}}</small>
<b-tooltip type="is-dark" :label="$t('Mobilizon is a federated network. You can interact with this event from a different server.')">
<small>
{{
$t("Either on the {instance} instance or on another instance.", {
instance: host,
})
}}
</small>
<b-tooltip
type="is-dark"
:label="
$t(
'Mobilizon is a federated network. You can interact with this event from a different server.'
)
"
>
<b-icon size="is-small" icon="help-circle-outline" />
</b-tooltip>
</p>
</div>
<vertical-divider :content="$t('Or')" v-if="anonymousParticipationAllowed" />
<div class="column" v-if="anonymousParticipationAllowed && hasAnonymousEmailParticipationMethod">
<router-link :to="{ name: RouteName.EVENT_PARTICIPATE_WITHOUT_ACCOUNT }" v-if="event.local">
<div
class="column"
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" />
</figure>
@ -34,34 +53,42 @@
<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><br />
<small v-if="!event.local">{{ $t('You will be redirected to the original instance')}}</small>
<small>{{ $t("Participate using your email address") }}</small>
<br />
<small v-if="!event.local">
{{ $t("You will be redirected to the original instance") }}
</small>
</p>
</div>
</div>
<div class="has-text-centered">
<b-button tag="a" type="is-text" @click="$router.go(-1)">
{{ $t('Back to previous page') }}
</b-button>
<b-button tag="a" type="is-text" @click="$router.go(-1)">{{
$t("Back to previous page")
}}</b-button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { FETCH_EVENT } from '@/graphql/event';
import EventListCard from '@/components/Event/EventListCard.vue';
import EventListViewCard from '@/components/Event/EventListViewCard.vue';
import { EventModel, IEvent } from '@/types/event.model';
import VerticalDivider from '@/components/Utils/VerticalDivider.vue';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import Subtitle from '@/components/Utils/Subtitle.vue';
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_EVENT } from "@/graphql/event";
import EventListCard from "@/components/Event/EventListCard.vue";
import EventListViewCard from "@/components/Event/EventListViewCard.vue";
import { EventModel, IEvent } from "@/types/event.model";
import VerticalDivider from "@/components/Utils/VerticalDivider.vue";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import Subtitle from "@/components/Utils/Subtitle.vue";
import RouteName from "../../router/name";
@Component({
components: { VerticalDivider, EventListViewCard, EventListCard, Subtitle },
components: {
VerticalDivider,
EventListViewCard,
EventListCard,
Subtitle,
},
apollo: {
event: {
query: FETCH_EVENT,
@ -70,7 +97,9 @@ import Subtitle from '@/components/Utils/Subtitle.vue';
uuid: this.uuid,
};
},
skip() { return !this.uuid; },
skip() {
return !this.uuid;
},
update: (data) => new EventModel(data.event),
},
config: CONFIG,
@ -78,8 +107,11 @@ import Subtitle from '@/components/Utils/Subtitle.vue';
})
export default class UnloggedParticipation extends Vue {
@Prop({ type: String, required: true }) uuid!: string;
RouteName = RouteName;
event!: IEvent;
config!: IConfig;
get host() {
@ -91,15 +123,17 @@ export default class UnloggedParticipation extends Vue {
}
get hasAnonymousEmailParticipationMethod(): boolean {
return this.config.anonymous.participation.allowed && this.config.anonymous.participation.validation.email.enabled;
return (
this.config.anonymous.participation.allowed &&
this.config.anonymous.participation.validation.email.enabled
);
}
}
</script>
<style lang="scss" scoped>
.column > a {
.column > a {
display: flex;
flex-direction: column;
align-items: center;
}
}
</style>

View File

@ -12,26 +12,26 @@
<b-upload @input="onFileChanged" :accept="accept">
<a class="button is-primary">
<b-icon icon="upload"></b-icon>
<span>{{ $t('Click to upload') }}</span>
<span>{{ $t("Click to upload") }}</span>
</a>
</b-upload>
</div>
</template>
<style scoped lang="scss">
.root {
.root {
display: flex;
align-items: center;
}
}
figure.image {
figure.image {
margin-right: 30px;
max-height: 200px;
max-width: 200px;
overflow: hidden;
}
}
.image-placeholder {
.image-placeholder {
background-color: grey;
width: 100%;
height: 100%;
@ -44,18 +44,29 @@
flex: 1;
color: #eee;
}
}
}
</style>
<script lang="ts">
import { Component, Model, Prop, Vue, Watch } from 'vue-property-decorator';
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, default: 'image/gif,image/png,image/jpeg,image/webp' }) accept;
@Model("change", { type: File }) readonly pictureFile!: File;
@Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" })
accept!: string;
@Prop({
type: String,
required: false,
default() {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
@Prop({ type: String, required: false, default() { return this.$t('Avatar'); } }) textFallback!: string;
return this.$t("Avatar");
},
})
textFallback!: string;
imageSrc: string | null = null;
@ -63,13 +74,13 @@ export default class PictureUpload extends Vue {
this.updatePreview(this.pictureFile);
}
@Watch('pictureFile')
@Watch("pictureFile")
onPictureFileChanged(val: File) {
this.updatePreview(val);
}
onFileChanged(file: File) {
this.$emit('change', file);
this.$emit("change", file);
this.updatePreview(file);
}

View File

@ -22,10 +22,10 @@
<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">{{ report.content }}</div>
@ -34,9 +34,9 @@
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { ActorType } from '@/types/actor';
import { Component, Prop, Vue } from "vue-property-decorator";
import { IReport } from "@/types/report.model";
import { ActorType } from "@/types/actor";
@Component
export default class ReportCard extends Vue {
@ -46,9 +46,9 @@ export default class ReportCard extends Vue {
}
</script>
<style lang="scss">
.content img.image {
.content img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
}
</style>

View File

@ -4,36 +4,37 @@
<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"/>
<div class="media-left">
<b-icon icon="alert" type="is-warning" size="is-large" />
</div>
<div class="media-content">
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="comment.actor.avatar">
<img :src="comment.actor.avatar.url" alt="Image">
<img :src="comment.actor.avatar.url" alt="Image" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content">
<div class="content">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
<br>
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
<br />
<p v-html="comment.text"></p>
</div>
</div>
</article>
</div>
<p>{{ $t('The report will be sent to the moderators of your instance. You can explain why you report this content below.') }}</p>
<p>
{{
$t(
"The report will be sent to the moderators of your instance. You can explain why you report this content below."
)
}}
</p>
<div class="control">
<b-input
@ -45,24 +46,26 @@
</div>
<div class="control" v-if="outsideDomain">
<p>{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}</p>
<b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
<p>
{{
$t(
"The content came from another server. Transfer an anonymous copy of the report?"
)
}}
</p>
<b-switch v-model="forward">{{
$t("Transfer to {outsideDomain}", { outsideDomain })
}}</b-switch>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="close">
<button class="button" ref="cancelButton" @click="close">
{{ translatedCancelText }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="confirm">
<button class="button is-primary" ref="confirmButton" @click="confirm">
{{ translatedConfirmText }}
</button>
</footer>
@ -70,8 +73,8 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IComment } from '@/types/comment.model';
import { Component, Prop, Vue } from "vue-property-decorator";
import { IComment } from "../../types/comment.model";
@Component({
mounted() {
@ -79,23 +82,30 @@ import { IComment } from '@/types/comment.model';
},
})
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title;
@Prop({ type: Object }) comment!: IComment;
@Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String }) cancelText;
@Prop({ type: String }) confirmText;
@Prop({ type: Function }) onConfirm!: Function;
isActive: boolean = false;
content: string = '';
forward: boolean = false;
@Prop({ type: String }) title!: string;
@Prop({ type: Object }) comment!: IComment;
@Prop({ type: String, default: "" }) outsideDomain!: string;
@Prop({ type: String }) cancelText!: string;
@Prop({ type: String }) confirmText!: string;
isActive = false;
content = "";
forward = false;
get translatedCancelText() {
return this.cancelText || this.$t('Cancel');
return this.cancelText || this.$t("Cancel");
}
get translatedConfirmText() {
return this.confirmText || this.$t('Send the report');
return this.confirmText || this.$t("Send the report");
}
confirm() {
@ -108,16 +118,16 @@ export default class ReportModal extends Vue {
*/
close() {
this.isActive = false;
this.$emit('close');
this.$emit("close");
}
}
</script>
<style lang="scss" scoped>
.modal-card .modal-card-foot {
.modal-card .modal-card-foot {
justify-content: flex-end;
}
}
.modal-card-body {
.modal-card-body {
.media-content {
.box {
.media {
@ -130,5 +140,5 @@ export default class ReportModal extends Vue {
margin-bottom: 2rem;
}
}
}
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<div class="resource-wrapper">
<router-link
:to="{
name: RouteName.RESOURCE_FOLDER,
params: {
path: ResourceMixin.resourcePathArray(resource),
preferredUsername: usernameWithDomain(group),
},
}"
>
<div class="preview">
<b-icon icon="folder" size="is-large" />
</div>
<div class="body">
<h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
</div>
<draggable
v-if="!inline"
class="dropzone"
v-model="list"
:sort="false"
:group="groupObject"
@change="onChange"
/>
</router-link>
<resource-dropdown
class="actions"
v-if="!inline"
@delete="$emit('delete', resource.id)"
@move="$emit('move', resource.id)"
@rename="$emit('rename', resource)"
/>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from "vue-property-decorator";
import { Route } from "vue-router";
import Draggable, { ChangeEvent } from "vuedraggable";
import { IResource } from "../../types/resource";
import RouteName from "../../router/name";
import ResourceMixin from "../../mixins/resource";
import { IGroup, usernameWithDomain } from "../../types/actor";
import ResourceDropdown from "./ResourceDropdown.vue";
import { UPDATE_RESOURCE } from "../../graphql/resources";
@Component({
components: { Draggable, ResourceDropdown },
})
export default class FolderItem extends Mixins(ResourceMixin) {
@Prop({ required: true, type: Object }) resource!: IResource;
@Prop({ required: true, type: Object }) group!: IGroup;
@Prop({ required: false, default: false }) inline!: boolean;
list = [];
groupObject: object = {
name: `folder-${this.resource.title}`,
pull: false,
put: ["resources"],
};
RouteName = RouteName;
ResourceMixin = ResourceMixin;
usernameWithDomain = usernameWithDomain;
async onChange(evt: ChangeEvent<IResource>): Promise<Route | undefined> {
console.log("into folder item");
console.log(evt);
if (evt.added && evt.added.element) {
const movedResource = evt.added.element as IResource;
const updatedResource = await this.moveResource(movedResource);
if (updatedResource && this.resource.path) {
// eslint-disable-next-line
// @ts-ignore
return this.$router.push({
name: RouteName.RESOURCE_FOLDER,
params: {
// eslint-disable-next-line
// @ts-ignore
path: ResourceMixin.resourcePathArray(this.resource),
preferredUsername: this.group.preferredUsername,
},
});
}
}
return undefined;
}
async moveResource(resource: IResource): Promise<IResource | undefined> {
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;
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.resource-wrapper {
display: flex;
flex: 1;
align-items: center;
.actions {
flex: 0;
display: block;
margin: auto 1rem auto 2rem;
cursor: pointer;
}
}
.dropzone {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
}
a {
display: flex;
font-size: 14px;
color: #444b5d;
text-decoration: none;
overflow: hidden;
flex: 1;
position: relative;
.preview {
flex: 0 0 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.body {
padding: 10px 8px 8px;
flex: 1 1 auto;
overflow: hidden;
h3 {
white-space: nowrap;
display: block;
font-weight: 500;
margin-bottom: 5px;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
}
}
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<b-dropdown aria-role="list" position="is-bottom-left">
<b-icon icon="dots-horizontal" slot="trigger" />
<b-dropdown-item aria-role="listitem" @click="$emit('rename')">
<b-icon icon="pencil" />
{{ $t("Rename") }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="$emit('move')">
<b-icon icon="folder-move" />
{{ $t("Move") }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="$emit('delete')">
<b-icon icon="delete" />
{{ $t("Delete") }}
</b-dropdown-item>
</b-dropdown>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
@Component
export default class ResourceDropdown extends Vue {}
</script>

View File

@ -0,0 +1,137 @@
<template>
<div class="resource-wrapper">
<a :href="resource.resourceUrl" target="_blank">
<div class="preview">
<div v-if="resource.type && Object.keys(mapServiceTypeToIcon).includes(resource.type)">
<b-icon :icon="mapServiceTypeToIcon[resource.type]" size="is-large" />
</div>
<div
class="preview-image"
v-else-if="resource.metadata && resource.metadata.imageRemoteUrl"
:style="`background-image: url(${resource.metadata.imageRemoteUrl})`"
/>
<div class="preview-type" v-else>
<b-icon icon="link" size="is-large" />
</div>
</div>
<div class="body">
<img
class="favicon"
v-if="resource.metadata && resource.metadata.faviconUrl"
:src="resource.metadata.faviconUrl"
/>
<h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
<span class="host" v-else>{{ urlHostname }}</span>
</div>
</a>
<resource-dropdown
class="actions"
v-if="!inline"
@delete="$emit('delete', resource.id)"
@move="$emit('move', resource.id)"
@rename="$emit('rename', resource)"
/>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IResource, mapServiceTypeToIcon } from "@/types/resource";
import ResourceDropdown from "@/components/Resource/ResourceDropdown.vue";
@Component({
components: { ResourceDropdown },
})
export default class ResourceItem extends Vue {
@Prop({ required: true, type: Object }) resource!: IResource;
@Prop({ required: false, default: false }) inline!: boolean;
list = [];
mapServiceTypeToIcon = mapServiceTypeToIcon;
get urlHostname(): string {
return new URL(this.resource.resourceUrl).hostname.replace(/^(www\.)/, "");
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.resource-wrapper {
display: flex;
flex: 1;
align-items: center;
.actions {
flex: 0;
display: block;
margin: auto 1rem auto 2rem;
cursor: pointer;
}
}
a {
display: flex;
font-size: 14px;
color: #444b5d;
text-decoration: none;
overflow: hidden;
flex: 1;
.preview {
flex: 0 0 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.preview-image {
border-radius: 4px 0 0 4px;
display: block;
margin: 0;
width: 100%;
height: 100%;
object-fit: cover;
background-size: cover;
background-position: 50%;
}
}
.body {
padding: 10px 8px 8px;
flex: 1 1 auto;
overflow: hidden;
img.favicon {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: middle;
}
h3 {
white-space: nowrap;
display: inline-block;
font-weight: 500;
margin-bottom: 5px;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
vertical-align: middle;
}
.host {
display: block;
margin-top: 5px;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@ -1,33 +1,52 @@
<template>
<b-input custom-class="searchField" icon="magnify" type="search" rounded :placeholder="defaultPlaceHolder" v-model="searchText" @keyup.native.enter="enter" />
<label>
<span class="visually-hidden">{{ defaultPlaceHolder }}</span>
<b-input
custom-class="searchField"
icon="magnify"
type="search"
rounded
:placeholder="defaultPlaceHolder"
v-model="searchText"
@keyup.native.enter="enter"
/>
</label>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../router/name";
@Component
export default class SearchField extends Vue {
@Prop({ type: String, required: false }) placeholder!: string;
searchText: string = '';
searchText = "";
enter() {
this.$router.push({ name: RouteName.SEARCH, params: { searchTerm: this.searchText } });
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchText },
});
}
get defaultPlaceHolder(): string {
// We can't use "this" inside @Prop's default value.
return this.placeholder || this.$t('Search') as string;
return this.placeholder || (this.$t("Search") as string);
}
}
</script>
<style lang="scss">
input.searchField {
label span.visually-hidden {
display: none;
}
input.searchField {
box-shadow: none;
border-color: #b5b5b5;
&::placeholder {
color: gray;
}
}
}
</style>

View File

@ -7,8 +7,8 @@
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { ISettingMenuSection } from '@/types/setting-menu.model';
import { Component, Prop, Vue } from "vue-property-decorator";
import { ISettingMenuSection } from "@/types/setting-menu.model";
@Component
export default class SettingMenuItem extends Vue {
@ -28,9 +28,9 @@ export default class SettingMenuItem extends Vue {
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
@import "@/variables.scss";
li.setting-menu-item {
li.setting-menu-item {
font-size: 1.05rem;
background-color: #fff1de;
color: $primary;
@ -46,9 +46,10 @@ export default class SettingMenuItem extends Vue {
color: inherit;
}
&:hover, &.active {
&:hover,
&.active {
cursor: pointer;
background-color: lighten(#fea72b, 10%);
}
}
}
</style>

View File

@ -8,25 +8,28 @@
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { ISettingMenuSection } from '@/types/setting-menu.model';
import SettingMenuItem from '@/components/Settings/SettingMenuItem.vue';
import { Component, Prop, Vue } from "vue-property-decorator";
import { ISettingMenuSection } from "@/types/setting-menu.model";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
@Component({
components: { SettingMenuItem },
})
export default class SettingMenuSection extends Vue {
@Prop({ required: true, type: Object }) menuSection!: ISettingMenuSection;
get sectionActive(): boolean|undefined {
return this.menuSection.items && this.menuSection.items.some((({ to }) => to && to.name === this.$route.name));
get sectionActive(): boolean | undefined {
return (
this.menuSection.items &&
this.menuSection.items.some(({ to }) => to && to.name === this.$route.name)
);
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
@import "@/variables.scss";
li {
li {
font-size: 1.3rem;
background-color: $secondary;
color: $primary;
@ -36,7 +39,8 @@ export default class SettingMenuSection extends Vue {
background-color: #fea72b;
}
a, b {
a,
b {
cursor: pointer;
margin: 5px 0;
display: block;
@ -44,5 +48,5 @@ export default class SettingMenuSection extends Vue {
color: inherit;
font-weight: 500;
}
}
}
</style>

View File

@ -4,15 +4,17 @@
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import SettingMenuSection from '@/components/Settings/SettingMenuSection.vue';
import { ISettingMenuSection } from '@/types/setting-menu.model';
import { Component, Prop, Vue } from "vue-property-decorator";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue";
import { ISettingMenuSection } from "@/types/setting-menu.model";
@Component({
components: { SettingMenuSection },
})
export default class SettingsMenu extends Vue {
@Prop({ required: true, type: Array }) menu!: ISettingMenuSection[];
get menuValue() { return this.menu; }
get menuValue() {
return this.menu;
}
}
</script>

24
js/src/components/Tag.vue Normal file
View File

@ -0,0 +1,24 @@
<template>
<span class="tag">
<span>
<slot />
</span>
</span>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component
export default class Tag extends Vue {}
</script>
<style lang="scss" scoped>
span.tag {
background: #ecebf7;
color: #8e8bae;
text-transform: uppercase;
&::before {
content: "#";
}
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<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>
<span class="details has-text-grey">
<span v-if="todo.dueDate" class="due_date">
<b-icon icon="calendar" />
{{ todo.dueDate | formatDateString }}
</span>
<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>
</span>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { ITodo } from "../../types/todos";
import RouteName from "../../router/name";
import { UPDATE_TODO } from "../../graphql/todos";
@Component
export default class Todo extends Vue {
@Prop({ required: true, type: Object }) todo!: ITodo;
RouteName = RouteName;
editMode = false;
get status(): boolean {
return this.todo.status;
}
set status(status: boolean) {
this.updateTodo({ status });
}
updateTodo(params: object) {
this.$apollo.mutate({
mutation: UPDATE_TODO,
variables: {
id: this.todo.id,
...params,
},
});
this.editMode = false;
}
}
</script>
<style lang="scss" scoped>
span.details {
margin-left: 1rem;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<div class="card" v-if="todo">
<div class="card-content">
<b-field :label="$t('Statut')">
<b-checkbox size="is-large" v-model="status" />
</b-field>
<b-field :label="$t('Title')">
<b-input v-model="title" />
</b-field>
<b-field :label="$t('Assigned to')">
<actor-auto-complete v-model="assignedTo" />
</b-field>
<b-field :label="$t('Due on')">
<b-datepicker v-model="dueDate" />
</b-field>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { debounce } from "lodash";
import { ITodo } from "../../types/todos";
import RouteName from "../../router/name";
import { UPDATE_TODO } from "../../graphql/todos";
import ActorAutoComplete from "../Account/ActorAutoComplete.vue";
import { IPerson } from "../../types/actor";
@Component({
components: { ActorAutoComplete },
})
export default class Todo extends Vue {
@Prop({ required: true, type: Object }) todo!: ITodo;
RouteName = RouteName;
editMode = false;
debounceUpdateTodo!: Function;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data() {
return {
debounceUpdateTodo: debounce(this.updateTodo, 1000),
};
}
get title(): string {
return this.todo.title;
}
set title(title: string) {
this.debounceUpdateTodo({ title });
}
get status(): boolean {
return this.todo.status;
}
set status(status: boolean) {
this.debounceUpdateTodo({ status });
}
get assignedTo(): IPerson | undefined {
return this.todo.assignedTo;
}
set assignedTo(person: IPerson | undefined) {
this.debounceUpdateTodo({ assignedToId: person ? person.id : null });
}
get dueDate(): Date | undefined {
return this.todo.dueDate;
}
set dueDate(dueDate: Date | undefined) {
this.debounceUpdateTodo({ dueDate });
}
updateTodo(params: object) {
this.$apollo.mutate({
mutation: UPDATE_TODO,
variables: {
id: this.todo.id,
...params,
},
});
this.editMode = false;
}
}
</script>

View File

@ -1,21 +1,20 @@
<template>
<h3>
<h2>
<span>
<slot />
</span>
</h3>
</h2>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Component, Vue } from "vue-property-decorator";
@Component
export default class Subtitle extends Vue {
}
export default class Subtitle extends Vue {}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
@import "@/variables.scss";
h3 {
h2 {
display: block;
margin: 15px 0 30px;
@ -23,10 +22,10 @@ export default class Subtitle extends Vue {
background: $secondary;
display: inline;
padding: 3px 8px;
color: #3A384C;
color: #3a384c;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-weight: 400;
font-size: 32px;
}
}
}
</style>

View File

@ -2,11 +2,11 @@
<div class="is-divider-vertical" :data-content="dataContent"></div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class VerticalDivider extends Vue {
@Prop({ default: 'Or' }) content;
@Prop({ default: "Or" }) content!: string;
get dataContent() {
return this.content.toLocaleUpperCase();
@ -14,9 +14,9 @@ export default class VerticalDivider extends Vue {
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
@import "@/variables.scss";
.is-divider-vertical[data-content]::after {
.is-divider-vertical[data-content]::after {
background-color: $body-background-color;
}
}
</style>

View File

@ -1,6 +1,6 @@
export const AUTH_ACCESS_TOKEN = 'auth-access-token';
export const AUTH_REFRESH_TOKEN = 'auth-refresh-token';
export const AUTH_USER_ID = 'auth-user-id';
export const AUTH_USER_EMAIL = 'auth-user-email';
export const AUTH_USER_ACTOR_ID = 'auth-user-actor-id';
export const AUTH_USER_ROLE = 'auth-user-role';
export const AUTH_ACCESS_TOKEN = "auth-access-token";
export const AUTH_REFRESH_TOKEN = "auth-refresh-token";
export const AUTH_USER_ID = "auth-user-id";
export const AUTH_USER_EMAIL = "auth-user-email";
export const AUTH_USER_ACTOR_ID = "auth-user-actor-id";
export const AUTH_USER_ROLE = "auth-user-role";

View File

@ -3,22 +3,32 @@ function parseDateTime(value: string): Date {
}
function formatDateString(value: string): string {
return parseDateTime(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return parseDateTime(value).toLocaleString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
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: boolean = true): string {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' };
function formatDateTimeString(value: string, showTime = true): string {
const options = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
if (showTime) {
options.hour = 'numeric';
options.minute = 'numeric';
options.hour = "numeric";
options.minute = "numeric";
}
return parseDateTime(value).toLocaleTimeString(undefined, options);
}
export { formatDateString, formatTimeString, formatDateTimeString };

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