Merge branch 'master' into merge-5750-in-5818

This commit is contained in:
Moriz Wahl 2023-02-03 22:10:57 +01:00
commit 60b9d577f0
96 changed files with 3765 additions and 1173 deletions

2
.nvmrc
View File

@ -1 +1 @@
v17.9.0 v19.4.0

View File

@ -105,7 +105,7 @@ Sprint retrospective
## Philosophy ## Philosophy
We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that: We practice [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that:
* developers can make contributions to other people's PRs (after checking in with them) * developers can make contributions to other people's PRs (after checking in with them)
* we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers * we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers
@ -115,7 +115,7 @@ We believe in open source contributions as a learning experience everyone is
We use pair programming sessions as a tool for knowledge sharing. We can learn a lot from each other and only by sharing what we know and overcoming challenges together can we grow as a team and truly own this project collectively. We use pair programming sessions as a tool for knowledge sharing. We can learn a lot from each other and only by sharing what we know and overcoming challenges together can we grow as a team and truly own this project collectively.
As a volunteeer you have no commitment except your own self development and your awesomeness by contributing to this free and open-source software project. Cheers to you! As a volunteer you have no commitment except your own self development and your awesomeness by contributing to this free and open-source software project. Cheers to you!
<!-- <!--
## Open-Source Bounties ## Open-Source Bounties
@ -149,3 +149,106 @@ Our Open-Source bounty program is a work-in-progress. Based on our future
experience we will make changes and improvements. So keep an eye on this experience we will make changes and improvements. So keep an eye on this
contribution guide. contribution guide.
--> -->
## Programming
### Localization
#### Quotation Marks
The following characters are different from the programming quotation mark:
`"` or `\"`
Please copy and paste the following quotes for the languages:
* de: „Dies ist ein Beispielsatz.“
* en: “This is a sample sentence.”
* See <https://grammar.collinsdictionary.com/easy-learning/when-do-you-use-quotation-marks-or-in-english>
## Docker More Closely
### Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
#### Environment Variable For Apple M1 Platform
To set the Docker platform environment variable in your terminal tab, run:
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
#### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for development
$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and contraints in Neo4j database
$ docker compose exec backend yarn prod:migrate init
# clean db
$ docker compose exec backend yarn db:reset
# seed db
$ docker compose exec backend yarn db:seed
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and contraints in Neo4j database
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
```
### Analyzing Docker Builds
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!
The `dive build` command is exactly the right one to fulfill what we are looking for.
We can use it just like the `docker build` command and get an analysis afterwards.
So, in our main folder, we use it in the following way:
```bash
# in main folder
$ dive build --target <layer-name> -t "ocelotsocialnetwork/<app-name>:local-<layer-name>" --build-arg BBUILD_DATE="<build-date>" --build-arg BBUILD_VERSION="<build-version>" --build-arg BBUILD_COMMIT="<build-commit>" <app-folder-name-or-dot>/
```
The build arguments are optional.
For the specific applications, we use them as follows.
#### Backend
##### Production For Backend
```bash
# in main folder
$ dive build --target production -t "ocelotsocialnetwork/backend:local-production" backend/
```
##### Development For Backend
```bash
# in main folder
$ dive build --target development -t "ocelotsocialnetwork/backend:local-development" backend/
```
#### Webapp
##### Production For Webapp
```bash
# in main folder
$ dive build --target production -t "ocelotsocialnetwork/webapp:local-production" webapp/
```
##### Development For Webapp
```bash
# in main folder
$ dive build --target development -t "ocelotsocialnetwork/webapp:local-development" webapp/
```

View File

@ -1,86 +0,0 @@
# Docker More Closely
## Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
### Enviroment Variable For Apple M1 Platform
To set the Docker platform environment variable in your terminal tab, run:
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for development
$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and contraints in Neo4j database
$ docker compose exec backend yarn prod:migrate init
# clean db
$ docker compose exec backend yarn db:reset
# seed db
$ docker compose exec backend yarn db:seed
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
# only once: init admin user and create indexes and contraints in Neo4j database
$ docker compose exec backend /bin/sh -c "yarn prod:migrate init"
```
## Analysing Docker Builds
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!
The `dive build` command is exactly the right one to fulfill what we are looking for.
We can use it just like the `docker build` command and get an analysis afterwards.
So, in our main folder, we use it in the following way:
```bash
# in main folder
$ dive build --target <layer-name> -t "ocelotsocialnetwork/<app-name>:local-<layer-name>" --build-arg BBUILD_DATE="<build-date>" --build-arg BBUILD_VERSION="<build-version>" --build-arg BBUILD_COMMIT="<build-commit>" <app-folder-name-or-dot>/
```
The build arguments are optional.
For the specific applications, we use them as follows.
### Backend
#### Production For Backend
```bash
# in main folder
$ dive build --target production -t "ocelotsocialnetwork/backend:local-production" backend/
```
#### Development For Backend
```bash
# in main folder
$ dive build --target development -t "ocelotsocialnetwork/backend:local-development" backend/
```
### Webapp
#### Production For Webapp
```bash
# in main folder
$ dive build --target production -t "ocelotsocialnetwork/webapp:local-production" webapp/
```
#### Development For Webapp
```bash
# in main folder
$ dive build --target development -t "ocelotsocialnetwork/webapp:local-development" webapp/
```

View File

@ -1 +1 @@
v12.19.0 v19.4.0

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE (Is pushed to DockerHub for rebranding) ################################### # BASE (Is pushed to DockerHub for rebranding) ###################################
################################################################################## ##################################################################################
FROM node:12.19.0-alpine3.10 as base FROM node:19.4.0-alpine3.17 as base
# ENVs # ENVs
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -19,12 +19,19 @@ Wait a little until your backend is up and running at [http://localhost:4000/](h
## Installation without Docker ## Installation without Docker
For the local installation you need a recent version of For the local installation you need a recent version of
[node](https://nodejs.org/en/) (&gt;= `v10.12.0`). We are using [Node](https://nodejs.org/en/) (&gt;= `v16.19.0`). We are using
`12.19.0` and therefore we recommend to use the same version `v19.4.0` and therefore we recommend to use the same version
([see](https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4082) ([see](https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4082)
some known problems with more recent node versions). You can use the some known problems with more recent node versions). You can use the
[node version manager](https://github.com/nvm-sh/nvm) to switch [node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
between different local node versions. between different local Node versions:
```bash
# install Node
$ cd backend
$ nvm install v19.4.0
$ nvm use v19.4.0
```
Install node dependencies with [yarn](https://yarnpkg.com/en/): Install node dependencies with [yarn](https://yarnpkg.com/en/):
@ -32,6 +39,10 @@ Install node dependencies with [yarn](https://yarnpkg.com/en/):
# in main folder # in main folder
$ cd backend $ cd backend
$ yarn install $ yarn install
# or just
$ yarn
# or just later on to use version of ".nvmrc" file
$ nvm use && yarn
``` ```
Copy Environment Variables: Copy Environment Variables:

View File

@ -10,4 +10,5 @@ module.exports = {
], ],
coverageReporters: ['lcov', 'text'], coverageReporters: ['lcov', 'text'],
testMatch: ['**/src/**/?(*.)+(spec|test).js?(x)'], testMatch: ['**/src/**/?(*.)+(spec|test).js?(x)'],
setupFilesAfterEnv: ['<rootDir>/test/setup.js']
} }

View File

@ -65,22 +65,22 @@
"linkifyjs": "~2.1.8", "linkifyjs": "~2.1.8",
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.8", "merge-graphql-schemas": "^1.7.8",
"metascraper": "^5.11.8", "metascraper": "^5.33.5",
"metascraper-audio": "^5.14.26", "metascraper-audio": "^5.33.5",
"metascraper-author": "^5.14.22", "metascraper-author": "^5.33.5",
"metascraper-clearbit-logo": "^5.3.0", "metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.11.8", "metascraper-date": "^5.33.5",
"metascraper-description": "^5.23.1", "metascraper-description": "^5.33.5",
"metascraper-image": "^5.11.8", "metascraper-image": "^5.33.5",
"metascraper-lang": "^5.23.1", "metascraper-lang": "^5.33.5",
"metascraper-lang-detector": "^4.10.2", "metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.14.26", "metascraper-logo": "^5.33.5",
"metascraper-publisher": "^5.23.0", "metascraper-publisher": "^5.33.5",
"metascraper-soundcloud": "^5.23.0", "metascraper-soundcloud": "^5.33.5",
"metascraper-title": "^5.11.8", "metascraper-title": "^5.33.5",
"metascraper-url": "^5.14.26", "metascraper-url": "^5.33.5",
"metascraper-video": "^5.11.8", "metascraper-video": "^5.33.5",
"metascraper-youtube": "^5.23.0", "metascraper-youtube": "^5.33.5",
"migrate": "^1.7.0", "migrate": "^1.7.0",
"mime-types": "^2.1.26", "mime-types": "^2.1.26",
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
@ -123,6 +123,8 @@
"supertest": "~4.0.2" "supertest": "~4.0.2"
}, },
"resolutions": { "resolutions": {
"fs-capacitor": "6.0.0" "**/**/fs-capacitor":"^6.2.0",
"**/graphql-upload": "^11.0.0",
"nan": "2.17.0"
} }
} }

View File

@ -85,7 +85,7 @@ class Store {
await createDefaultAdminUser(session) await createDefaultAdminUser(session)
if (CONFIG.CATEGORIES_ACTIVE) await createCategories(session) if (CONFIG.CATEGORIES_ACTIVE) await createCategories(session)
const writeTxResultPromise = session.writeTransaction(async (txc) => { const writeTxResultPromise = session.writeTransaction(async (txc) => {
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and constraints
return Promise.all( return Promise.all(
[ [
'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])',

View File

@ -10,10 +10,13 @@ const sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
const { inviteCode } = args const { inviteCode } = args
const response = await resolve(root, args, context, resolveInfo) const response = await resolve(root, args, context, resolveInfo)
const { email, nonce } = response const { email, nonce } = response
if (inviteCode) { if (nonce) {
await sendMail(signupTemplate({ email, variables: { nonce, inviteCode } })) // emails that already exist do not have a nonce
} else { if (inviteCode) {
await sendMail(signupTemplate({ email, variables: { nonce } })) await sendMail(signupTemplate({ email, variables: { nonce, inviteCode } }))
} else {
await sendMail(signupTemplate({ email, variables: { nonce } }))
}
} }
delete response.nonce delete response.nonce
return response return response
@ -30,7 +33,9 @@ const sendPasswordResetMail = async (resolve, root, args, context, resolveInfo)
const sendEmailVerificationMail = async (resolve, root, args, context, resolveInfo) => { const sendEmailVerificationMail = async (resolve, root, args, context, resolveInfo) => {
const response = await resolve(root, args, context, resolveInfo) const response = await resolve(root, args, context, resolveInfo)
const { email, nonce, name } = response const { email, nonce, name } = response
await sendMail(emailVerificationTemplate({ email, variables: { nonce, name } })) if (nonce) {
await sendMail(emailVerificationTemplate({ email, variables: { nonce, name } }))
}
delete response.nonce delete response.nonce
return response return response
} }

View File

@ -0,0 +1,7 @@
import { GraphQLUpload } from 'graphql-upload'
export default {
// This maps the `Upload` scalar to the implementation provided
// by the `graphql-upload` package.
Upload: GraphQLUpload,
}

View File

@ -40,7 +40,9 @@ export default {
} }
// check email does not belong to anybody // check email does not belong to anybody
await existingEmailAddress({ args, context }) const existingEmail = await existingEmailAddress({ args, context })
if (existingEmail && existingEmail.alreadyExistingEmail && existingEmail.user)
return existingEmail.alreadyExistingEmail
const nonce = generateNonce() const nonce = generateNonce()
const { const {

View File

@ -134,11 +134,17 @@ describe('AddEmailAddress', () => {
}) })
describe('but if another user owns an `EmailAddress` already with that email', () => { describe('but if another user owns an `EmailAddress` already with that email', () => {
it('throws UserInputError because of unique constraints', async () => { it('does not throw UserInputError', async () => {
await Factory.build('user', {}, { email: 'new-email@example.org' }) await Factory.build('user', {}, { email: 'new-email@example.org' })
await expect(mutate({ mutation, variables })).resolves.toMatchObject({ await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { AddEmailAddress: null }, data: {
errors: [{ message: 'A user account with this email already exists.' }], AddEmailAddress: {
createdAt: expect.any(String),
verifiedAt: null,
email: 'new-email@example.org',
},
},
errors: undefined,
}) })
}) })
}) })

View File

@ -96,7 +96,7 @@ describe('Query', () => {
description: null, description: null,
html: null, html: null,
image: null, image: null,
lang: null, lang: 'false',
publisher: null, publisher: null,
sources: ['resource'], sources: ['resource'],
title: null, title: null,

View File

@ -1,5 +1,3 @@
import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail({ args, context }) { export default async function alreadyExistingMail({ args, context }) {
const session = context.driver.session() const session = context.driver.session()
try { try {
@ -20,9 +18,11 @@ export default async function alreadyExistingMail({ args, context }) {
}) })
}) })
const [emailBelongsToUser] = await existingEmailAddressTxPromise const [emailBelongsToUser] = await existingEmailAddressTxPromise
const { alreadyExistingEmail, user } = emailBelongsToUser || {} /*
if (user) throw new UserInputError('A user account with this email already exists.') const { alreadyExistingEmail, user } =
return alreadyExistingEmail if (user) throw new UserInputError('A user account with this email already exists.')
*/
return emailBelongsToUser || {}
} finally { } finally {
session.close() session.close()
} }

View File

@ -113,10 +113,11 @@ const sanitizeRelationshipType = (relationshipType) => {
const localFileUpload = ({ createReadStream, uniqueFilename }) => { const localFileUpload = ({ createReadStream, uniqueFilename }) => {
const destination = `/uploads/${uniqueFilename}` const destination = `/uploads/${uniqueFilename}`
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
createReadStream() createReadStream().pipe(
.pipe(createWriteStream(`public${destination}`)) createWriteStream(`public${destination}`)
.on('finish', () => resolve(destination)) .on('finish', () => resolve(destination))
.on('error', reject), .on('error', (error) => reject(error)),
),
) )
} }

View File

@ -13,7 +13,12 @@ export default {
args.nonce = generateNonce() args.nonce = generateNonce()
args.email = normalizeEmail(args.email) args.email = normalizeEmail(args.email)
let emailAddress = await existingEmailAddress({ args, context }) let emailAddress = await existingEmailAddress({ args, context })
if (emailAddress) return emailAddress /*
if (emailAddress.user) {
// what to do?
}
*/
if (emailAddress.alreadyExistingEmail) return emailAddress.alreadyExistingEmail
try { try {
emailAddress = await neode.create('EmailAddress', args) emailAddress = await neode.create('EmailAddress', args)
return emailAddress.toJson() return emailAddress.toJson()

View File

@ -118,9 +118,9 @@ describe('Signup', () => {
await emailAddress.relateTo(user, 'belongsTo') await emailAddress.relateTo(user, 'belongsTo')
}) })
it('throws UserInputError error because of unique constraint violation', async () => { it('does not throw UserInputError error', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({ await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'A user account with this email already exists.' }], data: { Signup: { email: 'someuser@example.org' } },
}) })
}) })
}) })

View File

@ -12,6 +12,7 @@ import { RedisPubSub } from 'graphql-redis-subscriptions'
import { PubSub } from 'graphql-subscriptions' import { PubSub } from 'graphql-subscriptions'
import Redis from 'ioredis' import Redis from 'ioredis'
import bodyParser from 'body-parser' import bodyParser from 'body-parser'
import { graphqlUploadExpress } from 'graphql-upload'
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG
@ -67,6 +68,7 @@ const createServer = (options) => {
}, },
}, },
debug: !!CONFIG.DEBUG, debug: !!CONFIG.DEBUG,
uploads: false,
tracing: !!CONFIG.DEBUG, tracing: !!CONFIG.DEBUG,
formatError: (error) => { formatError: (error) => {
if (error.message === 'ERROR_VALIDATION') { if (error.message === 'ERROR_VALIDATION') {
@ -85,6 +87,7 @@ const createServer = (options) => {
app.use(express.static('public')) app.use(express.static('public'))
app.use(bodyParser.json({ limit: '10mb' })) app.use(bodyParser.json({ limit: '10mb' }))
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true })) app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }))
app.use(graphqlUploadExpress())
server.applyMiddleware({ app, path: '/' }) server.applyMiddleware({ app, path: '/' })
const httpServer = http.createServer(app) const httpServer = http.createServer(app)
server.installSubscriptionHandlers(httpServer) server.installSubscriptionHandlers(httpServer)

8
backend/test/setup.js Normal file
View File

@ -0,0 +1,8 @@
// Polyfill missing encoders in jsdom
// https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest
import { TextEncoder, TextDecoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
// Metascraper takes longer nowadays, double time
jest.setTimeout(10000)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
{ {
"projectId": "qa7fe2", "projectId": "qa7fe2",
"defaultCommandTimeout": 10000,
"ignoreTestFiles": "*.js", "ignoreTestFiles": "*.js",
"chromeWebSecurity": false, "chromeWebSecurity": false,
"baseUrl": "http://localhost:3000", "baseUrl": "http://localhost:3000",

View File

@ -13,29 +13,29 @@ Feature: User profile - list social media accounts
When I navigate to page "/settings/my-social-media" When I navigate to page "/settings/my-social-media"
Then I am on page "/settings/my-social-media" Then I am on page "/settings/my-social-media"
When I add a social media link When I add a social media link
Then I see a toaster with "Added social media" Then I see a toaster with status "success"
And the new social media link shows up on the page And the new social media link shows up on the page
Scenario: Other users viewing my Social Media Scenario: Other users viewing my Social Media
Given I have added a social media link Given I have added the social media link "https://freeradical.zone/peter-pan"
When I navigate to page "/profile/peter-pan" When I navigate to page "/profile/peter-pan"
Then they should be able to see my social media links Then they should be able to see my social media links
Scenario: Deleting Social Media Scenario: Deleting Social Media
When I navigate to page "/settings/my-social-media" When I navigate to page "/settings/my-social-media"
Then I am on page "/settings/my-social-media" Then I am on page "/settings/my-social-media"
Given I have added a social media link Given I have added the social media link "https://freeradical.zone/peter-pan"
When I delete a social media link When I delete the social media link "https://freeradical.zone/peter-pan"
Then I see a toaster with "Deleted social media" Then I see a toaster with status "success"
Scenario: Editing Social Media Scenario: Editing Social Media
When I navigate to page "/settings/my-social-media" When I navigate to page "/settings/my-social-media"
Then I am on page "/settings/my-social-media" Then I am on page "/settings/my-social-media"
Given I have added a social media link Given I have added the social media link "https://freeradical.zone/peter-pan"
When I start editing a social media link When I start editing a social media link
Then I can cancel editing Then I can cancel editing
When I start editing a social media link When I start editing a social media link
And I edit and save the link And I edit and save the link
Then I see a toaster with "Added social media" Then I see a toaster with status "success"
And the new url is displayed And the new url is displayed
But the old url is not displayed But the old url is not displayed

View File

@ -1,12 +1,10 @@
import { When } from "cypress-cucumber-preprocessor/steps"; import { When } from "cypress-cucumber-preprocessor/steps";
When('I add a social media link', () => { When('I add a social media link', () => {
cy.get('button') cy.get('[data-test="add-save-button"]')
.contains('Add link')
.click() .click()
.get('#editSocialMedia') .get('#editSocialMedia')
.type('https://freeradical.zone/peter-pan') .type('https://freeradical.zone/peter-pan')
.get('button') .get('[data-test="add-save-button"]')
.contains('Save')
.click() .click()
}) })

View File

@ -1,6 +0,0 @@
import { When } from "cypress-cucumber-preprocessor/steps";
When('I delete a social media link', () => {
cy.get(".base-button[title='Delete']")
.click()
})

View File

@ -0,0 +1,12 @@
import { When } from "cypress-cucumber-preprocessor/steps";
When('I delete the social media link {string}', (link) => {
cy.get('[data-test="delete-button"]')
.click()
cy.get('[data-test="confirm-modal"]')
.should("be.visible")
cy.get('[data-test="confirm-button"]')
.click()
cy.get('.ds-list-item-content > a')
.contains(link).should('not.exist')
})

View File

@ -4,7 +4,6 @@ When('I edit and save the link', () => {
cy.get('input#editSocialMedia') cy.get('input#editSocialMedia')
.clear() .clear()
.type('https://freeradical.zone/tinkerbell') .type('https://freeradical.zone/tinkerbell')
.get('button') .get('[data-test="add-save-button"]')
.contains('Save')
.click() .click()
}) })

View File

@ -1,13 +0,0 @@
import { Given } from "cypress-cucumber-preprocessor/steps";
Given('I have added a social media link', () => {
cy.visit('/settings/my-social-media')
.get('button')
.contains('Add link')
.click()
.get('#editSocialMedia')
.type('https://freeradical.zone/peter-pan')
.get('button')
.contains('Save')
.click()
})

View File

@ -0,0 +1,13 @@
import { Given } from "cypress-cucumber-preprocessor/steps";
Given('I have added the social media link {string}', (link) => {
cy.visit('/settings/my-social-media')
.get('[data-test="add-save-button"]')
.click()
.get('#editSocialMedia')
.type(link)
.get('[data-test="add-save-button"]')
.click()
cy.get('.ds-list-item-content > a')
.contains(link)
})

View File

@ -1,6 +1,6 @@
import { When } from "cypress-cucumber-preprocessor/steps"; import { When } from "cypress-cucumber-preprocessor/steps";
When('I start editing a social media link', () => { When('I start editing a social media link', () => {
cy.get(".base-button[title='Edit']") cy.get('[data-test="edit-button"]')
.click() .click()
}) })

View File

@ -1,8 +1,8 @@
import { Then } from "cypress-cucumber-preprocessor/steps"; import { Then } from "cypress-cucumber-preprocessor/steps";
Then('they should be able to see my social media links', () => { Then('they should be able to see my social media links', () => {
cy.get('.base-card') cy.get('[data-test="social-media-list-headline"]')
.contains('Where else can I find Peter Pan?') .contains('Peter Pan')
.get('a[href="https://freeradical.zone/peter-pan"]') .get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1) .should('have.length', 1)
}) })

View File

@ -0,0 +1,9 @@
import { Then } from "cypress-cucumber-preprocessor/steps";
Then("I see a toaster with status {string}", (status) => {
switch (status) {
case "success":
cy.get(".iziToast.iziToast-color-green").should("be.visible");
break;
}
})

View File

@ -20,7 +20,6 @@ services:
- GRAPHQL_URI=http://localhost:4000 - GRAPHQL_URI=http://localhost:4000
- CLIENT_URI=http://localhost:3000 - CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd - JWT_SECRET=b/&&7b78BF&fv/Vd
- MAPBOX_TOKEN=pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
- NEO4J_apoc_import_file_enabled=true - NEO4J_apoc_import_file_enabled=true
- "SSH_USERNAME=${SSH_USERNAME}" - "SSH_USERNAME=${SSH_USERNAME}"

View File

@ -98,22 +98,22 @@ On a server with Kubernetes cluster:
$ kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate init" $ kubectl -n default exec -it $(kubectl -n default get pods | grep ocelot-backend | awk '{ print $1 }') -- /bin/sh -c "yarn prod:migrate init"
``` ```
***Cypher commands to show indexes and contraints*** ***Cypher commands to show indexes and constraints***
```bash ```bash
# in browser command line or cypher shell # in browser command line or cypher shell
# show all indexes and contraints # show all indexes and constraints
$ :schema $ :schema
# show all indexes # show all indexes
$ CALL db.indexes(); $ CALL db.indexes();
# show all contraints # show all constraints
$ CALL db.constraints(); $ CALL db.constraints();
``` ```
***Cypher commands to create and drop indexes and contraints*** ***Cypher commands to create and drop indexes and constraints***
```bash ```bash
# in browser command line or cypher shell # in browser command line or cypher shell
@ -126,6 +126,6 @@ $ CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"]);
# drop an index # drop an index
$ DROP CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE $ DROP CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE
# drop all indexes and contraints # drop all indexes and constraints
$ CALL apoc.schema.assert({},{},true) YIELD label, key RETURN * ; $ CALL apoc.schema.assert({},{},true) YIELD label, key RETURN * ;
``` ```

View File

@ -48,7 +48,8 @@
"slug": "^6.0.0" "slug": "^6.0.0"
}, },
"resolutions": { "resolutions": {
"set-value": "^2.0.1" "set-value": "^2.0.1",
"nan": "2.17.0"
}, },
"dependencies": {} "dependencies": {}
} }

View File

@ -1,7 +1,8 @@
SENTRY_DSN_WEBAPP= SENTRY_DSN_WEBAPP=
COMMIT= COMMIT=
PUBLIC_REGISTRATION=false
INVITE_REGISTRATION=true
WEBSOCKETS_URI=ws://localhost:3000/api/graphql WEBSOCKETS_URI=ws://localhost:3000/api/graphql
GRAPHQL_URI=http://localhost:4000/ GRAPHQL_URI=http://localhost:4000/
MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g"
PUBLIC_REGISTRATION=false
INVITE_REGISTRATION=true
CATEGORIES_ACTIVE=false CATEGORIES_ACTIVE=false

View File

@ -1 +1 @@
v16.19.0 v19.4.0

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE (Is pushed to DockerHub for rebranding) ################################### # BASE (Is pushed to DockerHub for rebranding) ###################################
################################################################################## ##################################################################################
FROM node:16.19.0-alpine3.17 as base FROM node:19.4.0-alpine3.17 as base
# ENVs # ENVs
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE ########################################################################### # BASE ###########################################################################
################################################################################## ##################################################################################
FROM node:16.19.0-alpine3.17 as base FROM node:19.4.0-alpine3.17 as base
# ENVs # ENVs
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -4,18 +4,32 @@
## Installation ## Installation
For preparation we need Node and recommend to use [node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
between different local Node versions:
```bash
# install Node
$ cd webapp
$ nvm install v16.19.0
$ nvm use v16.19.0
```
Install node dependencies with [yarn](https://yarnpkg.com/en/):
```bash ```bash
# install all dependencies # install all dependencies
$ cd webapp/ $ cd webapp
$ yarn install $ yarn install
# or just # or just
$ yarn $ yarn
# or just later on to use version of ".nvmrc" file
$ nvm use && yarn
``` ```
Copy: Copy:
```text ```text
# in webapp/ # in webapp
cp .env.template .env cp .env.template .env
``` ```

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -3,28 +3,28 @@
* @presenter Color * @presenter Color
*/ */
$color-primary: rgb(23, 181, 63); $color-primary: rgb(23, 181, 63);
$color-primary-light: rgb(96, 214, 98); $color-primary-light: rgb(96, 214, 98);
$color-primary-dark: rgb(25, 122, 49); $color-primary-dark: rgb(25, 122, 49);
$color-primary-active: rgb(25, 194, 67); $color-primary-active: rgb(25, 194, 67);
$color-primary-inverse: rgb(241, 253, 244); $color-primary-inverse: rgb(241, 253, 244);
$color-secondary: rgb(0, 142, 230); $color-secondary: rgb(0, 142, 230);
$color-secondary-active: rgb(10, 161, 255); $color-secondary-active: rgb(10, 161, 255);
$color-secondary-inverse: rgb(240, 249, 255); $color-secondary-inverse: rgb(240, 249, 255);
$color-success: rgb(23, 181, 63); $color-success: rgb(23, 181, 63);
$color-success-active: rgb(26, 203, 71); $color-success-active: rgb(26, 203, 71);
$color-success-inverse: rgb(241, 253, 244); $color-success-inverse: rgb(241, 253, 244);
$color-danger: rgb(219, 57, 36); $color-danger: rgb(219, 57, 36);
$color-danger-light: rgb(242, 97, 65); $color-danger-light: rgb(242, 97, 65);
$color-danger-dark: rgb(158, 43, 28); $color-danger-dark: rgb(158, 43, 28);
$color-danger-active: rgb(224, 81, 62); $color-danger-active: rgb(224, 81, 62);
$color-danger-inverse: rgb(253, 243, 242); $color-danger-inverse: rgb(253, 243, 242);
$color-warning: rgb(230, 121, 25); $color-warning: rgb(230, 121, 25);
$color-warning-active: rgb(233, 137, 53); $color-warning-active: rgb(233, 137, 53);
$color-warning-inverse: rgb(253, 247, 241); $color-warning-inverse: rgb(253, 247, 241);
$color-yellow: rgb(245, 196, 0); $color-yellow: rgb(245, 196, 0);
$color-yellow-active: rgb(255, 206, 10); $color-yellow-active: rgb(255, 206, 10);
$color-yellow-inverse: rgb(255, 252, 240); $color-yellow-inverse: rgb(255, 252, 240);
/** /**
* @tokens Color Neutral * @tokens Color Neutral

View File

@ -1,5 +1,19 @@
<template> <template>
<div> <div>
<nuxt-link to="/groups"><base-button icon="users" circle ghost /></nuxt-link> <nuxt-link to="/groups">
<base-button
icon="users"
circle
ghost
v-tooltip="{
content: $t('group.button.tooltip'),
placement: 'bottom-start',
}"
/>
</nuxt-link>
</div> </div>
</template> </template>
<script>
export default {}
</script>

View File

@ -48,7 +48,7 @@
@change="changeGroupType($event)" @change="changeGroupType($event)"
> >
<option v-for="groupType in groupTypeOptions" :key="groupType" :value="groupType"> <option v-for="groupType in groupTypeOptions" :key="groupType" :value="groupType">
{{ $t(`group.types.${groupType}`) }} {{ $t(`group.typesOptions.${groupType}`) }}
</option> </option>
</select> </select>
<ds-chip <ds-chip
@ -142,25 +142,28 @@
style="position: relative; display: inline-block; right: -96%; top: -33px; width: 26px" style="position: relative; display: inline-block; right: -96%; top: -33px; width: 26px"
@click="formData.locationName = ''" @click="formData.locationName = ''"
></base-button> ></base-button>
<ds-text class="location-hint" color="softer">
{{ $t('settings.data.labelCityHint') }}
</ds-text>
<ds-space margin-top="small" /> <ds-space margin-top="small" />
<!-- category --> <!-- category -->
<categories-select <div v-if="categoriesActive">
v-if="categoriesActive" <ds-text class="select-label">
model="categoryIds" {{ $t('group.categoriesTitle') }}
name="categoryIds" </ds-text>
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip
v-if="categoriesActive"
size="base"
:color="errors && errors.categoryIds ? 'danger' : 'medium'"
>
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<categories-select
model="categoryIds"
name="categoryIds"
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip size="base" :color="errors && errors.categoryIds ? 'danger' : 'medium'">
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
</div>
<!-- submit --> <!-- submit -->
<ds-space margin-top="large"> <ds-space margin-top="large">
<nuxt-link to="/groups"> <nuxt-link to="/groups">
@ -436,5 +439,9 @@ export default {
align-self: flex-end; align-self: flex-end;
margin-top: $space-base; margin-top: $space-base;
} }
> .location-hint {
margin-top: -$space-base + $space-xxx-small;
}
} }
</style> </style>

View File

@ -24,7 +24,7 @@
<logo logoType="header" /> <logo logoType="header" />
</nuxt-link> </nuxt-link>
</ds-flex-item> </ds-flex-item>
<!-- dynamic-brand-menu --> <!-- dynamic brand menus -->
<ds-flex-item <ds-flex-item
v-for="item in menu" v-for="item in menu"
:key="item.name" :key="item.name"
@ -43,8 +43,7 @@
</ds-text> </ds-text>
</nuxt-link> </nuxt-link>
</ds-flex-item> </ds-flex-item>
<!-- search field -->
<!-- search-field -->
<ds-flex-item <ds-flex-item
v-if="isLoggedIn" v-if="isLoggedIn"
id="nav-search-box" id="nav-search-box"
@ -59,233 +58,254 @@
> >
<search-field /> <search-field />
</ds-flex-item> </ds-flex-item>
<!-- filter-menu <!-- filter menu -->
TODO: Filter is only visible on index <!-- TODO: Filter is only visible on index -->
--> <ds-flex-item v-if="isLoggedIn && SHOW_CONTENT_FILTER_HEADER_MENU" style="flex-grow: 0; flex-basis: auto">
<ds-flex-item <client-only>
v-if="isLoggedIn && SHOW_CONTENT_FILTER_HEADER_MENU" <filter-menu v-show="showFilterMenuDropdown" />
style="flex-grow: 0; flex-basis: auto" </client-only>
> </ds-flex-item>
<client-only> <!-- right symbols -->
<filter-menu v-show="showFilterMenuDropdown" /> <ds-flex-item style="flex-basis: auto">
</client-only> <div class="main-navigation-right" style="flex-basis: auto">
</ds-flex-item> <!-- locale switch -->
<!-- locale-switch --> <locale-switch class="topbar-locale-switch" placement="top" offset="8" />
<ds-flex-item style="flex-basis: auto"> <template v-if="isLoggedIn">
<div class="main-navigation-right" style="flex-basis: auto"> <!-- notification menu -->
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
<template v-if="isLoggedIn">
<client-only>
<!-- notification-menu -->
<notification-menu placement="top" />
</client-only>
<div v-if="inviteRegistration">
<client-only> <client-only>
<!-- invite-button --> <notification-menu placement="top" />
<invite-button placement="top" />
</client-only> </client-only>
</div> <!-- invite button -->
<!-- group button --> <div v-if="inviteRegistration">
<client-only v-if="SHOW_GROUP_BUTTON_IN_HEADER"> <client-only>
<invite-button placement="top" />
</client-only>
</div>
<!-- group button -->
<client-only v-if="SHOW_GROUP_BUTTON_IN_HEADER">
<group-button />
</client-only>
<!-- map button -->
<client-only v-if="!isEmpty(this.$env.MAPBOX_TOKEN)">
<map-button />
</client-only>
<!-- avatar menu -->
<client-only>
<avatar-menu placement="top" />
</client-only>
</template>
</div>
</ds-flex-item>
</ds-flex>
<!-- mobile header menu -->
<div v-else class="mobil-header-box">
<!-- logo, hamburger-->
<ds-flex style="align-items: center">
<ds-flex-item :width="{ base: LOGOS.LOGO_HEADER_WIDTH }" style="margin-right: 20px">
<a
v-if="LOGOS.LOGO_HEADER_CLICK.externalLink"
:href="LOGOS.LOGO_HEADER_CLICK.externalLink.url"
:target="LOGOS.LOGO_HEADER_CLICK.externalLink.target"
>
<logo logoType="header" />
</a>
<nuxt-link
v-else
:to="LOGOS.LOGO_HEADER_CLICK.internalPath.to"
v-scroll-to="LOGOS.LOGO_HEADER_CLICK.internalPath.scrollTo"
>
<logo logoType="header" />
</nuxt-link>
</ds-flex-item>
<!-- mobile hamburger menu -->
<ds-flex-item class="mobile-hamburger-menu">
<client-only>
<div style="display: inline-flex; padding-right: 20px">
<notification-menu />
</div>
</client-only>
<base-button icon="bars" @click="toggleMobileMenuView" circle />
</ds-flex-item>
</ds-flex>
<!-- search, filter -->
<ds-flex class="mobile-menu">
<!-- search field mobile -->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="padding: 20px"
>
<search-field />
</ds-flex-item>
<!-- filter menu mobile -->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="flex-grow: 0; flex-basis: auto; padding: 20px 0"
>
<client-only>
<filter-menu v-show="showFilterMenuDropdown" :showMobileMenu="showMobileMenu" />
</client-only>
</ds-flex-item>
</ds-flex>
<!-- right symbols -->
<ds-flex style="margin: 0 20px">
<!-- locale switch mobile -->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }">
<locale-switch
class="topbar-locale-switch topbar-locale-switch-mobile"
placement="top"
offset="8"
/>
</ds-flex-item>
<!-- invite button mobile -->
<ds-flex-item
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only>
<invite-button placement="top" />
</client-only>
</ds-flex-item>
<!-- group button -->
<ds-flex-item
v-if="SHOW_GROUP_BUTTON_IN_HEADER"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only>
<group-button /> <group-button />
</client-only> </client-only>
<!-- avatar-menu --> </ds-flex-item>
<!-- map button -->
<ds-flex-item
v-if="!isEmpty(this.$env.MAPBOX_TOKEN)"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only>
<map-button />
</client-only>
</ds-flex-item>
<!-- avatar menu mobile -->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }" style="text-align: end">
<client-only> <client-only>
<avatar-menu placement="top" /> <avatar-menu placement="top" />
</client-only> </client-only>
</template> </ds-flex-item>
</ds-flex>
<div :class="{ 'hide-mobile-menu': !toggleMobileMenu }" class="mobile-menu footer-mobile">
<!-- dynamic branding menus -->
<ul v-if="isHeaderMenu" class="dynamic-branding-mobil">
<li v-for="item in menu" :key="item.name">
<a v-if="item.url" :href="item.url" :target="item.target">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</a>
<nuxt-link v-else :to="item.path">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</nuxt-link>
</li>
</ul>
<hr />
<!-- dynamic footer menu in header -->
<ul class="dynamic-footer-mobil">
<li v-for="pageParams in links.FOOTER_LINK_LIST" :key="pageParams.name">
<page-params-link :pageParams="pageParams">
{{ $t(pageParams.internalPage.footerIdent) }}
</page-params-link>
</li>
</ul>
</div> </div>
</ds-flex-item>
</ds-flex>
<!-- mobile header menu -->
<div v-else class="mobil-header-box">
<!-- logo, hamburger-->
<ds-flex style="align-items: center">
<ds-flex-item :width="{ base: LOGOS.LOGO_HEADER_WIDTH }" style="margin-right: 20px">
<a
v-if="LOGOS.LOGO_HEADER_CLICK.externalLink"
:href="LOGOS.LOGO_HEADER_CLICK.externalLink.url"
:target="LOGOS.LOGO_HEADER_CLICK.externalLink.target"
>
<logo logoType="header" />
</a>
<nuxt-link
v-else
:to="LOGOS.LOGO_HEADER_CLICK.internalPath.to"
v-scroll-to="LOGOS.LOGO_HEADER_CLICK.internalPath.scrollTo"
>
<logo logoType="header" />
</nuxt-link>
</ds-flex-item>
<!-- mobile hamburger menu -->
<ds-flex-item class="mobile-hamburger-menu">
<client-only>
<div style="display: inline-flex; padding-right: 20px">
<notification-menu />
</div>
</client-only>
<base-button icon="bars" @click="toggleMobileMenuView" circle />
</ds-flex-item>
</ds-flex>
<!-- search, filter-->
<ds-flex class="mobile-menu">
<!-- search-field mobile-->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="padding: 20px"
>
<search-field />
</ds-flex-item>
<!-- filter menu mobile-->
<ds-flex-item
v-if="isLoggedIn"
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="flex-grow: 0; flex-basis: auto; padding: 20px 0"
>
<client-only>
<filter-menu v-show="showFilterMenuDropdown" :showMobileMenu="showMobileMenu" />
</client-only>
</ds-flex-item>
</ds-flex>
<!-- switch language, notification, invite, profil -->
<ds-flex style="margin: 0 20px">
<!-- locale-switch mobile-->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }">
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</ds-flex-item>
<!-- invite-button mobile-->
<ds-flex-item
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only>
<invite-button placement="top" />
</client-only>
</ds-flex-item>
<!-- group button -->
<ds-flex-item
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
style="text-align: center"
>
<client-only v-if="SHOW_GROUP_BUTTON_IN_HEADER">
<group-button />
</client-only>
</ds-flex-item>
<!-- avatar-menu mobile-->
<ds-flex-item :class="{ 'hide-mobile-menu': !toggleMobileMenu }" style="text-align: end">
<client-only>
<avatar-menu placement="top" />
</client-only>
</ds-flex-item>
</ds-flex>
<div :class="{ 'hide-mobile-menu': !toggleMobileMenu }" class="mobile-menu footer-mobile">
<!-- dynamic branding menu -->
<ul v-if="isHeaderMenu" class="dynamic-branding-mobil">
<li v-for="item in menu" :key="item.name">
<a v-if="item.url" :href="item.url" :target="item.target">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</a>
<nuxt-link v-else :to="item.path">
<ds-text size="large" bold>
{{ $t(item.nameIdent) }}
</ds-text>
</nuxt-link>
</li>
</ul>
<hr />
<!-- dynamic footer menu in header -->
<ul class="dynamic-footer-mobil">
<li v-for="pageParams in links.FOOTER_LINK_LIST" :key="pageParams.name">
<page-params-link :pageParams="pageParams">
{{ $t(pageParams.internalPage.footerIdent) }}
</page-params-link>
</li>
</ul>
</div> </div>
</div>
</div> </div>
</ds-container> </ds-container>
</template> </template>
<script>
import { mapGetters } from 'vuex'
import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
import { SHOW_CONTENT_FILTER_HEADER_MENU } from '~/constants/filter.js'
import LOGOS from '~/constants/logos.js'
import headerMenu from '~/constants/headerMenu.js'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import GroupButton from '~/components/Group/GroupButton'
import InviteButton from '~/components/InviteButton/InviteButton'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import Logo from '~/components/Logo/Logo'
import SearchField from '~/components/features/SearchField/SearchField.vue'
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
export default { <script>
components: { import { mapGetters } from 'vuex'
AvatarMenu, import isEmpty from 'lodash/isEmpty'
FilterMenu, import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
GroupButton, import { SHOW_CONTENT_FILTER_HEADER_MENU } from '~/constants/filter.js'
InviteButton, import LOGOS from '~/constants/logos.js'
LocaleSwitch, import headerMenu from '~/constants/headerMenu.js'
Logo, import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
NotificationMenu, import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
PageParamsLink, import GroupButton from '~/components/Group/GroupButton'
SearchField, import InviteButton from '~/components/InviteButton/InviteButton'
}, import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
props: { import Logo from '~/components/Logo/Logo'
showMobileMenu: { type: Boolean, default: false }, import MapButton from '~/components/Map/MapButton'
}, import SearchField from '~/components/features/SearchField/SearchField.vue'
data() { import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
return { import links from '~/constants/links.js'
hideNavbar: false, import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
prevScrollpos: 0,
links, export default {
LOGOS, components: {
SHOW_GROUP_BUTTON_IN_HEADER, AvatarMenu,
SHOW_CONTENT_FILTER_HEADER_MENU, FilterMenu,
isHeaderMenu: headerMenu.MENU.length > 0, GroupButton,
menu: headerMenu.MENU, InviteButton,
mobileSearchVisible: false, LocaleSwitch,
toggleMobileMenu: false, Logo,
inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling, MapButton,
categoriesActive: this.$env.CATEGORIES_ACTIVE, NotificationMenu,
} PageParamsLink,
}, SearchField,
computed: { },
...mapGetters({ props: {
isLoggedIn: 'auth/isLoggedIn', showMobileMenu: { type: Boolean, default: false },
}), },
showFilterMenuDropdown() { data() {
const [firstRoute] = this.$route.matched return {
return firstRoute && firstRoute.name === 'index' hideNavbar: false,
}, prevScrollpos: 0,
}, isEmpty,
methods: { links,
handleScroll() { LOGOS,
const currentScrollPos = window.pageYOffset SHOW_GROUP_BUTTON_IN_HEADER,
if (this.prevScrollpos > currentScrollPos) { SHOW_CONTENT_FILTER_HEADER_MENU,
this.hideNavbar = false isHeaderMenu: headerMenu.MENU.length > 0,
} else { menu: headerMenu.MENU,
this.hideNavbar = true mobileSearchVisible: false,
} toggleMobileMenu: false,
this.prevScrollpos = currentScrollPos inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling,
}, categoriesActive: this.$env.CATEGORIES_ACTIVE,
toggleMobileMenuView() { }
this.toggleMobileMenu = !this.toggleMobileMenu },
}, computed: {
}, ...mapGetters({
mounted() { isLoggedIn: 'auth/isLoggedIn',
window.addEventListener('scroll', this.handleScroll) }),
}, showFilterMenuDropdown() {
} const [firstRoute] = this.$route.matched
return firstRoute && firstRoute.name === 'index'
},
},
methods: {
handleScroll() {
const currentScrollPos = window.pageYOffset
if (this.prevScrollpos > currentScrollPos) {
this.hideNavbar = false
} else {
this.hideNavbar = true
}
this.prevScrollpos = currentScrollPos
},
toggleMobileMenuView() {
this.toggleMobileMenu = !this.toggleMobileMenu
},
},
mounted() {
window.addEventListener('scroll', this.handleScroll)
},
}
</script> </script>
<style lang="scss"> <style lang="scss">
@ -309,6 +329,9 @@ export default {
align-self: center; align-self: center;
display: inline-flex; display: inline-flex;
} }
.topbar-locale-switch-mobile {
margin-top: $space-xx-small;
}
.main-navigation-flex { .main-navigation-flex {
align-items: center; align-items: center;
} }

View File

@ -3,7 +3,7 @@
<dropdown ref="menu" :placement="placement" :offset="offset"> <dropdown ref="menu" :placement="placement" :offset="offset">
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<a class="locale-menu" href="#" @click.prevent="toggleMenu()"> <a class="locale-menu" href="#" @click.prevent="toggleMenu()">
<base-icon name="globe" /> <!-- <base-icon name="globe" /> -->
<span class="label">{{ current.code.toUpperCase() }}</span> <span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" /> <base-icon class="dropdown-arrow" name="angle-down" />
</a> </a>

View File

@ -0,0 +1,30 @@
<template>
<div>
<nuxt-link to="/map">
<base-button
class="map-button"
circle
ghost
v-tooltip="{
content: $t('map.button.tooltip'),
placement: 'bottom-start',
}"
>
<base-icon name="globe-detailed" size="large" />
</base-button>
</nuxt-link>
</div>
</template>
<script>
export default {
name: 'MapButton',
}
</script>
<style lang="scss">
.map-button {
margin-left: 4px;
margin-right: 4px;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div>
<base-button
:class="['map-style-button', actualStyle === style.url ? '' : '--deactivated']"
v-for="style in styles"
:key="style.title"
filled
size="small"
@click="setStyle(style.url)"
>
{{ style.title }}
</base-button>
</div>
</template>
<script>
export default {
name: 'MapStylesButtons',
props: {
styles: { type: Array, required: true },
actualStyle: { type: String, required: true },
setStyle: { type: Function, required: true },
},
}
</script>
<style lang="scss">
.map-style-button {
position: relative;
margin-left: 6px;
margin-bottom: 6px;
margin-top: 6px;
&.--deactivated {
color: $text-color-base;
background-color: $background-color-softer;
}
}
</style>

View File

@ -22,6 +22,7 @@
:name="name" :name="name"
@close="close" @close="close"
/> />
<!-- "id", "type", and "name" props are only used for compatibility with the other modals -->
<confirm-modal <confirm-modal
v-if="open === 'confirm'" v-if="open === 'confirm'"
:id="data.resource.id" :id="data.resource.id"
@ -57,6 +58,7 @@ export default {
open: 'modal/open', open: 'modal/open',
}), }),
name() { name() {
// REFACTORING: This gets unneccesary if we use "modalData" in all modals by probaply replacing them all by "confirm-modal"
if (!this.data || !this.data.resource) return '' if (!this.data || !this.data.resource) return ''
const { const {
resource: { name, title, author }, resource: { name, title, author },

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-modal :title="title" :is-open="isOpen" @cancel="cancel"> <ds-modal :title="title" :is-open="isOpen" @cancel="cancel" data-test="confirm-modal">
<transition name="ds-transition-fade"> <transition name="ds-transition-fade">
<ds-flex v-if="success" class="hc-modal-success" centered> <ds-flex v-if="success" class="hc-modal-success" centered>
<sweetalert-icon icon="success" /> <sweetalert-icon icon="success" />
@ -15,6 +15,7 @@
:danger="!modalData.buttons.confirm.danger" :danger="!modalData.buttons.confirm.danger"
:icon="modalData.buttons.cancel.icon" :icon="modalData.buttons.cancel.icon"
@click="cancel" @click="cancel"
data-test="cancel-button"
> >
{{ $t(modalData.buttons.cancel.textIdent) }} {{ $t(modalData.buttons.cancel.textIdent) }}
</base-button> </base-button>
@ -25,6 +26,7 @@
:icon="modalData.buttons.confirm.icon" :icon="modalData.buttons.confirm.icon"
:loading="loading" :loading="loading"
@click="confirm" @click="confirm"
data-test="confirm-button"
> >
{{ $t(modalData.buttons.confirm.textIdent) }} {{ $t(modalData.buttons.confirm.textIdent) }}
</base-button> </base-button>
@ -41,10 +43,10 @@ export default {
SweetalertIcon, SweetalertIcon,
}, },
props: { props: {
name: { type: String, default: '' }, name: { type: String, default: '' }, // only used for compatibility with the other modals in 'Modal.vue'
type: { type: String, required: true }, type: { type: String, required: true }, // only used for compatibility with the other modals in 'Modal.vue'
modalData: { type: Object, required: true }, modalData: { type: Object, required: true },
id: { type: String, required: true }, id: { type: String, required: true }, // only used for compatibility with the other modals in 'Modal.vue'
}, },
data() { data() {
return { return {

View File

@ -30,7 +30,10 @@
v-for="category in post.categories" v-for="category in post.categories"
:key="category.id" :key="category.id"
v-tooltip="{ v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`), content: `
${$t(`contribution.category.name.${category.slug}`)}:
${$t(`contribution.category.description.${category.slug}`)}
`,
placement: 'bottom-start', placement: 'bottom-start',
}" }"
:icon="category.icon" :icon="category.icon"

View File

@ -2,7 +2,7 @@
<ds-space v-if="user.socialMedia && user.socialMedia.length" margin="large"> <ds-space v-if="user.socialMedia && user.socialMedia.length" margin="large">
<base-card class="social-media-bc"> <base-card class="social-media-bc">
<ds-space margin="x-small"> <ds-space margin="x-small">
<ds-text tag="h5" color="soft"> <ds-text tag="h5" color="soft" data-test="social-media-list-headline">
{{ $t('profile.socialMedia') }} {{ userName | truncate(15) }}? {{ $t('profile.socialMedia') }} {{ userName | truncate(15) }}?
</ds-text> </ds-text>
<template> <template>

View File

@ -1,6 +1,7 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import MySomethingList from './MySomethingList.vue' import MySomethingList from './MySomethingList.vue'
import Vue from 'vue'
const localVue = global.localVue const localVue = global.localVue
@ -9,12 +10,23 @@ describe('MySomethingList.vue', () => {
let propsData let propsData
let data let data
let mocks let mocks
let mutations
beforeEach(() => { beforeEach(() => {
propsData = { propsData = {
useFormData: { dummy: '' }, useFormData: { dummy: '' },
useItems: [{ id: 'id', dummy: 'dummy' }], useItems: [{ id: 'id', dummy: 'dummy' }],
namePropertyKey: 'dummy', namePropertyKey: 'dummy',
texts: {
addButton: 'add-button',
addNew: 'add-new-something',
deleteModal: {
titleIdent: 'delete-modal.title',
messageIdent: 'delete-modal.message',
confirm: { icon: 'trash', buttonTextIdent: 'delete-modal.confirm-button' },
},
edit: 'edit-something',
},
callbacks: { edit: jest.fn(), submit: jest.fn(), delete: jest.fn() }, callbacks: { edit: jest.fn(), submit: jest.fn(), delete: jest.fn() },
} }
data = () => { data = () => {
@ -30,6 +42,9 @@ describe('MySomethingList.vue', () => {
success: jest.fn(), success: jest.fn(),
}, },
} }
mutations = {
'modal/SET_OPEN': jest.fn().mockResolvedValueOnce(),
}
}) })
describe('mount', () => { describe('mount', () => {
@ -39,12 +54,16 @@ describe('MySomethingList.vue', () => {
'list-item': '<div class="list-item"></div>', 'list-item': '<div class="list-item"></div>',
'edit-item': '<div class="edit-item"></div>', 'edit-item': '<div class="edit-item"></div>',
} }
const store = new Vuex.Store({
mutations,
})
return mount(MySomethingList, { return mount(MySomethingList, {
propsData, propsData,
data, data,
mocks, mocks,
localVue, localVue,
slots, slots,
store,
}) })
} }
@ -114,13 +133,42 @@ describe('MySomethingList.vue', () => {
) )
}) })
it('calls delete', async () => { it('calls delete by committing "modal/SET_OPEN"', async () => {
const deleteButton = wrapper.find('.base-button[data-test="delete-button"]') const deleteButton = wrapper.find('.base-button[data-test="delete-button"]')
deleteButton.trigger('click') deleteButton.trigger('click')
await Vue.nextTick() await Vue.nextTick()
const expectedItem = expect.objectContaining({ id: 'id', dummy: 'dummy' }) const expectedModalData = expect.objectContaining({
expect(propsData.callbacks.delete).toHaveBeenCalledTimes(1) name: 'confirm',
expect(propsData.callbacks.delete).toHaveBeenCalledWith(expect.any(Object), expectedItem) data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: 'delete-modal.title',
messageIdent: 'delete-modal.message',
messageParams: {
name: 'dummy',
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete-modal.confirm-button',
callback: expect.any(Function),
},
cancel: {
icon: 'close',
textIdent: 'actions.cancel',
callback: expect.any(Function),
},
},
},
},
})
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1)
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledWith(
expect.any(Object),
expectedModalData,
)
}) })
}) })
}) })

View File

@ -9,11 +9,7 @@
<div v-if="isEditing"> <div v-if="isEditing">
<ds-space margin="base"> <ds-space margin="base">
<ds-heading tag="h5"> <ds-heading tag="h5">
{{ {{ isCreation ? texts.addNew : texts.edit + ' — ' + editingItem[namePropertyKey] }}
isCreation
? $t('settings.social-media.addNewTitle')
: $t('settings.social-media.editTitle', { name: editingItem[namePropertyKey] })
}}
</ds-heading> </ds-heading>
</ds-space> </ds-space>
<ds-space v-if="items" margin-top="base"> <ds-space v-if="items" margin-top="base">
@ -36,11 +32,11 @@
data-test="edit-button" data-test="edit-button"
/> />
<base-button <base-button
:title="$t('actions.delete')"
icon="trash" icon="trash"
circle circle
ghost ghost
@click="handleDeleteItem(item)" @click="handleDeleteItem(item)"
:title="$t('actions.delete')"
data-test="delete-button" data-test="delete-button"
/> />
</template> </template>
@ -58,7 +54,7 @@
type="submit" type="submit"
data-test="add-save-button" data-test="add-save-button"
> >
{{ isEditing ? $t('actions.save') : $t('settings.social-media.submit') }} {{ isEditing ? $t('actions.save') : texts.addButton }}
</base-button> </base-button>
<base-button v-if="isEditing" id="cancel" danger @click="handleCancel()"> <base-button v-if="isEditing" id="cancel" danger @click="handleCancel()">
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
@ -69,27 +65,18 @@
</template> </template>
<script> <script>
import { mapMutations } from 'vuex'
export default { export default {
name: 'MySomethingList', name: 'MySomethingList',
props: { props: {
useFormData: { useFormData: { type: Object, default: () => ({}) },
useFormSchema: { type: Object, default: () => ({}) },
useItems: { type: Array, default: () => [] },
defaultItem: { type: Object, default: () => ({}) },
namePropertyKey: { type: String, required: true },
texts: {
type: Object, type: Object,
default: () => ({}),
},
useFormSchema: {
type: Object,
default: () => ({}),
},
useItems: {
type: Array,
default: () => [],
},
defaultItem: {
type: Object,
default: () => ({}),
},
namePropertyKey: {
type: String,
required: true, required: true,
}, },
callbacks: { callbacks: {
@ -128,6 +115,9 @@ export default {
}, },
}, },
methods: { methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
handleInput(data) { handleInput(data) {
this.callbacks.handleInput(this, data) this.callbacks.handleInput(this, data)
this.disabled = true this.disabled = true
@ -155,8 +145,42 @@ export default {
this.editingItem = null this.editingItem = null
this.disabled = true this.disabled = true
}, },
async handleDeleteItem(item) { handleDeleteItem(item) {
await this.callbacks.delete(this, item) this.openModal(item)
},
openModal(item) {
this.commitModalData(this.modalData(item))
},
modalData(item) {
return {
name: 'confirm',
data: {
type: '',
resource: { id: '' },
modalData: {
titleIdent: this.texts.deleteModal.titleIdent,
messageIdent: this.texts.deleteModal.messageIdent,
messageParams: {
name: item[this.namePropertyKey],
},
buttons: {
confirm: {
danger: true,
icon: this.texts.deleteModal.confirm.icon,
textIdent: this.texts.deleteModal.confirm.buttonTextIdent,
callback: () => {
this.callbacks.delete(this, item)
},
},
cancel: {
icon: 'close',
textIdent: 'actions.cancel',
callback: () => {},
},
},
},
},
}
}, },
}, },
} }

View File

@ -96,6 +96,7 @@ export default {
border-radius: $border-radius-x-large; border-radius: $border-radius-x-large;
overflow: hidden; overflow: hidden;
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
letter-spacing: $letter-spacing-large;
cursor: pointer; cursor: pointer;
&.--danger { &.--danger {

View File

@ -1,6 +1,6 @@
<template> <template>
<span v-if="svgIcon" class="base-icon"> <span v-if="svgIcon" class="base-icon">
<component :is="svgIcon" aria-hidden="true" focusable="false" class="svg" /> <component :class="['svg', `--${size}`]" :is="svgIcon" aria-hidden="true" focusable="false" />
</span> </span>
</template> </template>
@ -16,6 +16,13 @@ export default {
return iconNames.includes(value) return iconNames.includes(value)
}, },
}, },
size: {
type: String,
default: 'regular',
validator(value) {
return value.match(/^(small|regular|large)$/)
},
},
}, },
computed: { computed: {
svgIcon() { svgIcon() {
@ -42,6 +49,19 @@ export default {
> .svg { > .svg {
height: 1.2em; height: 1.2em;
fill: currentColor; fill: currentColor;
&.--small {
height: 0.8em;
}
&.--regular {
height: 1.2em;
}
&.--large {
margin-left: 4px;
height: 2.2em;
}
} }
} }
</style> </style>

View File

@ -28,6 +28,7 @@ const sentry = {
const options = { const options = {
VERSION: process.env.VERSION || pkg.version, VERSION: process.env.VERSION || pkg.version,
DESCRIPTION: process.env.DESCRIPTION || pkg.description, DESCRIPTION: process.env.DESCRIPTION || pkg.description,
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN,
PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true' || false, PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true' || false,
INVITE_REGISTRATION: process.env.INVITE_REGISTRATION !== 'false', // default = true INVITE_REGISTRATION: process.env.INVITE_REGISTRATION !== 'false', // default = true
// Cookies // Cookies

View File

@ -13,11 +13,19 @@ export const userFragment = gql`
} }
` `
export const locationAndBadgesFragment = (lang) => gql` export const locationFragment = (lang) => gql`
fragment locationAndBadges on User { fragment location on User {
locationName
location { location {
name: name${lang} name: name${lang}
lng
lat
} }
}
`
export const badgesFragment = gql`
fragment badges on User {
badges { badges {
id id
icon icon

View File

@ -5,7 +5,8 @@ import {
commentFragment, commentFragment,
postCountsFragment, postCountsFragment,
userCountsFragment, userCountsFragment,
locationAndBadgesFragment, locationFragment,
badgesFragment,
tagsCategoriesAndPinnedFragment, tagsCategoriesAndPinnedFragment,
} from './Fragments' } from './Fragments'
@ -14,7 +15,8 @@ export default (i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${userCountsFragment} ${userCountsFragment}
${locationAndBadgesFragment(lang)} ${locationFragment(lang)}
${badgesFragment}
${postFragment} ${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment} ${tagsCategoriesAndPinnedFragment}
@ -28,7 +30,8 @@ export default (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
blocked blocked
} }
comments(orderBy: createdAt_asc) { comments(orderBy: createdAt_asc) {
@ -36,7 +39,8 @@ export default (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
} }
group { group {
@ -54,7 +58,8 @@ export const filterPosts = (i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${userCountsFragment} ${userCountsFragment}
${locationAndBadgesFragment(lang)} ${locationFragment(lang)}
${badgesFragment}
${postFragment} ${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment} ${tagsCategoriesAndPinnedFragment}
@ -67,7 +72,8 @@ export const filterPosts = (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
group { group {
id id
@ -84,7 +90,8 @@ export const profilePagePosts = (i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${userCountsFragment} ${userCountsFragment}
${locationAndBadgesFragment(lang)} ${locationFragment(lang)}
${badgesFragment}
${postFragment} ${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment} ${tagsCategoriesAndPinnedFragment}
@ -102,7 +109,8 @@ export const profilePagePosts = (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
group { group {
id id
@ -127,7 +135,8 @@ export const relatedContributions = (i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${userCountsFragment} ${userCountsFragment}
${locationAndBadgesFragment(lang)} ${locationFragment(lang)}
${badgesFragment}
${postFragment} ${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment} ${tagsCategoriesAndPinnedFragment}
@ -140,7 +149,8 @@ export const relatedContributions = (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
relatedContributions(first: 2) { relatedContributions(first: 2) {
...post ...post
@ -149,7 +159,8 @@ export const relatedContributions = (i18n) => {
author { author {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
} }
} }

View File

@ -0,0 +1,40 @@
import gql from 'graphql-tag'
// ------ mutations
export const createSocialMediaMutation = () => {
return gql`
mutation ($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
}
export const updateSocialMediaMutation = () => {
return gql`
mutation ($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
}
export const deleteSocialMediaMutation = () => {
return gql`
mutation ($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
}
// ------ queries
// put the queries here

View File

@ -1,26 +1,28 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { import {
userCountsFragment, userCountsFragment,
locationAndBadgesFragment, locationFragment,
badgesFragment,
userFragment, userFragment,
postFragment, postFragment,
commentFragment, commentFragment,
} from './Fragments' } from './Fragments'
export default (i18n) => { export const profileUserQuery = (i18n) => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment} ${userFragment}
${userCountsFragment} ${userCountsFragment}
${locationAndBadgesFragment(lang)} ${locationFragment(lang)}
${badgesFragment}
query User($id: ID!, $followedByCount: Int, $followingCount: Int) { query User($id: ID!, $followedByCount: Int!, $followingCount: Int!) {
User(id: $id) { User(id: $id) {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
about about
locationName
createdAt createdAt
followedByCurrentUser followedByCurrentUser
isMuted isMuted
@ -29,12 +31,14 @@ export default (i18n) => {
following(first: $followingCount) { following(first: $followingCount) {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
followedBy(first: $followedByCount) { followedBy(first: $followedByCount) {
...user ...user
...userCounts ...userCounts
...locationAndBadges ...location
...badges
} }
socialMedia { socialMedia {
id id
@ -62,6 +66,48 @@ export const minimisedUserQuery = () => {
` `
} }
export const adminUserQuery = () => {
return gql`
query ($filter: _UserFilter, $first: Int, $offset: Int, $email: String) {
User(
email: $email
filter: $filter
first: $first
offset: $offset
orderBy: createdAt_desc
) {
id
name
slug
email
role
createdAt
contributionsCount
commentedCount
shoutedCount
}
}
`
}
export const mapUserQuery = (i18n) => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment}
${locationFragment(lang)}
${badgesFragment}
query {
User {
...user
about
...location
...badges
}
}
`
}
export const notificationQuery = (i18n) => { export const notificationQuery = (i18n) => {
return gql` return gql`
${userFragment} ${userFragment}

View File

@ -1,4 +1,5 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
// import { locationFragment } from './Fragments'
// ------ mutations // ------ mutations
@ -146,7 +147,9 @@ export const changeGroupMemberRoleMutation = () => {
export const groupQuery = (i18n) => { export const groupQuery = (i18n) => {
const lang = i18n ? i18n.locale().toUpperCase() : 'EN' const lang = i18n ? i18n.locale().toUpperCase() : 'EN'
// ${locationFragment(lang)}
return gql` return gql`
query ($isMember: Boolean, $id: ID, $slug: String, $first: Int, $offset: Int) { query ($isMember: Boolean, $id: ID, $slug: String, $first: Int, $offset: Int) {
Group(isMember: $isMember, id: $id, slug: $slug, first: $first, offset: $offset) { Group(isMember: $isMember, id: $id, slug: $slug, first: $first, offset: $offset) {
id id
@ -171,8 +174,11 @@ export const groupQuery = (i18n) => {
url url
} }
locationName locationName
# ...location
location { location {
name: name${lang} name: name${lang}
lng
lat
} }
myRole myRole
} }

View File

@ -1,14 +1,14 @@
<template> <template>
<div class="layout-default"> <div class="layout-default">
<div class="main-navigation"> <div class="main-navigation">
<header-menu :showMobileMenu="showMobileMenu" /> <header-menu :showMobileMenu="isMobile" />
</div> </div>
<ds-container> <ds-container>
<div class="main-container"> <div class="main-container">
<nuxt :showMobileMenu="showMobileMenu" /> <nuxt :showMobileMenu="showMobileMenu" />
</div> </div>
</ds-container> </ds-container>
<page-footer v-if="!showMobileMenu" /> <page-footer v-if="!isMobile" />
<div id="overlay" /> <div id="overlay" />
<client-only> <client-only>
<modal /> <modal />
@ -17,8 +17,9 @@
</template> </template>
<script> <script>
import HeaderMenu from '~/components/HeaderMenu/HeaderMenu'
import seo from '~/mixins/seo' import seo from '~/mixins/seo'
import mobile from '~/mixins/mobile'
import HeaderMenu from '~/components/HeaderMenu/HeaderMenu'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import PageFooter from '~/components/PageFooter/PageFooter' import PageFooter from '~/components/PageFooter/PageFooter'
@ -28,27 +29,10 @@ export default {
Modal, Modal,
PageFooter, PageFooter,
}, },
mixins: [seo], mixins: [seo, mobile()],
data() {
return {
windowWidth: null,
maxMobileWidth: 810,
}
},
computed: {
showMobileMenu() {
if (!this.windowWidth) return false
return this.windowWidth <= this.maxMobileWidth
},
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth
})
},
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.main-navigation { .main-navigation {
background-color: $color-header-background; background-color: $color-header-background;

View File

@ -402,20 +402,24 @@
}, },
"group": { "group": {
"actionRadii": { "actionRadii": {
"continental": "Kontinentale Gruppe", "continental": "Kontinental",
"global": "Globale Gruppe", "global": "Global oder nur virtuell",
"interplanetary": "Interplanetare Gruppe", "interplanetary": "Interplanetar",
"national": "Nationale Gruppe", "national": "National",
"regional": "Regionale Gruppe" "regional": "Regional"
}, },
"actionRadius": "Aktionsradius", "actionRadius": "Aktionsradius der Gruppe",
"addMemberToGroup": "Zur Gruppe hinzufügen", "addMemberToGroup": "Zur Gruppe hinzufügen",
"addMemberToGroupSuccess": "„{name}“ wurde der Gruppe mit der Rolle „{role}“ hinzugefügt!", "addMemberToGroupSuccess": "„{name}“ wurde der Gruppe mit der Rolle „{role}“ hinzugefügt!",
"addUser": "Benutzer hinzufügen", "addUser": "Benutzer hinzufügen",
"addUserNoOptions": "Keine Nutzer gefunden!", "addUserNoOptions": "Keine Nutzer gefunden!",
"addUserPlaceholder": "Benutzername", "addUserPlaceholder": "Benutzername",
"allGroups": "Alle Gruppen", "allGroups": "Alle Gruppen",
"button": {
"tooltip": "Gruppen anzeigen"
},
"categories": "Thema ::: Themen", "categories": "Thema ::: Themen",
"categoriesTitle": "Themen der Gruppe",
"changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!", "changeMemberRole": "Die Rolle wurde auf „{role}“ geändert!",
"contentMenu": { "contentMenu": {
"visitGroupPage": "Gruppe anzeigen" "visitGroupPage": "Gruppe anzeigen"
@ -477,11 +481,16 @@
"usual": "Einfaches Mitglied" "usual": "Einfaches Mitglied"
}, },
"save": "Neue Gruppe anlegen", "save": "Neue Gruppe anlegen",
"type": "Gruppentyp", "type": "Öffentlichkeit der Gruppe",
"types": { "types": {
"closed": "Geschlossene Gruppe", "closed": "Geschlossen",
"hidden": "Versteckte Gruppe", "hidden": "Geheim",
"public": "Öffentliche Gruppe" "public": "Öffentlich"
},
"typesOptions": {
"closed": "Geschlossen — Alle Beiträge nur für Gruppenmitglieder sichtbar",
"hidden": "Geheim — Gruppe (auch der Name) komplett unsichtbar",
"public": "Öffentlich — Gruppe und alle Beiträge für registrierte Nutzer sichtbar"
}, },
"update": "Änderung speichern", "update": "Änderung speichern",
"updatedGroup": "Die Gruppendaten wurden geändert!" "updatedGroup": "Die Gruppendaten wurden geändert!"
@ -527,6 +536,24 @@
"questions": "Bei Fragen oder Problemen erreichst Du uns per E-Mail an", "questions": "Bei Fragen oder Problemen erreichst Du uns per E-Mail an",
"title": "{APPLICATION_NAME} befindet sich in der Wartung" "title": "{APPLICATION_NAME} befindet sich in der Wartung"
}, },
"map": {
"alertMessage": "Es kann nicht auf die Karte zugegriffen werden: Der Mapbox-Token ist auf dem Server nicht gesetzt!",
"button": {
"tooltip": "Landkarte anzeigen"
},
"markerTypes": {
"group": "Gruppe",
"theUser": "deine Position",
"user": "Benutzer"
},
"pageTitle": "Landkarte",
"styles": {
"dark": "Dunkel",
"outdoors": "Landschaft",
"satellite": "Satellit",
"streets": "Straßen"
}
},
"modals": { "modals": {
"deleteUser": { "deleteUser": {
"created": "Erstellt" "created": "Erstellt"
@ -800,6 +827,7 @@
"data": { "data": {
"labelBio": "Über Dich", "labelBio": "Über Dich",
"labelCity": "Deine Stadt oder Region", "labelCity": "Deine Stadt oder Region",
"labelCityHint": "(zeigt ungefähre Position auf der Landkarte)",
"labelName": "Dein Name", "labelName": "Dein Name",
"labelSlug": "Dein eindeutiger Benutzername", "labelSlug": "Dein eindeutiger Benutzername",
"name": "Deine Daten", "name": "Deine Daten",
@ -920,9 +948,14 @@
"name": "Sicherheit" "name": "Sicherheit"
}, },
"social-media": { "social-media": {
"addNewTitle": "Neuen Link hinzufügen", "add-new-link": "Neuen Link hinzufügen",
"editTitle": "Link \"{name}\" ändern", "delete-modal": {
"name": "Soziale Netzwerke", "confirm-button": "Löschen",
"message": "Lösche „{name}“.",
"title": "Möchtest du wirklich deinen Link löschen?"
},
"edit-link": "Ändere den Link",
"name": "Soziale Medien",
"placeholder": "Deine Webadresse des Sozialen Netzwerkes", "placeholder": "Deine Webadresse des Sozialen Netzwerkes",
"requireUnique": "Dieser Link existiert bereits", "requireUnique": "Dieser Link existiert bereits",
"submit": "Link hinzufügen", "submit": "Link hinzufügen",

View File

@ -402,20 +402,24 @@
}, },
"group": { "group": {
"actionRadii": { "actionRadii": {
"continental": "Continental Group", "continental": "Continental",
"global": "Global Group", "global": "Global or only virtual",
"interplanetary": "Interplanetary Group", "interplanetary": "Interplanetary",
"national": "National Group", "national": "National",
"regional": "Regional Group" "regional": "Regional"
}, },
"actionRadius": "Action radius", "actionRadius": "Action radius of the group",
"addMemberToGroup": "Add to group", "addMemberToGroup": "Add to group",
"addMemberToGroupSuccess": "“{name}” was added to the group with the role “{role}”!", "addMemberToGroupSuccess": "“{name}” was added to the group with the role “{role}”!",
"addUser": "Add User", "addUser": "Add User",
"addUserNoOptions": "No users found!", "addUserNoOptions": "No users found!",
"addUserPlaceholder": " Username", "addUserPlaceholder": " Username",
"allGroups": "All Groups", "allGroups": "All Groups",
"button": {
"tooltip": "Show groups"
},
"categories": "Topic ::: Topics", "categories": "Topic ::: Topics",
"categoriesTitle": "Topics of the group",
"changeMemberRole": "The role has been changed to “{role}”!", "changeMemberRole": "The role has been changed to “{role}”!",
"contentMenu": { "contentMenu": {
"visitGroupPage": "Show group" "visitGroupPage": "Show group"
@ -477,11 +481,16 @@
"usual": "Simple Member" "usual": "Simple Member"
}, },
"save": "Create new group", "save": "Create new group",
"type": "Group type", "type": "Visibility of the group",
"types": { "types": {
"closed": "Closed Group", "closed": "Closed",
"hidden": "Hidden Group", "hidden": "Secret",
"public": "Public Group" "public": "Public"
},
"typesOptions": {
"closed": "Closed — All posts only visible to the group's members",
"hidden": "Secret — Group (including the name) is completely invisible",
"public": "Public — Group and all posts are visible for all registered users"
}, },
"update": "Save change", "update": "Save change",
"updatedGroup": "The group data has been changed." "updatedGroup": "The group data has been changed."
@ -527,6 +536,24 @@
"questions": "Any Questions or concerns, send an e-mail to", "questions": "Any Questions or concerns, send an e-mail to",
"title": "{APPLICATION_NAME} is under maintenance" "title": "{APPLICATION_NAME} is under maintenance"
}, },
"map": {
"alertMessage": "The map cannot be accessed: The Mapbox token is not set on the server!",
"button": {
"tooltip": "Show map"
},
"markerTypes": {
"group": "group",
"theUser": "your position",
"user": "user"
},
"pageTitle": "Map",
"styles": {
"dark": "Dark",
"outdoors": "Outdoors",
"satellite": "Satellite",
"streets": "Streets"
}
},
"modals": { "modals": {
"deleteUser": { "deleteUser": {
"created": "Created" "created": "Created"
@ -800,6 +827,7 @@
"data": { "data": {
"labelBio": "About You", "labelBio": "About You",
"labelCity": "Your City or Region", "labelCity": "Your City or Region",
"labelCityHint": "(shows approximate position on map)",
"labelName": "Your Name", "labelName": "Your Name",
"labelSlug": "Your unique user name", "labelSlug": "Your unique user name",
"name": "Your data", "name": "Your data",
@ -920,8 +948,13 @@
"name": "Security" "name": "Security"
}, },
"social-media": { "social-media": {
"addNewTitle": "Add new link", "add-new-link": "Add new link",
"editTitle": "Edit link \"{name}\"", "delete-modal": {
"confirm-button": "Delete",
"message": "Delete “{name}”.",
"title": "Do you really want to delete your link?"
},
"edit-link": "Edit link",
"name": "Social media", "name": "Social media",
"placeholder": "Your social media url", "placeholder": "Your social media url",
"requireUnique": "You added this url already", "requireUnique": "You added this url already",

View File

@ -8,9 +8,9 @@
"private": false, "private": false,
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "yarn run nuxt -c nuxt.config.maintenance.js", "dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt -c nuxt.config.maintenance.js",
"build": "yarn run nuxt build -c nuxt.config.maintenance.js", "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt build -c nuxt.config.maintenance.js",
"start": "yarn run nuxt start -c nuxt.config.maintenance.js", "start": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt start -c nuxt.config.maintenance.js",
"generate": "yarn run nuxt generate -c nuxt.config.maintenance.js" "generate": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt generate -c nuxt.config.maintenance.js"
} }
} }

22
webapp/mixins/mobile.js Normal file
View File

@ -0,0 +1,22 @@
export default (mobileWidth = null) => {
return {
data() {
return {
windowWidth: null,
maxMobileWidth: mobileWidth || 810, // greater counts as desktop
}
},
computed: {
isMobile() {
if (!this.windowWidth) return false
return this.windowWidth <= this.maxMobileWidth
},
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth
})
},
}
}

View File

@ -126,6 +126,7 @@ export default {
{ src: '~/plugins/vue-filters.js' }, { src: '~/plugins/vue-filters.js' },
{ src: '~/plugins/vue-infinite-loading.js', ssr: false }, { src: '~/plugins/vue-infinite-loading.js', ssr: false },
{ src: '~/plugins/vue-observe-visibility.js', ssr: false }, { src: '~/plugins/vue-observe-visibility.js', ssr: false },
{ src: '~/plugins/v-mapbox.js', mode: 'client' },
], ],
router: { router: {
@ -155,6 +156,11 @@ export default {
'@nuxtjs/pwa', '@nuxtjs/pwa',
], ],
buildModules: [
// https://composition-api.nuxtjs.org/getting-started/setup#quick-start
'@nuxtjs/composition-api/module',
],
/* /*
** Axios module configuration ** Axios module configuration
*/ */

View File

@ -7,13 +7,12 @@
"license": "MIT", "license": "MIT",
"private": false, "private": false,
"scripts": { "scripts": {
"dev": "nuxt", "dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider nuxt",
"dev:styleguide": "cross-env STYLEGUIDE_DEV=true yarn run dev", "dev:styleguide": "cross-env STYLEGUIDE_DEV=true yarn run dev",
"storybook": "start-storybook -p 3002 -s ./static -c storybook/", "storybook": "cross-env NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 3002 -s ./static -c storybook/",
"build": "nuxt build", "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider nuxt build",
"start": "nuxt start", "start": "cross-env NODE_OPTIONS=--openssl-legacy-provider nuxt start",
"generate:maintenance": "nuxt generate -c nuxt.config.maintenance.js", "generate": "cross-env NODE_OPTIONS=--openssl-legacy-provider nuxt generate",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue .", "lint": "eslint --ext .js,.vue .",
"locales": "../scripts/translations/missing-keys.sh && ../scripts/translations/sort.sh", "locales": "../scripts/translations/missing-keys.sh && ../scripts/translations/sort.sh",
"precommit": "yarn lint", "precommit": "yarn lint",
@ -22,6 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@human-connection/styleguide": "0.5.22", "@human-connection/styleguide": "0.5.22",
"@mapbox/mapbox-gl-geocoder": "^5.0.1",
"@nuxtjs/apollo": "^4.0.0-rc19", "@nuxtjs/apollo": "^4.0.0-rc19",
"@nuxtjs/axios": "~5.9.7", "@nuxtjs/axios": "~5.9.7",
"@nuxtjs/dotenv": "~1.4.1", "@nuxtjs/dotenv": "~1.4.1",
@ -40,6 +40,7 @@
"intersection-observer": "^0.12.0", "intersection-observer": "^0.12.0",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkify-it": "~3.0.2", "linkify-it": "~3.0.2",
"mapbox-gl": "1.13.2",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"nuxt": "~2.12.1", "nuxt": "~2.12.1",
"nuxt-dropzone": "^1.0.4", "nuxt-dropzone": "^1.0.4",
@ -50,6 +51,7 @@
"tiptap": "~1.26.6", "tiptap": "~1.26.6",
"tiptap-extensions": "~1.28.8", "tiptap-extensions": "~1.28.8",
"trunc-html": "^1.1.2", "trunc-html": "^1.1.2",
"v-mapbox": "^1.11.2",
"v-tooltip": "~2.1.3", "v-tooltip": "~2.1.3",
"validator": "^13.0.0", "validator": "^13.0.0",
"vue-count-to": "~1.0.13", "vue-count-to": "~1.0.13",
@ -67,6 +69,7 @@
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "~7.9.0", "@babel/preset-env": "~7.9.0",
"@faker-js/faker": "5.1.0", "@faker-js/faker": "5.1.0",
"@nuxtjs/composition-api": "0.32.0",
"@storybook/addon-a11y": "^6.3.6", "@storybook/addon-a11y": "^6.3.6",
"@storybook/addon-actions": "^5.3.21", "@storybook/addon-actions": "^5.3.21",
"@storybook/addon-notes": "^5.3.18", "@storybook/addon-notes": "^5.3.18",
@ -108,5 +111,8 @@
"vue-jest": "~3.0.5", "vue-jest": "~3.0.5",
"vue-svg-loader": "~0.16.0", "vue-svg-loader": "~0.16.0",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"
},
"resolutions": {
"nan": "2.17.0"
} }
} }

View File

@ -74,10 +74,10 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import gql from 'graphql-tag'
import { isEmail } from 'validator' import { isEmail } from 'validator'
import normalizeEmail from '~/components/utils/NormalizeEmail' import normalizeEmail from '~/components/utils/NormalizeEmail'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons' import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { adminUserQuery } from '~/graphql/User'
import { FetchAllRoles, updateUserRole } from '~/graphql/admin/Roles' import { FetchAllRoles, updateUserRole } from '~/graphql/admin/Roles'
export default { export default {
@ -138,27 +138,7 @@ export default {
apollo: { apollo: {
User: { User: {
query() { query() {
return gql` return adminUserQuery()
query ($filter: _UserFilter, $first: Int, $offset: Int, $email: String) {
User(
email: $email
filter: $filter
first: $first
offset: $offset
orderBy: createdAt_desc
) {
id
name
slug
email
role
createdAt
contributionsCount
commentedCount
shoutedCount
}
}
`
}, },
variables() { variables() {
const { offset, first, email, filter } = this const { offset, first, email, filter } = this

164
webapp/pages/map.spec.js Normal file
View File

@ -0,0 +1,164 @@
// eslint-disable-next-line no-unused-vars
import mapboxgl from 'mapbox-gl'
import { mount } from '@vue/test-utils'
import VueMeta from 'vue-meta'
import Vuex from 'vuex'
import Map from './map'
jest.mock('mapbox-gl', () => {
return {
GeolocateControl: jest.fn(),
Map: jest.fn(() => ({
addControl: jest.fn(),
on: jest.fn(),
remove: jest.fn(),
})),
NavigationControl: jest.fn(),
Popup: jest.fn(() => {
return {
isOpen: jest.fn(),
setLngLat: jest.fn(() => {
return {
setHTML: jest.fn(() => {
return {
addTo: jest.fn(),
}
}),
}
}),
}
}),
}
})
const localVue = global.localVue
localVue.use(VueMeta, { keyName: 'head' })
const onEventMocks = {}
const mapOnMock = jest.fn((key, ...args) => {
onEventMocks[key] = args[args.length - 1]
})
const mapAddControlMock = jest.fn()
const mapMock = {
on: mapOnMock,
addControl: mapAddControlMock,
loadImage: jest.fn(),
getCanvas: jest.fn(() => {
return {
style: {
cursor: 'pointer',
},
}
}),
}
const stubs = {
'client-only': true,
'mgl-map': true,
MglFullscreenControl: true,
MglNavigationControl: true,
MglGeolocateControl: true,
MglScaleControl: true,
}
describe('map', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: (t) => t,
$env: {
MAPBOX_TOKEN: 'MY_MAPBOX_TOKEN',
},
$toast: {
error: jest.fn(),
},
}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({ getters: { 'auth/user': () => false } })
return mount(Map, {
mocks,
localVue,
stubs,
store,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.is('div')).toBeTruthy()
})
it('has correct <head> content', () => {
expect(wrapper.vm.$metaInfo.title).toBe('map.pageTitle')
})
describe('trigger map load', () => {
beforeEach(async () => {
await wrapper.find('mgl-map-stub').vm.$emit('load', { map: mapMock })
})
it('initializes on style load', () => {
expect(mapOnMock).toBeCalledWith('style.load', expect.any(Function))
})
it('initializes on mouseenter', () => {
expect(mapOnMock).toBeCalledWith('mouseenter', 'markers', expect.any(Function))
})
it('initializes on mouseleave', () => {
expect(mapOnMock).toBeCalledWith('mouseleave', 'markers', expect.any(Function))
})
it('calls add map control', () => {
expect(mapAddControlMock).toBeCalled()
})
describe('trigger style load event', () => {
let spy
beforeEach(() => {
spy = jest.spyOn(wrapper.vm, 'loadMarkersIconsAndAddMarkers')
onEventMocks['style.load']()
})
it('calls loadMarkersIconsAndAddMarkers', () => {
expect(spy).toBeCalled()
})
})
describe('trigger mouse enter event', () => {
beforeEach(() => {
onEventMocks.mouseenter({
features: [
{
geometry: {
coordinates: [100, 200],
},
properties: {
type: 'user',
},
},
],
lngLat: {
lng: 100,
lat: 200,
},
})
})
it('works without errors and warnings', () => {
expect(true).toBe(true)
})
})
})
})
})

483
webapp/pages/map.vue Normal file
View File

@ -0,0 +1,483 @@
<!-- Example Reference: https://codesandbox.io/s/v-mapbox-with-nuxt-lbrt6?file=/pages/index.vue -->
<template>
<div>
<ds-space margin="small">
<ds-heading tag="h1">{{ $t('map.pageTitle') }}</ds-heading>
</ds-space>
<ds-space margin="large" />
<client-only v-if="!isEmpty($env.MAPBOX_TOKEN)">
<map-styles-buttons
v-if="isMobile"
:styles="styles"
:actualStyle="mapOptions.style"
:setStyle="setStyle"
/>
<mgl-map
:mapbox-gl="mapboxgl"
:access-token="mapOptions.accessToken"
:map-style.sync="mapOptions.style"
:center="mapOptions.center"
:zoom="mapOptions.zoom"
:max-zoom="mapOptions.maxZoom"
:cross-source-collisions="false"
:fail-if-major-performance-caveat="false"
:preserve-drawing-buffer="true"
:hash="false"
:min-pitch="0"
:max-pitch="60"
@load="onMapLoad"
>
<map-styles-buttons
v-if="!isMobile"
:styles="styles"
:actualStyle="mapOptions.style"
:setStyle="setStyle"
/>
<MglFullscreenControl />
<MglNavigationControl position="top-right" />
<MglGeolocateControl position="top-right" />
<MglScaleControl />
</mgl-map>
</client-only>
<empty v-else icon="alert" :message="$t('map.alertMessage')" />
</div>
</template>
<script>
import { isEmpty, toArray } from 'lodash'
import mapboxgl from 'mapbox-gl'
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'
import { mapGetters } from 'vuex'
import { profileUserQuery, mapUserQuery } from '~/graphql/User'
import { groupQuery } from '~/graphql/groups'
import mobile from '~/mixins/mobile'
import Empty from '~/components/Empty/Empty'
import MapStylesButtons from '~/components/Map/MapStylesButtons'
const maxMobileWidth = 639 // on this width and smaller the mapbox 'MapboxGeocoder' search gets bigger
export default {
name: 'Map',
mixins: [mobile(maxMobileWidth)],
components: {
Empty,
MapStylesButtons,
},
head() {
return {
title: this.$t('map.pageTitle'),
}
},
data() {
mapboxgl.accessToken = this.$env.MAPBOX_TOKEN
return {
isEmpty,
mapboxgl,
activeStyle: null,
defaultCenter: [10.452764, 51.165707], // center of Germany: https://www.gpskoordinaten.de/karte/land/DE
currentUserLocation: null,
currentUserCoordinates: null,
users: null,
groups: null,
markers: {
icons: [
{
id: 'marker-blue',
name: 'mapbox-marker-icon-20px-blue.png',
},
{
id: 'marker-orange',
name: 'mapbox-marker-icon-20px-orange.png',
},
{
id: 'marker-green',
name: 'mapbox-marker-icon-20px-green.png',
},
],
isImagesLoaded: false,
geoJSON: [],
isGeoJSON: false,
isSourceAndLayerAdded: false,
isFlyToCenter: false,
popup: null,
popupOnLeaveTimeoutId: null,
},
}
},
async mounted() {
this.currentUserLocation = await this.getUserLocation(this.currentUser.id)
this.currentUserCoordinates = this.currentUserLocation
? this.getCoordinates(this.currentUserLocation)
: null
this.addMarkersOnCheckPrepared()
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
isPreparedForMarkers() {
return (
!this.markers.isGeoJSON &&
this.markers.isImagesLoaded &&
this.currentUser &&
this.users &&
this.groups
)
},
styles() {
return toArray(this.availableStyles)
},
availableStyles() {
// https://docs.mapbox.com/api/maps/styles/
const availableStyles = {
outdoors: {
url: 'mapbox://styles/mapbox/outdoors-v12?optimize=true',
},
streets: {
url: 'mapbox://styles/mapbox/streets-v11?optimize=true',
// use the newest version?
// url: 'mapbox://styles/mapbox/streets-v12',
},
satellite: {
url: 'mapbox://styles/mapbox/satellite-streets-v11?optimize=true',
},
dark: {
url: 'mapbox://styles/mapbox/dark-v10?optimize=true',
},
}
Object.keys(availableStyles).map((key) => {
availableStyles[key].title = this.$t('map.styles.' + key)
})
return availableStyles
},
mapOptions() {
return {
// accessToken: this.$env.MAPBOX_TOKEN, // is set already above
style: !this.activeStyle ? this.availableStyles.outdoors.url : this.activeStyle,
center: this.mapCenter,
zoom: this.mapZoom,
maxZoom: 22,
// projection: 'globe', // the package is probably to old, because of Vue2: https://docs.mapbox.com/mapbox-gl-js/example/globe/
}
},
mapCenter() {
return this.currentUserCoordinates ? this.currentUserCoordinates : this.defaultCenter
},
mapZoom() {
return this.currentUserCoordinates ? 10 : 4
},
},
watch: {
isPreparedForMarkers(newValue) {
if (newValue) {
this.addMarkersOnCheckPrepared()
}
},
},
methods: {
onMapLoad({ map }) {
this.map = map
// set the default atmosphere style
// this.map.setFog({}) // the package is probably to old, because of Vue2: https://docs.mapbox.com/mapbox-gl-js/example/globe/
this.map.on('style.load', (value) => {
// Triggered when `setStyle` is called.
this.markers.isImagesLoaded = false
this.markers.isSourceAndLayerAdded = false
this.loadMarkersIconsAndAddMarkers()
})
// add search field for locations
this.map.addControl(
new MapboxGeocoder({
accessToken: this.$env.MAPBOX_TOKEN,
mapboxgl: this.mapboxgl,
}),
)
// example for popup: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-hover/
// create a popup, but don't add it to the map yet
this.markers.popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: true,
})
this.map.on('mouseenter', 'markers', (e) => {
// if (e.features[0].properties.type !== 'theUser') {}
if (this.popupOnLeaveTimeoutId) {
clearTimeout(this.popupOnLeaveTimeoutId)
this.popupOnLeaveTimeoutId = null
}
if (this.markers.popup.isOpen()) {
this.map.getCanvas().style.cursor = ''
this.markers.popup.remove()
}
// Change the cursor style as a UI indicator.
this.map.getCanvas().style.cursor = 'pointer'
// Copy coordinates array.
const coordinates = e.features[0].geometry.coordinates.slice()
const markerTypeLabel =
e.features[0].properties.type === 'group'
? this.$t('map.markerTypes.group')
: e.features[0].properties.type === 'user'
? this.$t('map.markerTypes.user')
: this.$t('map.markerTypes.theUser')
const markerProfileLinkTitle =
(e.features[0].properties.type === 'group' ? '&' : '@') + e.features[0].properties.slug
const markerProfileLink =
(e.features[0].properties.type === 'group' ? '/group' : '/profile') +
`/${e.features[0].properties.id}/${e.features[0].properties.slug}`
let description = `
<div>
<div>
<b>${e.features[0].properties.name}</b> <i>(${markerTypeLabel})</i>
</div>
<div>
<a href="${markerProfileLink}" target="_blank">${markerProfileLinkTitle}</a>
</div>
</div>
`
description +=
e.features[0].properties.about && e.features[0].properties.about.length > 0
? `
<hr>
<div>
${e.features[0].properties.about}
</div>`
: ''
// Ensure that if the map is zoomed out such that multiple
// copies of the feature are visible, the popup appears
// over the copy being pointed to.
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360
}
// Populate the popup and set its coordinates
// based on the feature found.
this.markers.popup.setLngLat(coordinates).setHTML(description).addTo(this.map)
})
this.map.on('mouseleave', 'markers', (e) => {
if (this.markers.popup.isOpen()) {
this.popupOnLeaveTimeoutId = setTimeout(() => {
this.map.getCanvas().style.cursor = ''
this.markers.popup.remove()
}, 3000)
}
})
this.loadMarkersIconsAndAddMarkers()
},
language(map) {
// example in mapbox-gl-language: https://github.com/mapbox/mapbox-gl-language/blob/master/index.js
map.getStyle().layers.forEach(function (thisLayer) {
if (thisLayer.id.indexOf('-label') > 0) {
// seems to use user language. specific language would be `name_de`, but is not compatible with all maps
// variant sets all 'text-field' layers to languages of their countries
map.setLayoutProperty(thisLayer.id, 'text-field', ['get', 'name'])
}
})
},
setStyle(url) {
this.map.setStyle(url)
this.activeStyle = url
},
loadMarkersIconsAndAddMarkers() {
Promise.all(
this.markers.icons.map(
(marker) =>
new Promise((resolve, reject) => {
// our images have to be in the 'static/img/*' folder otherwise they are not reachable via URL
this.map.loadImage('img/mapbox/marker-icons/' + marker.name, (error, image) => {
if (error) throw error
this.map.addImage(marker.id, image)
resolve()
})
}),
),
).then(() => {
this.markers.isImagesLoaded = true
this.language(this.map)
this.addMarkersOnCheckPrepared()
})
},
addMarkersOnCheckPrepared() {
// set geoJSON for markers
if (this.isPreparedForMarkers) {
// add markers for "users"
this.users.forEach((user) => {
if (user.id !== this.currentUser.id && user.location) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'user',
iconName: 'marker-green',
iconRotate: 0.0,
id: user.id,
slug: user.slug,
name: user.name,
about: user.about,
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(user.location),
},
})
}
})
// add markers for "groups"
this.groups.forEach((group) => {
if (group.location) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'group',
iconName: 'marker-blue',
iconRotate: 0.0,
id: group.id,
slug: group.slug,
name: group.name,
about: group.about,
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(group.location),
},
})
}
})
// add marker for "currentUser"
if (this.currentUserCoordinates) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'theUser',
iconName: 'marker-orange',
iconRotate: 45.0,
id: this.currentUser.id,
slug: this.currentUser.slug,
name: this.currentUser.name,
about: this.currentUser.about,
},
geometry: {
type: 'Point',
coordinates: this.currentUserCoordinates,
},
})
}
this.markers.isGeoJSON = true
}
// add source and layer
if (!this.markers.isSourceAndLayerAdded && this.markers.isGeoJSON && this.map) {
this.map.addSource('markers', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: this.markers.geoJSON,
},
})
this.map.addLayer({
id: 'markers',
type: 'symbol',
source: 'markers',
layout: {
'icon-image': ['get', 'iconName'], // get the "icon-image" from the source's "iconName" property
'icon-allow-overlap': true,
'icon-size': 1.0,
'icon-rotate': ['get', 'iconRotate'], // get the "icon-rotate" from the source's "iconRotate" property
// 'text-field': ['get', 'name'], // get the "text-field" from the source's "name" property
// 'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'],
// 'text-offset': [0, 0],
// 'text-anchor': 'top',
// 'text-allow-overlap': true,
},
})
this.markers.isSourceAndLayerAdded = true
}
// fly to center if never done
if (!this.markers.isFlyToCenter && this.markers.isSourceAndLayerAdded) {
this.mapFlyToCenter()
this.markers.isFlyToCenter = true
}
},
mapFlyToCenter() {
if (this.map) {
// example: https://docs.mapbox.com/mapbox-gl-js/example/center-on-feature/
this.map.flyTo({
center: this.mapCenter,
zoom: this.mapZoom,
})
}
},
getCoordinates(location) {
return [location.lng, location.lat]
},
async getUserLocation(id) {
try {
const {
data: { User: users },
} = await this.$apollo.query({
query: profileUserQuery(this.$i18n),
variables: {
id,
followedByCount: 0,
followingCount: 0,
},
})
return users && users[0] && users[0].location ? users[0].location : null
} catch (err) {
this.$toast.error(err.message)
return null
}
},
},
apollo: {
User: {
query() {
return mapUserQuery(this.$i18n)
},
variables() {
return {}
},
update({ User }) {
this.users = User
this.addMarkersOnCheckPrepared()
},
fetchPolicy: 'cache-and-network',
},
Group: {
query() {
return groupQuery(this.$i18n)
},
variables() {
return {}
},
update({ Group }) {
this.groups = Group
this.addMarkersOnCheckPrepared()
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
<style lang="scss">
// description: https: //github.com/geospoc/v-mapbox/tree/v1.11.2/docs
// code example: https: //codesandbox.io/embed/v-mapbox-map-demo-k1l1n?autoresize=1&fontsize=14&hidenavigation=1&theme=dark
@import 'mapbox-gl/dist/mapbox-gl.css';
@import 'v-mapbox/dist/v-mapbox.css';
.mgl-map-wrapper {
height: 70vh;
}
</style>

View File

@ -184,8 +184,7 @@ import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue' import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation' import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import { profilePagePosts } from '~/graphql/PostQuery' import { profilePagePosts } from '~/graphql/PostQuery'
import UserQuery from '~/graphql/User' import { profileUserQuery, updateUserMutation } from '~/graphql/User'
import { updateUserMutation } from '~/graphql/User.js'
import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers' import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers' import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers'
import UpdateQuery from '~/components/utils/UpdateQuery' import UpdateQuery from '~/components/utils/UpdateQuery'
@ -408,7 +407,7 @@ export default {
}, },
User: { User: {
query() { query() {
return UserQuery(this.$i18n) return profileUserQuery(this.$i18n)
}, },
variables() { variables() {
return { return {

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-form v-model="form" :schema="formSchema" @submit="submit"> <ds-form class="settings-form" v-model="form" :schema="formSchema" @submit="submit">
<template #default="{ errors }"> <template #default="{ errors }">
<base-card> <base-card>
<h2 class="title">{{ $t('settings.data.name') }}</h2> <h2 class="title">{{ $t('settings.data.name') }}</h2>
@ -22,6 +22,9 @@
:loading="loadingGeo" :loading="loadingGeo"
@input.native="handleCityInput" @input.native="handleCityInput"
/> />
<ds-text class="location-hint" color="softer">
{{ $t('settings.data.labelCityHint') }}
</ds-text>
<!-- eslint-enable vue/use-v-on-exact --> <!-- eslint-enable vue/use-v-on-exact -->
<ds-input <ds-input
id="about" id="about"
@ -158,3 +161,12 @@ export default {
}, },
} }
</script> </script>
<style lang="scss">
// .settings-form {
// >
.location-hint {
margin-top: -$space-x-small - $space-xxx-small - $space-xxx-small;
}
// }
</style>

View File

@ -10,6 +10,7 @@ describe('my-social-media.vue', () => {
let wrapper let wrapper
let mocks let mocks
let getters let getters
let mutations
const socialMediaUrl = 'https://freeradical.zone/@mattwr18' const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const newSocialMediaUrl = 'https://twitter.com/mattwr18' const newSocialMediaUrl = 'https://twitter.com/mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico' const faviconUrl = 'https://freeradical.zone/favicon.ico'
@ -30,6 +31,9 @@ describe('my-social-media.vue', () => {
return {} return {}
}, },
} }
mutations = {
'modal/SET_OPEN': jest.fn().mockResolvedValueOnce(),
}
}) })
describe('mount', () => { describe('mount', () => {
@ -37,6 +41,7 @@ describe('my-social-media.vue', () => {
const Wrapper = () => { const Wrapper = () => {
const store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
mutations,
}) })
return mount(MySocialMedia, { store, mocks, localVue }) return mount(MySocialMedia, { store, mocks, localVue })
} }
@ -145,11 +150,14 @@ describe('my-social-media.vue', () => {
}) })
}) })
describe('deleting social media link', () => { // TODO: confirm deletion modal is not present
describe.skip('deleting social media link', () => {
beforeEach(async () => { beforeEach(async () => {
const deleteButton = wrapper.find('.base-button[data-test="delete-button"]') const deleteButton = wrapper.find('.base-button[data-test="delete-button"]')
deleteButton.trigger('click') deleteButton.trigger('click')
await Vue.nextTick() await Vue.nextTick()
// wrapper.find('button.cancel').trigger('click')
// await Vue.nextTick()
}) })
it('sends the link id to the backend', () => { it('sends the link id to the backend', () => {

View File

@ -7,13 +7,8 @@
:useItems="socialMediaLinks" :useItems="socialMediaLinks"
:defaultItem="{ url: '' }" :defaultItem="{ url: '' }"
:namePropertyKey="'url'" :namePropertyKey="'url'"
:callbacks="{ :texts="mySomethingListTexts"
handleInput: () => {}, :callbacks="mySomethingListCallbacks"
handleInputValid,
edit: callbackEditSocialMedia,
submit: handleSubmitSocialMedia,
delete: callbackDeleteSocialMedia,
}"
> >
<template #list-item="{ item }"> <template #list-item="{ item }">
<social-media-list-item :item="item" /> <social-media-list-item :item="item" />
@ -33,7 +28,11 @@
<script> <script>
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import unionBy from 'lodash/unionBy' import unionBy from 'lodash/unionBy'
import gql from 'graphql-tag' import {
createSocialMediaMutation,
updateSocialMediaMutation,
deleteSocialMediaMutation,
} from '~/graphql/SocialMedia.js'
import MySomethingList from '~/components/_new/features/MySomethingList/MySomethingList.vue' import MySomethingList from '~/components/_new/features/MySomethingList/MySomethingList.vue'
import SocialMediaListItem from '~/components/_new/features/SocialMedia/SocialMediaListItem.vue' import SocialMediaListItem from '~/components/_new/features/SocialMedia/SocialMediaListItem.vue'
@ -77,6 +76,30 @@ export default {
return { id, url, favicon } return { id, url, favicon }
}) })
}, },
mySomethingListTexts() {
return {
addButton: this.$t('settings.social-media.submit'),
addNew: this.$t('settings.social-media.add-new-link'),
deleteModal: {
titleIdent: 'settings.social-media.delete-modal.title',
messageIdent: 'settings.social-media.delete-modal.message',
confirm: {
icon: 'trash',
buttonTextIdent: 'settings.social-media.delete-modal.confirm-button',
},
},
edit: this.$t('settings.social-media.edit-link'),
}
},
mySomethingListCallbacks() {
return {
handleInput: () => {},
handleInputValid: this.handleInputValid,
edit: this.callbackEditSocialMedia,
submit: this.handleSubmitSocialMedia,
delete: this.callbackDeleteSocialMedia,
}
},
}, },
methods: { methods: {
...mapMutations({ ...mapMutations({
@ -93,7 +116,7 @@ export default {
thisList.formData.socialMediaUrl = link.url thisList.formData.socialMediaUrl = link.url
// try to set focus on link edit field // try to set focus on link edit field
// thisList.$refs.socialMediaUrl.$el.focus() // thisList.$refs.socialMediaUrl.$el.focus()
// !!! Check for existenz // !!! check for existence
// this.$scopedSlots.default()[0].context.$refs // this.$scopedSlots.default()[0].context.$refs
// thisList.$scopedSlots['edit-item']()[0].$el.focus() // thisList.$scopedSlots['edit-item']()[0].$el.focus()
// console.log(thisList.$scopedSlots['edit-item']()[0].context.$refs) // console.log(thisList.$scopedSlots['edit-item']()[0].context.$refs)
@ -111,25 +134,11 @@ export default {
let mutation, variables, successMessage let mutation, variables, successMessage
if (isCreation) { if (isCreation) {
mutation = gql` mutation = createSocialMediaMutation()
mutation ($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url: item.url } variables = { url: item.url }
successMessage = thisList.$t('settings.social-media.successAdd') successMessage = thisList.$t('settings.social-media.successAdd')
} else { } else {
mutation = gql` mutation = updateSocialMediaMutation()
mutation ($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables = { id: item.id, url: item.url } variables = { id: item.id, url: item.url }
successMessage = thisList.$t('settings.data.success') successMessage = thisList.$t('settings.data.success')
} }
@ -159,14 +168,7 @@ export default {
async callbackDeleteSocialMedia(thisList, item) { async callbackDeleteSocialMedia(thisList, item) {
try { try {
await thisList.$apollo.mutate({ await thisList.$apollo.mutate({
mutation: gql` mutation: deleteSocialMediaMutation(),
mutation ($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`,
variables: { variables: {
id: item.id, id: item.id,
}, },

View File

@ -0,0 +1,35 @@
// Vue2 + Mapbox Reference: https://github.com/geospoc/v-mapbox/issues/702
import Vue from 'vue'
import {
MglGeojsonLayer,
MglVectorLayer,
MglMap,
MglMarker,
MglPopup,
MglAttributionControl,
MglScaleControl,
MglNavigationControl,
MglGeolocateControl,
MglFullscreenControl,
} from 'v-mapbox'
// Map
Vue.component('MglMap', MglMap)
// overview of all: https://github.com/geospoc/v-mapbox/tree/v1.11.2/src/components
// mapbox: https://docs.mapbox.com/mapbox-gl-js/api/markers/
// Controls
Vue.component('MglAttributionControl', MglAttributionControl)
Vue.component('MglScaleControl', MglScaleControl)
Vue.component('MglNavigationControl', MglNavigationControl)
Vue.component('MglGeolocateControl', MglGeolocateControl)
Vue.component('MglFullscreenControl', MglFullscreenControl)
// Layers
Vue.component('MglGeojsonLayer', MglGeojsonLayer)
Vue.component('MglVectorLayer', MglVectorLayer)
// Marker & Popup
Vue.component('MglMarker', MglMarker)
Vue.component('MglPopup', MglPopup)

View File

@ -0,0 +1,12 @@
# Mabbox markers
I found the Mapbox markers to be downloaded at the bottom of the page:
<https://docs.mapbox.com/help/glossary/sprite/>
At URL:
<https://docs.mapbox.com/help/data/marker-icons.zip>
## Folder For Images Reachable By URL
It looks like that not all folders, as example the `assets/*` folder, is reachable by URL.
Our images have to be in the `static/img/*` folder.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#4264fb" stroke="#314ccd" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#314ccd" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#5b7897" stroke="#23374d" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#23374d" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#33c377" stroke="#269561" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#269561" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#f79640" stroke="#ba7334" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#ba7334" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#ee4e8b" stroke="#b43b71" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#b43b71" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#7753eb" stroke="#5a3fc0" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#5a3fc0" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#f84d4d" stroke="#951212" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#951212" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
<!-- Create a custom map style: https://studio.mapbox.com -->
<svg id="marker" data-name="marker" xmlns="http://www.w3.org/2000/svg" width="20" height="48" viewBox="0 0 20 48">
<g id="mapbox-marker-icon">
<g id="icon">
<ellipse id="shadow" cx="10" cy="27" rx="9" ry="5" fill="#c4c4c4" opacity="0.3" style="isolation: isolate"/>
<g id="mask" opacity="0.3">
<g id="group">
<path id="shadow-2" data-name="shadow" fill="#bfbfbf" d="M10,32c5,0,9-2.2,9-5s-4-5-9-5-9,2.2-9,5S5,32,10,32Z" fill-rule="evenodd"/>
</g>
</g>
<path id="color" fill="#d9d838" stroke="#a4a62d" stroke-width="0.5" d="M19.25,10.4a13.0663,13.0663,0,0,1-1.4607,5.2235,41.5281,41.5281,0,0,1-3.2459,5.5483c-1.1829,1.7369-2.3662,3.2784-3.2541,4.3859-.4438.5536-.8135.9984-1.0721,1.3046-.0844.1-.157.1852-.2164.2545-.06-.07-.1325-.1564-.2173-.2578-.2587-.3088-.6284-.7571-1.0723-1.3147-.8879-1.1154-2.0714-2.6664-3.2543-4.41a42.2677,42.2677,0,0,1-3.2463-5.5535A12.978,12.978,0,0,1,.75,10.4,9.4659,9.4659,0,0,1,10,.75,9.4659,9.4659,0,0,1,19.25,10.4Z"/>
<path id="circle" fill="#fff" stroke="#a4a62d" stroke-width="0.5" d="M13.55,10A3.55,3.55,0,1,1,10,6.45,3.5484,3.5484,0,0,1,13.55,10Z"/>
</g>
</g>
<rect width="20" height="48" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large Load Diff

View File

@ -4422,10 +4422,10 @@ mz@^2.4.0:
object-assign "^4.0.1" object-assign "^4.0.1"
thenify-all "^1.0.0" thenify-all "^1.0.0"
nan@^2.9.2: nan@2.17.0, nan@^2.9.2:
version "2.13.1" version "2.17.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.1.tgz#a15bee3790bde247e8f38f1d446edcdaeb05f2dd" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb"
integrity sha512-I6YB/YEuDeUZMmhscXKxGgZlFnhsn5y0hgOZBadkzfTRrZBtJDZeg6eQf7PYMIEclwmorTKK8GztsyOUSVBREA== integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
nanomatch@^1.2.9: nanomatch@^1.2.9:
version "1.2.13" version "1.2.13"