mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
Merge branch 'master' of https://github.com/Human-Connection/Human-Connection into 1017-send-out-notifications-on-create-omment
# Conflicts: # backend/src/schema/resolvers/notifications.spec.js
This commit is contained in:
commit
22b9bf77fa
@ -26,8 +26,8 @@ script:
|
||||
# Backend
|
||||
- docker-compose exec backend yarn run lint
|
||||
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage
|
||||
- docker-compose exec backend yarn run db:reset
|
||||
- docker-compose exec backend yarn run db:seed
|
||||
- docker-compose exec backend yarn run db:reset
|
||||
# ActivityPub cucumber testing temporarily disabled because it's too buggy
|
||||
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
|
||||
# - docker-compose exec backend yarn run db:reset
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
* [HTTPS](deployment/digital-ocean/https/README.md)
|
||||
* [Human Connection](deployment/human-connection/README.md)
|
||||
* [Mailserver](deployment/human-connection/mailserver/README.md)
|
||||
* [Maintenance](deployment/human-connection/maintenance/README.md)
|
||||
* [Volumes](deployment/volumes/README.md)
|
||||
* [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md)
|
||||
* [Volume Snapshots](deployment/volumes/volume-snapshots/README.md)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:12.6-alpine as base
|
||||
FROM node:12.7-alpine as base
|
||||
LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"dev": "nodemon --exec babel-node src/ -e js,gql",
|
||||
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
|
||||
"lint": "eslint src --config .eslintrc.js",
|
||||
"jest": "jest --forceExit --detectOpenHandles --runInBand",
|
||||
"test": "run-s test:jest test:cucumber",
|
||||
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
|
||||
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
|
||||
@ -47,13 +48,14 @@
|
||||
"apollo-client": "~2.6.3",
|
||||
"apollo-link-context": "~1.0.18",
|
||||
"apollo-link-http": "~1.5.15",
|
||||
"apollo-server": "~2.6.9",
|
||||
"apollo-server-express": "^2.6.9",
|
||||
"apollo-server": "~2.8.1",
|
||||
"apollo-server-express": "^2.8.1",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"bcryptjs": "~2.4.3",
|
||||
"cheerio": "~1.0.0-rc.3",
|
||||
"cors": "~2.8.5",
|
||||
"cross-env": "~5.2.0",
|
||||
"date-fns": "2.0.0-beta.1",
|
||||
"date-fns": "2.0.0-beta.3",
|
||||
"debug": "~4.1.1",
|
||||
"dotenv": "~8.0.0",
|
||||
"express": "^4.17.1",
|
||||
@ -61,17 +63,33 @@
|
||||
"graphql": "~14.4.2",
|
||||
"graphql-custom-directives": "~0.2.14",
|
||||
"graphql-iso-date": "~3.6.1",
|
||||
"graphql-middleware": "~3.0.2",
|
||||
"graphql-shield": "~6.0.3",
|
||||
"graphql-middleware": "~3.0.3",
|
||||
"graphql-shield": "~6.0.4",
|
||||
"graphql-tag": "~2.10.1",
|
||||
"helmet": "~3.18.0",
|
||||
"helmet": "~3.20.0",
|
||||
"jsonwebtoken": "~8.5.1",
|
||||
"linkifyjs": "~2.1.8",
|
||||
"lodash": "~4.17.14",
|
||||
"merge-graphql-schemas": "^1.5.8",
|
||||
"neo4j-driver": "~1.7.4",
|
||||
"merge-graphql-schemas": "^1.7.0",
|
||||
"metascraper": "^4.10.3",
|
||||
"metascraper-audio": "^5.6.5",
|
||||
"metascraper-author": "^5.6.5",
|
||||
"metascraper-clearbit-logo": "^5.3.0",
|
||||
"metascraper-date": "^5.6.5",
|
||||
"metascraper-description": "^5.6.5",
|
||||
"metascraper-image": "^5.6.5",
|
||||
"metascraper-lang": "^5.6.3",
|
||||
"metascraper-lang-detector": "^4.8.5",
|
||||
"metascraper-logo": "^5.6.5",
|
||||
"metascraper-publisher": "^5.6.5",
|
||||
"metascraper-soundcloud": "^5.6.5",
|
||||
"metascraper-title": "^5.6.5",
|
||||
"metascraper-url": "^5.6.5",
|
||||
"metascraper-video": "^5.6.5",
|
||||
"metascraper-youtube": "^5.6.3",
|
||||
"neo4j-driver": "~1.7.5",
|
||||
"neo4j-graphql-js": "^2.6.3",
|
||||
"neode": "^0.2.16",
|
||||
"neode": "^0.3.0",
|
||||
"node-fetch": "~2.6.0",
|
||||
"nodemailer": "^6.3.0",
|
||||
"npm-run-all": "~4.1.5",
|
||||
@ -83,23 +101,23 @@
|
||||
"wait-on": "~3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "~7.5.0",
|
||||
"@babel/core": "~7.5.4",
|
||||
"@babel/node": "~7.5.0",
|
||||
"@babel/cli": "~7.5.5",
|
||||
"@babel/core": "~7.5.5",
|
||||
"@babel/node": "~7.5.5",
|
||||
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
|
||||
"@babel/preset-env": "~7.5.4",
|
||||
"@babel/register": "~7.4.4",
|
||||
"apollo-server-testing": "~2.6.9",
|
||||
"@babel/preset-env": "~7.5.5",
|
||||
"@babel/register": "~7.5.5",
|
||||
"apollo-server-testing": "~2.8.1",
|
||||
"babel-core": "~7.0.0-0",
|
||||
"babel-eslint": "~10.0.2",
|
||||
"babel-jest": "~24.8.0",
|
||||
"chai": "~4.2.0",
|
||||
"cucumber": "~5.1.0",
|
||||
"eslint": "~6.0.1",
|
||||
"eslint": "~6.1.0",
|
||||
"eslint-config-prettier": "~6.0.0",
|
||||
"eslint-config-standard": "~12.0.0",
|
||||
"eslint-plugin-import": "~2.18.0",
|
||||
"eslint-plugin-jest": "~22.8.0",
|
||||
"eslint-config-standard": "~13.0.1",
|
||||
"eslint-plugin-import": "~2.18.2",
|
||||
"eslint-plugin-jest": "~22.14.1",
|
||||
"eslint-plugin-node": "~9.1.0",
|
||||
"eslint-plugin-prettier": "~3.1.0",
|
||||
"eslint-plugin-promise": "~4.2.1",
|
||||
|
||||
@ -35,8 +35,8 @@ export default class ActivityPub {
|
||||
|
||||
handleFollowActivity(activity) {
|
||||
debug(`inside FOLLOW ${activity.actor}`)
|
||||
let toActorName = extractNameFromId(activity.object)
|
||||
let fromDomain = extractDomainFromUrl(activity.actor)
|
||||
const toActorName = extractNameFromId(activity.object)
|
||||
const fromDomain = extractDomainFromUrl(activity.actor)
|
||||
const dataSource = this.dataSource
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -53,7 +53,7 @@ export default class ActivityPub {
|
||||
toActorObject = JSON.parse(toActorObject)
|
||||
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
|
||||
|
||||
let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
|
||||
const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
|
||||
activity.object,
|
||||
)
|
||||
|
||||
@ -222,6 +222,7 @@ export default class ActivityPub {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async trySend(activity, fromName, host, url, tries = 5) {
|
||||
try {
|
||||
return await signAndSend(activity, fromName, host, url)
|
||||
|
||||
@ -2,6 +2,7 @@ export default class Collections {
|
||||
constructor(dataSource) {
|
||||
this.dataSource = dataSource
|
||||
}
|
||||
|
||||
getFollowersCollection(actorId) {
|
||||
return this.dataSource.getFollowersCollection(actorId)
|
||||
}
|
||||
|
||||
@ -303,6 +303,7 @@ export default class NitroDataSource {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async saveFollowingCollectionPage(followingCollection, onlyNewestItem = true) {
|
||||
debug('inside saveFollowers')
|
||||
let orderedItems = followingCollection.orderedItems
|
||||
@ -470,6 +471,7 @@ export default class NitroDataSource {
|
||||
throwErrorIfApolloErrorOccurred(result)
|
||||
return result.data.SharedInboxEnpoint
|
||||
}
|
||||
|
||||
async addSharedInboxEndpoint(uri) {
|
||||
try {
|
||||
const result = await this.client.mutate({
|
||||
|
||||
@ -97,7 +97,7 @@ export function verifySignature(url, headers) {
|
||||
// private: signing
|
||||
function constructSigningString(url, headers) {
|
||||
const urlObj = new URL(url)
|
||||
let signingString = `(request-target): post ${urlObj.pathname}${
|
||||
const signingString = `(request-target): post ${urlObj.pathname}${
|
||||
urlObj.search !== '' ? urlObj.search : ''
|
||||
}`
|
||||
return Object.keys(headers).reduce((result, key) => {
|
||||
|
||||
@ -40,70 +40,72 @@ export function signAndSend(activity, fromName, targetDomain, url) {
|
||||
// fix for development: replace with http
|
||||
url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url
|
||||
debug(`passhprase = ${CONFIG.PRIVATE_KEY_PASSPHRASE}`)
|
||||
return new Promise(async (resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
debug('inside signAndSend')
|
||||
// get the private key
|
||||
const result = await activityPub.dataSource.client.query({
|
||||
query: gql`
|
||||
activityPub.dataSource.client
|
||||
.query({
|
||||
query: gql`
|
||||
query {
|
||||
User(slug: "${fromName}") {
|
||||
privateKey
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
})
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
reject(result.error)
|
||||
} else {
|
||||
// add security context
|
||||
const parsedActivity = JSON.parse(activity)
|
||||
if (Array.isArray(parsedActivity['@context'])) {
|
||||
parsedActivity['@context'].push('https://w3id.org/security/v1')
|
||||
} else {
|
||||
const context = [parsedActivity['@context']]
|
||||
context.push('https://w3id.org/security/v1')
|
||||
parsedActivity['@context'] = context
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
reject(result.error)
|
||||
} else {
|
||||
// add security context
|
||||
const parsedActivity = JSON.parse(activity)
|
||||
if (Array.isArray(parsedActivity['@context'])) {
|
||||
parsedActivity['@context'].push('https://w3id.org/security/v1')
|
||||
} else {
|
||||
const context = [parsedActivity['@context']]
|
||||
context.push('https://w3id.org/security/v1')
|
||||
parsedActivity['@context'] = context
|
||||
}
|
||||
// deduplicate context strings
|
||||
parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
|
||||
const privateKey = result.data.User[0].privateKey
|
||||
const date = new Date().toUTCString()
|
||||
|
||||
// deduplicate context strings
|
||||
parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
|
||||
const privateKey = result.data.User[0].privateKey
|
||||
const date = new Date().toUTCString()
|
||||
|
||||
debug(`url = ${url}`)
|
||||
request(
|
||||
{
|
||||
url: url,
|
||||
headers: {
|
||||
Host: targetDomain,
|
||||
Date: date,
|
||||
Signature: createSignature({
|
||||
privateKey,
|
||||
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
|
||||
url,
|
||||
debug(`url = ${url}`)
|
||||
request(
|
||||
{
|
||||
url: url,
|
||||
headers: {
|
||||
Host: targetDomain,
|
||||
Date: date,
|
||||
Signature: createSignature({
|
||||
privateKey,
|
||||
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
|
||||
url,
|
||||
headers: {
|
||||
Host: targetDomain,
|
||||
Date: date,
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}),
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}),
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(parsedActivity),
|
||||
},
|
||||
(error, response) => {
|
||||
if (error) {
|
||||
debug(`Error = ${JSON.stringify(error, null, 2)}`)
|
||||
reject(error)
|
||||
} else {
|
||||
debug('Response Headers:', JSON.stringify(response.headers, null, 2))
|
||||
debug('Response Body:', JSON.stringify(response.body, null, 2))
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
method: 'POST',
|
||||
body: JSON.stringify(parsedActivity),
|
||||
},
|
||||
(error, response) => {
|
||||
if (error) {
|
||||
debug(`Error = ${JSON.stringify(error, null, 2)}`)
|
||||
reject(error)
|
||||
} else {
|
||||
debug('Response Headers:', JSON.stringify(response.headers, null, 2))
|
||||
debug('Response Body:', JSON.stringify(response.body, null, 2))
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
38
backend/src/jest/snapshots/embeds/HumanConnectionOrg.html
Normal file
38
backend/src/jest/snapshots/embeds/HumanConnectionOrg.html
Normal file
File diff suppressed because one or more lines are too long
1545
backend/src/jest/snapshots/embeds/babyLovesCat.html
Normal file
1545
backend/src/jest/snapshots/embeds/babyLovesCat.html
Normal file
File diff suppressed because one or more lines are too long
10042
backend/src/jest/snapshots/embeds/pr960.html
Normal file
10042
backend/src/jest/snapshots/embeds/pr960.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -52,10 +52,12 @@ export default schema => {
|
||||
if (CONFIG.DISABLED_MIDDLEWARES) {
|
||||
const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',')
|
||||
order = order.filter(key => {
|
||||
if (disabledMiddlewares.includes(key)) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(`Warning: Disabled "${disabledMiddlewares}" middleware.`)
|
||||
}
|
||||
return !disabledMiddlewares.includes(key)
|
||||
})
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(`Warning: "${disabledMiddlewares}" middlewares have been disabled.`)
|
||||
}
|
||||
|
||||
const appliedMiddlewares = order.map(key => middlewares[key])
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
|
||||
import { neode } from '../bootstrap/neo4j'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
/*
|
||||
* TODO: implement
|
||||
@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
|
||||
const isAuthenticated = rule({
|
||||
cache: 'contextual',
|
||||
})(async (_parent, _args, ctx, _info) => {
|
||||
return ctx.user !== null
|
||||
return ctx.user != null
|
||||
})
|
||||
|
||||
const isModerator = rule()(async (parent, args, { user }, info) => {
|
||||
@ -30,6 +33,14 @@ const isMyOwn = rule({
|
||||
return context.user.id === parent.id
|
||||
})
|
||||
|
||||
const isMySocialMedia = rule({
|
||||
cache: 'no_cache',
|
||||
})(async (_, args, { user }) => {
|
||||
let socialMedia = await instance.find('SocialMedia', args.id)
|
||||
socialMedia = await socialMedia.toJson()
|
||||
return socialMedia.ownedBy.node.id === user.id
|
||||
})
|
||||
|
||||
const belongsToMe = rule({
|
||||
cache: 'no_cache',
|
||||
})(async (_, args, context) => {
|
||||
@ -86,8 +97,6 @@ const invitationLimitReached = rule({
|
||||
return record.get('limitReached')
|
||||
})
|
||||
return limitReached
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
@ -136,6 +145,7 @@ const permissions = shield(
|
||||
Query: {
|
||||
'*': deny,
|
||||
findPosts: allow,
|
||||
embed: allow,
|
||||
Category: allow,
|
||||
Tag: allow,
|
||||
Report: isModerator,
|
||||
@ -162,7 +172,8 @@ const permissions = shield(
|
||||
DeletePost: isAuthor,
|
||||
report: isAuthenticated,
|
||||
CreateSocialMedia: isAuthenticated,
|
||||
DeleteSocialMedia: isAuthenticated,
|
||||
UpdateSocialMedia: isMySocialMedia,
|
||||
DeleteSocialMedia: isMySocialMedia,
|
||||
// AddBadgeRewarded: isAdmin,
|
||||
// RemoveBadgeRewarded: isAdmin,
|
||||
reward: isAdmin,
|
||||
@ -176,6 +187,7 @@ const permissions = shield(
|
||||
enable: isModerator,
|
||||
disable: isModerator,
|
||||
CreateComment: isAuthenticated,
|
||||
UpdateComment: isAuthor,
|
||||
DeleteComment: isAuthor,
|
||||
DeleteUser: isDeletingOwnAccount,
|
||||
requestPasswordReset: allow,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import slugify from 'slug'
|
||||
export default async function uniqueSlug(string, isUnique) {
|
||||
let slug = slugify(string || 'anonymous', {
|
||||
const slug = slugify(string || 'anonymous', {
|
||||
lower: true,
|
||||
})
|
||||
if (await isUnique(slug)) return slug
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
|
||||
const validateUrl = async (resolve, root, args, context, info) => {
|
||||
const { url } = args
|
||||
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
|
||||
if (isValid) {
|
||||
/* eslint-disable-next-line no-return-await */
|
||||
return await resolve(root, args, context, info)
|
||||
} else {
|
||||
throw new UserInputError('Input is not a URL')
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateSocialMedia: validateUrl,
|
||||
},
|
||||
}
|
||||
@ -1,23 +1,8 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import Joi from '@hapi/joi'
|
||||
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
|
||||
|
||||
const validate = schema => {
|
||||
return async (resolve, root, args, context, info) => {
|
||||
const validation = schema.validate(args)
|
||||
if (validation.error) throw new UserInputError(validation.error)
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
}
|
||||
|
||||
const socialMediaSchema = Joi.object().keys({
|
||||
url: Joi.string()
|
||||
.uri()
|
||||
.required(),
|
||||
})
|
||||
|
||||
const validateCommentCreation = async (resolve, root, args, context, info) => {
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
const { postId } = args
|
||||
@ -45,9 +30,19 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateUpdateComment = async (resolve, root, args, context, info) => {
|
||||
const COMMENT_MIN_LENGTH = 1
|
||||
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
|
||||
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
|
||||
}
|
||||
|
||||
return resolve(root, args, context, info)
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateSocialMedia: validate(socialMediaSchema),
|
||||
CreateComment: validateCommentCreation,
|
||||
UpdateComment: validateUpdateComment,
|
||||
},
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import linkifyHtml from 'linkifyjs/html'
|
||||
const embedToAnchor = content => {
|
||||
const $ = cheerio.load(content)
|
||||
$('div[data-url-embed]').each((i, el) => {
|
||||
let url = el.attribs['data-url-embed']
|
||||
let aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
|
||||
const url = el.attribs['data-url-embed']
|
||||
const aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
|
||||
$(el).replaceWith(aTag)
|
||||
})
|
||||
return $('body').html()
|
||||
@ -87,7 +87,7 @@ function clean(dirty) {
|
||||
b: 'strong',
|
||||
s: 'strike',
|
||||
img: function(tagName, attribs) {
|
||||
let src = attribs.src
|
||||
const src = attribs.src
|
||||
|
||||
if (!src) {
|
||||
// remove broken images
|
||||
|
||||
@ -8,5 +8,6 @@ module.exports = {
|
||||
relationship: 'BELONGS_TO',
|
||||
target: 'User',
|
||||
direction: 'out',
|
||||
eager: true,
|
||||
},
|
||||
}
|
||||
|
||||
15
backend/src/models/SocialMedia.js
Normal file
15
backend/src/models/SocialMedia.js
Normal file
@ -0,0 +1,15 @@
|
||||
import uuid from 'uuid/v4'
|
||||
|
||||
module.exports = {
|
||||
id: { type: 'string', primary: true, default: uuid },
|
||||
url: { type: 'string', uri: true, required: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
ownedBy: {
|
||||
type: 'relationship',
|
||||
relationship: 'OWNED_BY',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
eager: true,
|
||||
cascade: 'detach',
|
||||
},
|
||||
}
|
||||
@ -3,8 +3,7 @@ import uuid from 'uuid/v4'
|
||||
module.exports = {
|
||||
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
|
||||
actorId: { type: 'string', allow: [null] },
|
||||
name: { type: 'string', min: 3 },
|
||||
email: { type: 'string', lowercase: true, email: true },
|
||||
name: { type: 'string', disallow: [null], min: 3 },
|
||||
slug: 'string',
|
||||
encryptedPassword: 'string',
|
||||
avatar: { type: 'string', allow: [null] },
|
||||
|
||||
@ -5,4 +5,5 @@ export default {
|
||||
User: require('./User.js'),
|
||||
InvitationCode: require('./InvitationCode.js'),
|
||||
EmailAddress: require('./EmailAddress.js'),
|
||||
SocialMedia: require('./SocialMedia.js'),
|
||||
}
|
||||
|
||||
9
backend/src/schema/helpers.js
Normal file
9
backend/src/schema/helpers.js
Normal file
@ -0,0 +1,9 @@
|
||||
export const undefinedToNull = list => {
|
||||
const resolvers = {}
|
||||
list.forEach(key => {
|
||||
resolvers[key] = async (parent, params, context, resolveInfo) => {
|
||||
return typeof parent[key] === 'undefined' ? null : parent[key]
|
||||
}
|
||||
})
|
||||
return resolvers
|
||||
}
|
||||
@ -19,6 +19,7 @@ export default applyScalars(
|
||||
'Notfication',
|
||||
'Statistics',
|
||||
'LoggedInUser',
|
||||
'SocialMedia',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
@ -30,6 +31,7 @@ export default applyScalars(
|
||||
'Notfication',
|
||||
'Statistics',
|
||||
'LoggedInUser',
|
||||
'SocialMedia',
|
||||
],
|
||||
// add 'User' here as soon as possible
|
||||
},
|
||||
|
||||
@ -18,7 +18,7 @@ export default {
|
||||
false,
|
||||
)
|
||||
|
||||
let transactionRes = await session.run(
|
||||
const transactionRes = await session.run(
|
||||
`
|
||||
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId})
|
||||
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
|
||||
|
||||
@ -5,11 +5,31 @@ import { host, login, gql } from '../../jest/helpers'
|
||||
const factory = Factory()
|
||||
let client
|
||||
let createCommentVariables
|
||||
let createPostVariables
|
||||
let createCommentVariablesSansPostId
|
||||
let createCommentVariablesWithNonExistentPost
|
||||
let userParams
|
||||
let authorParams
|
||||
let headers
|
||||
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
CreatePost(id: $id, title: $title, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const createCommentMutation = gql`
|
||||
mutation($id: ID, $postId: ID!, $content: String!) {
|
||||
CreateComment(id: $id, postId: $postId, content: $content) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostVariables = {
|
||||
id: 'p1',
|
||||
title: 'post to comment on',
|
||||
content: 'please comment on me',
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
userParams = {
|
||||
@ -25,21 +45,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('CreateComment', () => {
|
||||
const createCommentMutation = gql`
|
||||
mutation($postId: ID!, $content: String!) {
|
||||
CreateComment(postId: $postId, content: $content) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
`
|
||||
const createPostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!) {
|
||||
CreatePost(id: $id, title: $title, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
createCommentVariables = {
|
||||
@ -54,7 +59,6 @@ describe('CreateComment', () => {
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
beforeEach(async () => {
|
||||
headers = await login(userParams)
|
||||
client = new GraphQLClient(host, {
|
||||
@ -64,11 +68,6 @@ describe('CreateComment', () => {
|
||||
postId: 'p1',
|
||||
content: "I'm authorised to comment",
|
||||
}
|
||||
createPostVariables = {
|
||||
id: 'p1',
|
||||
title: 'post to comment on',
|
||||
content: 'please comment on me',
|
||||
}
|
||||
await client.request(createPostMutation, createPostVariables)
|
||||
})
|
||||
|
||||
@ -187,19 +186,8 @@ describe('CreateComment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteComment', () => {
|
||||
const deleteCommentMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let deleteCommentVariables = {
|
||||
id: 'c1',
|
||||
}
|
||||
|
||||
describe('ManageComments', () => {
|
||||
let authorParams
|
||||
beforeEach(async () => {
|
||||
authorParams = {
|
||||
email: 'author@example.org',
|
||||
@ -213,51 +201,178 @@ describe('DeleteComment', () => {
|
||||
content: 'Post to be commented',
|
||||
})
|
||||
await asAuthor.create('Comment', {
|
||||
id: 'c1',
|
||||
id: 'c456',
|
||||
postId: 'p1',
|
||||
content: 'Comment to be deleted',
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
let headers
|
||||
headers = await login(userParams)
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
let headers
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, { headers })
|
||||
})
|
||||
|
||||
it('deletes the comment', async () => {
|
||||
const expected = {
|
||||
DeleteComment: {
|
||||
id: 'c1',
|
||||
},
|
||||
describe('UpdateComment', () => {
|
||||
const updateCommentMutation = gql`
|
||||
mutation($content: String!, $id: ID!) {
|
||||
UpdateComment(content: $content, id: $id) {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual(
|
||||
expected,
|
||||
)
|
||||
`
|
||||
|
||||
let updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: 'The comment is updated',
|
||||
}
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the comment', async () => {
|
||||
const expected = {
|
||||
UpdateComment: {
|
||||
id: 'c456',
|
||||
content: 'The comment is updated',
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(updateCommentMutation, updateCommentVariables),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('throw an error if an empty string is sent from the editor as content', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: '<p></p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Comment must be at least 1 character long!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if a comment sent from the editor does not contain a single letter character', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c456',
|
||||
content: '<p> </p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Comment must be at least 1 character long!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if commentId is sent as an empty string', async () => {
|
||||
updateCommentVariables = {
|
||||
id: '',
|
||||
content: '<p>Hello</p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised!',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the comment does not exist in the database', async () => {
|
||||
updateCommentVariables = {
|
||||
id: 'c1000',
|
||||
content: '<p>Hello</p>',
|
||||
}
|
||||
|
||||
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised!',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteComment', () => {
|
||||
const deleteCommentMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteComment(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const deleteCommentVariables = {
|
||||
id: 'c456',
|
||||
}
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated but not the author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
|
||||
'Not Authorised',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as author', () => {
|
||||
beforeEach(async () => {
|
||||
headers = await login(authorParams)
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the comment', async () => {
|
||||
const expected = {
|
||||
DeleteComment: {
|
||||
id: 'c456',
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
client.request(deleteCommentMutation, deleteCommentVariables),
|
||||
).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
29
backend/src/schema/resolvers/embeds.js
Normal file
29
backend/src/schema/resolvers/embeds.js
Normal file
@ -0,0 +1,29 @@
|
||||
import scrape from './embeds/scraper.js'
|
||||
import { undefinedToNullResolver } from './helpers/Resolver'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
embed: async (object, { url }, context, resolveInfo) => {
|
||||
return scrape(url)
|
||||
},
|
||||
},
|
||||
Embed: {
|
||||
...undefinedToNullResolver([
|
||||
'type',
|
||||
'title',
|
||||
'author',
|
||||
'publisher',
|
||||
'date',
|
||||
'description',
|
||||
'url',
|
||||
'image',
|
||||
'audio',
|
||||
'video',
|
||||
'lang',
|
||||
'html',
|
||||
]),
|
||||
sources: async (parent, params, context, resolveInfo) => {
|
||||
return typeof parent.sources === 'undefined' ? [] : parent.sources
|
||||
},
|
||||
},
|
||||
}
|
||||
216
backend/src/schema/resolvers/embeds.spec.js
Normal file
216
backend/src/schema/resolvers/embeds.spec.js
Normal file
@ -0,0 +1,216 @@
|
||||
import fetch from 'node-fetch'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from '../../server'
|
||||
import { gql } from '../../jest/helpers'
|
||||
|
||||
jest.mock('node-fetch')
|
||||
const { Response } = jest.requireActual('node-fetch')
|
||||
|
||||
afterEach(() => {
|
||||
fetch.mockRestore()
|
||||
})
|
||||
|
||||
let variables = {}
|
||||
|
||||
const HumanConnectionOrg = fs.readFileSync(
|
||||
path.join(__dirname, '../../jest/snapshots/embeds/HumanConnectionOrg.html'),
|
||||
'utf8',
|
||||
)
|
||||
const pr960 = fs.readFileSync(
|
||||
path.join(__dirname, '../../jest/snapshots/embeds/pr960.html'),
|
||||
'utf8',
|
||||
)
|
||||
const babyLovesCat = fs.readFileSync(
|
||||
path.join(__dirname, '../../jest/snapshots/embeds/babyLovesCat.html'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const babyLovesCatEmbedResponse = new Response(
|
||||
JSON.stringify({
|
||||
height: 270,
|
||||
provider_name: 'YouTube',
|
||||
title: 'Baby Loves Cat',
|
||||
type: 'video',
|
||||
width: 480,
|
||||
thumbnail_height: 360,
|
||||
provider_url: 'https://www.youtube.com/',
|
||||
thumbnail_width: 480,
|
||||
html:
|
||||
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
|
||||
thumbnail_url: 'https://i.ytimg.com/vi/qkdXAtO40Fo/hqdefault.jpg',
|
||||
version: '1.0',
|
||||
author_name: 'Merkley Family',
|
||||
author_url: 'https://www.youtube.com/channel/UC5P8yei950tif7UmdPpkJLQ',
|
||||
}),
|
||||
)
|
||||
|
||||
describe('Query', () => {
|
||||
describe('embed', () => {
|
||||
let embedAction
|
||||
|
||||
beforeEach(() => {
|
||||
embedAction = async variables => {
|
||||
const { server } = createServer({
|
||||
context: () => {},
|
||||
})
|
||||
const { query } = createTestClient(server)
|
||||
const embed = gql`
|
||||
query($url: String!) {
|
||||
embed(url: $url) {
|
||||
type
|
||||
title
|
||||
author
|
||||
publisher
|
||||
date
|
||||
description
|
||||
url
|
||||
image
|
||||
audio
|
||||
video
|
||||
lang
|
||||
sources
|
||||
html
|
||||
}
|
||||
}
|
||||
`
|
||||
return query({ query: embed, variables })
|
||||
}
|
||||
})
|
||||
|
||||
describe('given a video link', () => {
|
||||
beforeEach(() => {
|
||||
fetch
|
||||
.mockReturnValueOnce(Promise.resolve(new Response('')))
|
||||
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
|
||||
variables = { url: 'https://www.w3schools.com/html/mov_bbb.mp4' }
|
||||
})
|
||||
|
||||
it('shows some default data', async () => {
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
embed: {
|
||||
audio: null,
|
||||
author: null,
|
||||
date: null,
|
||||
description: null,
|
||||
html: null,
|
||||
image: null,
|
||||
lang: null,
|
||||
publisher: 'YouTube',
|
||||
sources: ['resource'],
|
||||
title: null,
|
||||
type: 'link',
|
||||
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
video: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(embedAction(variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a Facebook link', () => {
|
||||
beforeEach(() => {
|
||||
fetch
|
||||
.mockReturnValueOnce(Promise.resolve(new Response(HumanConnectionOrg)))
|
||||
.mockReturnValueOnce(Promise.resolve('invalid json'))
|
||||
variables = { url: 'https://www.facebook.com/HumanConnectionOrg/' }
|
||||
})
|
||||
|
||||
it('does not crash if embed provider returns invalid JSON', async () => {
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
embed: {
|
||||
audio: null,
|
||||
author: null,
|
||||
date: expect.any(String),
|
||||
description:
|
||||
'Human Connection, Weilheim an der Teck. Gefällt 24.407 Mal. An upcoming non-profit social network focused on local and global positive change. Twitter accounts : @hc_world (EN), @hc_deutschland (GE),...',
|
||||
html: null,
|
||||
image:
|
||||
'https://scontent.ftxl3-1.fna.fbcdn.net/v/t1.0-1/c5.0.200.200a/p200x200/12108307_997373093648222_70057205881020137_n.jpg?_nc_cat=110&_nc_oc=AQnPPYQlR0dU556gOfl4xkXr7IPZdRIAUfQeXl3fpUv4DAsFN8T4PfgOjPwuq85GPKGZ5S5E5mWQ8IVV1UiRBAIZ&_nc_ht=scontent.ftxl3-1.fna&oh=90309adddaab38839782f16e7d4b7bcf&oe=5DEEDFE5',
|
||||
lang: 'de',
|
||||
publisher: 'Facebook',
|
||||
sources: ['resource'],
|
||||
title: 'Human Connection',
|
||||
type: 'link',
|
||||
url: 'https://www.facebook.com/HumanConnectionOrg/',
|
||||
video: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(embedAction(variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a Github link', () => {
|
||||
beforeEach(() => {
|
||||
fetch
|
||||
.mockReturnValueOnce(Promise.resolve(new Response(pr960)))
|
||||
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
|
||||
variables = { url: 'https://github.com/Human-Connection/Human-Connection/pull/960' }
|
||||
})
|
||||
|
||||
it('returns meta data even if no embed html can be retrieved', async () => {
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
embed: {
|
||||
type: 'link',
|
||||
title:
|
||||
'Editor embeds merge in nitro embed by mattwr18 · Pull Request #960 · Human-Connection/Human-Connection',
|
||||
author: 'Human-Connection',
|
||||
publisher: 'GitHub',
|
||||
date: expect.any(String),
|
||||
description: '🍰 Pullrequest Issues fixes #256',
|
||||
url: 'https://github.com/Human-Connection/Human-Connection/pull/960',
|
||||
image:
|
||||
'https://repository-images.githubusercontent.com/112590397/52c9a000-7e11-11e9-899d-aaa55f3a3d72',
|
||||
audio: null,
|
||||
video: null,
|
||||
lang: 'en',
|
||||
sources: ['resource'],
|
||||
html: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(embedAction(variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a youtube link', () => {
|
||||
beforeEach(() => {
|
||||
fetch
|
||||
.mockReturnValueOnce(Promise.resolve(new Response(babyLovesCat)))
|
||||
.mockReturnValueOnce(Promise.resolve(babyLovesCatEmbedResponse))
|
||||
variables = { url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo&t=18s' }
|
||||
})
|
||||
|
||||
it('returns meta data plus youtube iframe html', async () => {
|
||||
const expected = expect.objectContaining({
|
||||
data: {
|
||||
embed: {
|
||||
type: 'video',
|
||||
title: 'Baby Loves Cat',
|
||||
author: 'Merkley Family',
|
||||
publisher: 'YouTube',
|
||||
date: expect.any(String),
|
||||
description:
|
||||
'She’s incapable of controlling her limbs when her kitty is around. The obsession grows every day. Ps. That’s a sleep sack she’s in. Not a starfish outfit. Al...',
|
||||
url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
|
||||
image: 'https://i.ytimg.com/vi/qkdXAtO40Fo/maxresdefault.jpg',
|
||||
audio: null,
|
||||
video: null,
|
||||
lang: 'de',
|
||||
sources: ['resource', 'oembed'],
|
||||
html:
|
||||
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(embedAction(variables)).resolves.toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
3014
backend/src/schema/resolvers/embeds/providers.json
Normal file
3014
backend/src/schema/resolvers/embeds/providers.json
Normal file
File diff suppressed because it is too large
Load Diff
102
backend/src/schema/resolvers/embeds/scraper.js
Normal file
102
backend/src/schema/resolvers/embeds/scraper.js
Normal file
@ -0,0 +1,102 @@
|
||||
import Metascraper from 'metascraper'
|
||||
import fetch from 'node-fetch'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { ApolloError } from 'apollo-server'
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
import isArray from 'lodash/isArray'
|
||||
import mergeWith from 'lodash/mergeWith'
|
||||
|
||||
const error = require('debug')('embed:error')
|
||||
|
||||
const metascraper = Metascraper([
|
||||
require('metascraper-author')(),
|
||||
require('metascraper-date')(),
|
||||
require('metascraper-description')(),
|
||||
require('metascraper-image')(),
|
||||
require('metascraper-lang')(),
|
||||
require('metascraper-lang-detector')(),
|
||||
require('metascraper-logo')(),
|
||||
// require('metascraper-clearbit-logo')(),
|
||||
require('metascraper-publisher')(),
|
||||
require('metascraper-title')(),
|
||||
require('metascraper-url')(),
|
||||
require('metascraper-audio')(),
|
||||
require('metascraper-soundcloud')(),
|
||||
require('metascraper-video')(),
|
||||
require('metascraper-youtube')(),
|
||||
|
||||
// require('./rules/metascraper-embed')()
|
||||
])
|
||||
|
||||
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
|
||||
|
||||
// some providers allow a format parameter
|
||||
// we need JSON
|
||||
oEmbedProvidersFile = oEmbedProvidersFile.replace('{format}', 'json')
|
||||
|
||||
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
|
||||
|
||||
const fetchEmbed = async url => {
|
||||
const provider = oEmbedProviders.find(provider => {
|
||||
return provider.provider_url.includes(url.hostname)
|
||||
})
|
||||
if (!provider) return {}
|
||||
const {
|
||||
endpoints: [endpoint],
|
||||
} = provider
|
||||
const endpointUrl = new URL(endpoint.url)
|
||||
endpointUrl.searchParams.append('url', url.href)
|
||||
endpointUrl.searchParams.append('format', 'json')
|
||||
let json
|
||||
try {
|
||||
const response = await fetch(endpointUrl)
|
||||
json = await response.json()
|
||||
} catch (err) {
|
||||
error(`Error fetching embed data: ${err.message}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
type: json.type,
|
||||
html: json.html,
|
||||
author: json.author_name,
|
||||
date: json.upload_date,
|
||||
sources: ['oembed'],
|
||||
}
|
||||
}
|
||||
|
||||
const fetchResource = async url => {
|
||||
const response = await fetch(url)
|
||||
const html = await response.text()
|
||||
const resource = await metascraper({ html, url: url.href })
|
||||
return {
|
||||
sources: ['resource'],
|
||||
...resource,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function scrape(url) {
|
||||
url = new URL(url)
|
||||
if (url.hostname === 'youtu.be') {
|
||||
// replace youtu.be to get proper results
|
||||
url.hostname = 'youtube.com'
|
||||
}
|
||||
|
||||
const [meta, embed] = await Promise.all([fetchResource(url), fetchEmbed(url)])
|
||||
const output = mergeWith(meta, embed, (objValue, srcValue) => {
|
||||
if (isArray(objValue)) {
|
||||
return objValue.concat(srcValue)
|
||||
}
|
||||
})
|
||||
|
||||
if (isEmpty(output)) {
|
||||
throw new ApolloError('Not found', 'NOT_FOUND')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link',
|
||||
...output,
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ export default {
|
||||
const { id, type } = params
|
||||
|
||||
const session = context.driver.session()
|
||||
let transactionRes = await session.run(
|
||||
const transactionRes = await session.run(
|
||||
`MATCH (node {id: $id}), (user:User {id: $userId})
|
||||
WHERE $type IN labels(node) AND NOT $id = $userId
|
||||
MERGE (user)-[relation:FOLLOWS]->(node)
|
||||
@ -29,7 +29,7 @@ export default {
|
||||
const { id, type } = params
|
||||
const session = context.driver.session()
|
||||
|
||||
let transactionRes = await session.run(
|
||||
const transactionRes = await session.run(
|
||||
`MATCH (user:User {id: $userId})-[relation:FOLLOWS]->(node {id: $id})
|
||||
WHERE $type IN labels(node)
|
||||
DELETE relation
|
||||
|
||||
@ -41,8 +41,7 @@ describe('follow', () => {
|
||||
describe('follow user', () => {
|
||||
describe('unauthenticated follow', () => {
|
||||
it('throws authorization error', async () => {
|
||||
let client
|
||||
client = new GraphQLClient(host)
|
||||
const client = new GraphQLClient(host)
|
||||
await expect(client.request(mutationFollowUser('u2'))).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
@ -93,8 +92,7 @@ describe('follow', () => {
|
||||
// follow
|
||||
await clientUser1.request(mutationFollowUser('u2'))
|
||||
// unfollow
|
||||
let client
|
||||
client = new GraphQLClient(host)
|
||||
const client = new GraphQLClient(host)
|
||||
await expect(client.request(mutationUnfollowUser('u2'))).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
75
backend/src/schema/resolvers/helpers/Resolver.js
Normal file
75
backend/src/schema/resolvers/helpers/Resolver.js
Normal file
@ -0,0 +1,75 @@
|
||||
import { neode } from '../../../bootstrap/neo4j'
|
||||
|
||||
export const undefinedToNullResolver = list => {
|
||||
const resolvers = {}
|
||||
list.forEach(key => {
|
||||
resolvers[key] = async (parent, params, context, resolveInfo) => {
|
||||
return typeof parent[key] === 'undefined' ? null : parent[key]
|
||||
}
|
||||
})
|
||||
return resolvers
|
||||
}
|
||||
|
||||
export default function Resolver(type, options = {}) {
|
||||
const instance = neode()
|
||||
const {
|
||||
idAttribute = 'id',
|
||||
undefinedToNull = [],
|
||||
count = {},
|
||||
hasOne = {},
|
||||
hasMany = {},
|
||||
} = options
|
||||
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
|
||||
return async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent[key] !== 'undefined') return parent[key]
|
||||
const id = parent[idAttribute]
|
||||
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
let response = result.records.map(r => r.get('related').properties)
|
||||
if (returnType === 'object') response = response[0] || null
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
const countResolver = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent[key] !== 'undefined') return parent[key]
|
||||
const id = parent[idAttribute]
|
||||
const statement = `
|
||||
MATCH(u:${type} {${idAttribute}: {id}})${connection}
|
||||
WHERE NOT related.deleted = true AND NOT related.disabled = true
|
||||
RETURN COUNT(DISTINCT(related)) as count
|
||||
`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
const [response] = result.records.map(r => r.get('count').toNumber())
|
||||
return response
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
const hasManyResolver = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' })
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
const hasOneResolver = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' })
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
const result = {
|
||||
...undefinedToNullResolver(undefinedToNull),
|
||||
...countResolver(count),
|
||||
...hasOneResolver(hasOne),
|
||||
...hasManyResolver(hasMany),
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -9,7 +9,7 @@ import {
|
||||
|
||||
const factory = Factory()
|
||||
let client
|
||||
let userParams = {
|
||||
const userParams = {
|
||||
id: 'you',
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
@ -39,7 +39,7 @@ describe('Notification', () => {
|
||||
})
|
||||
|
||||
describe('currentUser { notifications }', () => {
|
||||
let variables = {}
|
||||
const variables = {}
|
||||
|
||||
describe('authenticated', () => {
|
||||
let headers
|
||||
@ -116,9 +116,7 @@ describe('currentUser { notifications }', () => {
|
||||
}
|
||||
}
|
||||
}`
|
||||
let variables = {
|
||||
read: false
|
||||
}
|
||||
const variables = { read: false }
|
||||
it('returns only unread notifications of current user', async () => {
|
||||
const expected = {
|
||||
currentUser: {
|
||||
|
||||
@ -5,7 +5,7 @@ export async function createPasswordReset(options) {
|
||||
const { driver, code, email, issuedAt = new Date() } = options
|
||||
const session = driver.session()
|
||||
const cypher = `
|
||||
MATCH (u:User) WHERE u.email = $email
|
||||
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
|
||||
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
|
||||
MERGE (u)-[:REQUESTED]->(pr)
|
||||
RETURN u
|
||||
@ -35,13 +35,13 @@ export default {
|
||||
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
const cypher = `
|
||||
MATCH (pr:PasswordReset {code: $code})
|
||||
MATCH (u:User {email: $email})-[:REQUESTED]->(pr)
|
||||
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
|
||||
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
|
||||
SET pr.usedAt = datetime()
|
||||
SET u.encryptedPassword = $encryptedNewPassword
|
||||
RETURN pr
|
||||
`
|
||||
let transactionRes = await session.run(cypher, {
|
||||
const transactionRes = await session.run(cypher, {
|
||||
stillValid,
|
||||
email,
|
||||
code,
|
||||
|
||||
@ -10,7 +10,7 @@ const driver = getDriver()
|
||||
|
||||
const getAllPasswordResets = async () => {
|
||||
const session = driver.session()
|
||||
let transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
|
||||
const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
|
||||
const resets = transactionRes.records.map(record => record.get('r'))
|
||||
session.close()
|
||||
return resets
|
||||
@ -84,9 +84,9 @@ describe('passwordReset', () => {
|
||||
}
|
||||
|
||||
const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }`
|
||||
let email = 'user@example.org'
|
||||
let code = 'abcdef'
|
||||
let newPassword = 'supersecret'
|
||||
const email = 'user@example.org'
|
||||
const code = 'abcdef'
|
||||
const newPassword = 'supersecret'
|
||||
let variables
|
||||
|
||||
describe('invalid email', () => {
|
||||
|
||||
@ -337,7 +337,7 @@ describe('DeletePost', () => {
|
||||
}
|
||||
`
|
||||
|
||||
let variables = {
|
||||
const variables = {
|
||||
id: 'p1',
|
||||
}
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@ const instance = neode()
|
||||
*/
|
||||
const checkEmailDoesNotExist = async ({ email }) => {
|
||||
email = email.toLowerCase()
|
||||
const users = await instance.all('User', { email })
|
||||
if (users.length > 0) throw new UserInputError('User account with this email already exists.')
|
||||
const emails = await instance.all('EmailAddress', { email })
|
||||
if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
|
||||
await expect(action()).rejects.toThrow('"email" must be a valid email')
|
||||
})
|
||||
|
||||
it('creates no EmailAddress node', async done => {
|
||||
it('creates no additional EmailAddress node', async done => {
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
let emailAddresses = await instance.all('EmailAddress')
|
||||
emailAddresses = await emailAddresses.toJson
|
||||
expect(emailAddresses).toHaveLength(0)
|
||||
done()
|
||||
}
|
||||
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
|
||||
describe('creates a EmailAddress node', () => {
|
||||
it('with a `createdAt` attribute', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.createdAt).toBeTruthy()
|
||||
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
|
||||
})
|
||||
|
||||
it('with a cryptographic `nonce`', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
|
||||
it('rejects because codes can be used only once', async done => {
|
||||
await action()
|
||||
try {
|
||||
variables.email = 'yetanotheremail@example.org'
|
||||
await action()
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/Invitation code already used/)
|
||||
@ -282,8 +284,8 @@ describe('Signup', () => {
|
||||
|
||||
it('creates a Signup with a cryptographic `nonce`', async () => {
|
||||
await action()
|
||||
const emailAddresses = await instance.all('EmailAddress')
|
||||
const emailAddress = await emailAddresses.first().toJson()
|
||||
let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
|
||||
emailAddress = await emailAddress.toJson()
|
||||
expect(emailAddress.nonce).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
@ -298,7 +300,7 @@ describe('SignupVerification', () => {
|
||||
}
|
||||
`
|
||||
describe('given valid password and email', () => {
|
||||
let variables = {
|
||||
const variables = {
|
||||
nonce: '123456',
|
||||
name: 'John Doe',
|
||||
password: '123',
|
||||
|
||||
@ -60,7 +60,7 @@ export default {
|
||||
if (!dbResponse) return null
|
||||
const { report, submitter, resource, type } = dbResponse
|
||||
|
||||
let response = {
|
||||
const response = {
|
||||
...report.properties,
|
||||
post: null,
|
||||
comment: null,
|
||||
|
||||
@ -4,7 +4,7 @@ import { UserInputError } from 'apollo-server'
|
||||
const instance = neode()
|
||||
|
||||
const getUserAndBadge = async ({ badgeKey, userId }) => {
|
||||
let user = await instance.first('User', 'id', userId)
|
||||
const user = await instance.first('User', 'id', userId)
|
||||
const badge = await instance.first('Badge', 'id', badgeKey)
|
||||
if (!user) throw new UserInputError("Couldn't find a user with that id")
|
||||
if (!badge) throw new UserInputError("Couldn't find a badge with that id")
|
||||
@ -36,8 +36,6 @@ export default {
|
||||
userId,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
throw err
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ export default {
|
||||
const { id, type } = params
|
||||
|
||||
const session = context.driver.session()
|
||||
let transactionRes = await session.run(
|
||||
const transactionRes = await session.run(
|
||||
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
|
||||
WHERE $type IN labels(node) AND NOT userWritten.id = $userId
|
||||
MERGE (user)-[relation:SHOUTED]->(node)
|
||||
@ -29,7 +29,7 @@ export default {
|
||||
const { id, type } = params
|
||||
const session = context.driver.session()
|
||||
|
||||
let transactionRes = await session.run(
|
||||
const transactionRes = await session.run(
|
||||
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
|
||||
WHERE $type IN labels(node)
|
||||
DELETE relation
|
||||
|
||||
@ -60,8 +60,7 @@ describe('shout', () => {
|
||||
describe('shout foreign post', () => {
|
||||
describe('unauthenticated shout', () => {
|
||||
it('throws authorization error', async () => {
|
||||
let client
|
||||
client = new GraphQLClient(host)
|
||||
const client = new GraphQLClient(host)
|
||||
await expect(client.request(mutationShoutPost('p1'))).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
@ -109,8 +108,7 @@ describe('shout', () => {
|
||||
// shout
|
||||
await clientUser1.request(mutationShoutPost('p2'))
|
||||
// unshout
|
||||
let client
|
||||
client = new GraphQLClient(host)
|
||||
const client = new GraphQLClient(host)
|
||||
await expect(client.request(mutationUnshoutPost('p2'))).rejects.toThrow('Not Authorised')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,30 +1,38 @@
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import { neode } from '../../bootstrap/neo4j'
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreateSocialMedia: async (object, params, context, resolveInfo) => {
|
||||
/**
|
||||
* TODO?: Creates double Nodes!
|
||||
*/
|
||||
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
|
||||
const session = context.driver.session()
|
||||
await session.run(
|
||||
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
|
||||
MERGE (socialMedia)<-[:OWNED]-(owner)
|
||||
RETURN owner`,
|
||||
{
|
||||
userId: context.user.id,
|
||||
socialMediaId: socialMedia.id,
|
||||
},
|
||||
)
|
||||
session.close()
|
||||
const [user, socialMedia] = await Promise.all([
|
||||
instance.find('User', context.user.id),
|
||||
instance.create('SocialMedia', params),
|
||||
])
|
||||
await socialMedia.relateTo(user, 'ownedBy')
|
||||
const response = await socialMedia.toJson()
|
||||
|
||||
return socialMedia
|
||||
return response
|
||||
},
|
||||
DeleteSocialMedia: async (object, params, context, resolveInfo) => {
|
||||
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
|
||||
UpdateSocialMedia: async (object, params, context, resolveInfo) => {
|
||||
const socialMedia = await instance.find('SocialMedia', params.id)
|
||||
await socialMedia.update({ url: params.url })
|
||||
const response = await socialMedia.toJson()
|
||||
|
||||
return socialMedia
|
||||
return response
|
||||
},
|
||||
DeleteSocialMedia: async (object, { id }, context, resolveInfo) => {
|
||||
const socialMedia = await instance.find('SocialMedia', id)
|
||||
if (!socialMedia) return null
|
||||
await socialMedia.delete()
|
||||
return socialMedia.toJson()
|
||||
},
|
||||
},
|
||||
SocialMedia: Resolver('SocialMedia', {
|
||||
idAttribute: 'url',
|
||||
hasOne: {
|
||||
ownedBy: '<-[:OWNED_BY]-(related:User)',
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@ -1,115 +1,274 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from '../../server'
|
||||
import Factory from '../../seed/factories'
|
||||
import { host, login, gql } from '../../jest/helpers'
|
||||
import { gql } from '../../jest/helpers'
|
||||
import { neode, getDriver } from '../../bootstrap/neo4j'
|
||||
|
||||
const driver = getDriver()
|
||||
const factory = Factory()
|
||||
const instance = neode()
|
||||
|
||||
describe('SocialMedia', () => {
|
||||
let client
|
||||
let headers
|
||||
const mutationC = gql`
|
||||
mutation($url: String!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
const mutationD = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteSocialMedia(id: $id) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
let socialMediaAction, someUser, ownerNode, owner
|
||||
|
||||
const ownerParams = {
|
||||
email: 'pippi@example.com',
|
||||
password: '1234',
|
||||
name: 'Pippi Langstrumpf',
|
||||
}
|
||||
|
||||
const userParams = {
|
||||
email: 'kalle@example.com',
|
||||
password: 'abcd',
|
||||
name: 'Kalle Blomqvist',
|
||||
}
|
||||
|
||||
const url = 'https://twitter.com/pippi-langstrumpf'
|
||||
const newUrl = 'https://twitter.com/bullerby'
|
||||
|
||||
const setUpSocialMedia = async () => {
|
||||
const socialMediaNode = await instance.create('SocialMedia', { url })
|
||||
await socialMediaNode.relateTo(ownerNode, 'ownedBy')
|
||||
return socialMediaNode.toJson()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await factory.create('User', {
|
||||
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
|
||||
id: 'acb2d923-f3af-479e-9f00-61b12e864666',
|
||||
name: 'Matilde Hermiston',
|
||||
slug: 'matilde-hermiston',
|
||||
role: 'user',
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
const someUserNode = await instance.create('User', userParams)
|
||||
someUser = await someUserNode.toJson()
|
||||
ownerNode = await instance.create('User', ownerParams)
|
||||
owner = await ownerNode.toJson()
|
||||
|
||||
socialMediaAction = async (user, mutation, variables) => {
|
||||
const { server } = createServer({
|
||||
context: () => {
|
||||
return {
|
||||
user,
|
||||
driver,
|
||||
}
|
||||
},
|
||||
})
|
||||
const { mutate } = createTestClient(server)
|
||||
|
||||
return mutate({
|
||||
mutation,
|
||||
variables,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await factory.cleanDatabase()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
client = new GraphQLClient(host)
|
||||
const variables = {
|
||||
url: 'http://nsosp.org',
|
||||
}
|
||||
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised')
|
||||
describe('create social media', () => {
|
||||
let mutation, variables
|
||||
|
||||
beforeEach(() => {
|
||||
mutation = gql`
|
||||
mutation($url: String!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
variables = { url }
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
const user = null
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
let user
|
||||
|
||||
beforeEach(() => {
|
||||
user = owner
|
||||
})
|
||||
|
||||
it('creates social media with the given url', async () => {
|
||||
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
CreateSocialMedia: {
|
||||
id: expect.any(String),
|
||||
url,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an empty string as url', async () => {
|
||||
variables = { url: '' }
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0].message).toEqual(
|
||||
expect.stringContaining('"url" is not allowed to be empty'),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid urls', async () => {
|
||||
variables = { url: 'not-a-url' }
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0].message).toEqual(
|
||||
expect.stringContaining('"url" must be a valid uri'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ownedBy', () => {
|
||||
beforeEach(() => {
|
||||
mutation = gql`
|
||||
mutation($url: String!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
url
|
||||
ownedBy {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
it('resolves', async () => {
|
||||
const user = someUser
|
||||
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('update social media', () => {
|
||||
let mutation, variables
|
||||
|
||||
beforeEach(async () => {
|
||||
headers = await login({
|
||||
email: 'test@example.org',
|
||||
password: '1234',
|
||||
})
|
||||
client = new GraphQLClient(host, {
|
||||
headers,
|
||||
const socialMedia = await setUpSocialMedia()
|
||||
|
||||
mutation = gql`
|
||||
mutation($id: ID!, $url: String!) {
|
||||
UpdateSocialMedia(id: $id, url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
variables = { url: newUrl, id: socialMedia.id }
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
const user = null
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
|
||||
it('creates social media with correct URL', async () => {
|
||||
const variables = {
|
||||
url: 'http://nsosp.org',
|
||||
}
|
||||
await expect(client.request(mutationC, variables)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
CreateSocialMedia: {
|
||||
id: expect.any(String),
|
||||
url: 'http://nsosp.org',
|
||||
describe('authenticated as other user', () => {
|
||||
it('throws authorization error', async () => {
|
||||
const user = someUser
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated as owner', () => {
|
||||
let user
|
||||
|
||||
beforeEach(() => {
|
||||
user = owner
|
||||
})
|
||||
|
||||
it('updates social media with the given id', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
UpdateSocialMedia: { ...variables },
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
|
||||
expect.objectContaining(expected),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not update if the the given id does not exist', async () => {
|
||||
variables.id = 'some-id'
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete social media', () => {
|
||||
let mutation, variables
|
||||
|
||||
beforeEach(async () => {
|
||||
const socialMedia = await setUpSocialMedia()
|
||||
|
||||
mutation = gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteSocialMedia(id: $id) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
variables = { url: newUrl, id: socialMedia.id }
|
||||
})
|
||||
|
||||
it('deletes social media', async () => {
|
||||
const creationVariables = {
|
||||
url: 'http://nsosp.org',
|
||||
}
|
||||
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
|
||||
const { id } = CreateSocialMedia
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
const user = null
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
const deletionVariables = {
|
||||
id,
|
||||
}
|
||||
const expected = {
|
||||
DeleteSocialMedia: {
|
||||
id: id,
|
||||
url: 'http://nsosp.org',
|
||||
},
|
||||
}
|
||||
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty string', async () => {
|
||||
const variables = {
|
||||
url: '',
|
||||
}
|
||||
await expect(client.request(mutationC, variables)).rejects.toThrow(
|
||||
'"url" is not allowed to be empty',
|
||||
)
|
||||
describe('authenticated as other user', () => {
|
||||
it('throws authorization error', async () => {
|
||||
const user = someUser
|
||||
const result = await socialMediaAction(user, mutation, variables)
|
||||
|
||||
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
|
||||
})
|
||||
})
|
||||
|
||||
it('validates URLs', async () => {
|
||||
const variables = {
|
||||
url: 'not-a-url',
|
||||
}
|
||||
describe('authenticated as owner', () => {
|
||||
let user
|
||||
|
||||
await expect(client.request(mutationC, variables)).rejects.toThrow(
|
||||
'"url" must be a valid uri',
|
||||
)
|
||||
beforeEach(async () => {
|
||||
user = owner
|
||||
})
|
||||
|
||||
it('deletes social media with the given id', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
DeleteSocialMedia: {
|
||||
id: variables.id,
|
||||
url,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
|
||||
expect.objectContaining(expected),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
export const query = (cypher, session) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = []
|
||||
const data = []
|
||||
session.run(cypher).subscribe({
|
||||
onNext: function(record) {
|
||||
let item = {}
|
||||
const item = {}
|
||||
record.keys.forEach(key => {
|
||||
item[key] = record.get(key)
|
||||
})
|
||||
@ -34,7 +34,7 @@ const queryOne = (cypher, session) => {
|
||||
export default {
|
||||
Query: {
|
||||
statistics: async (parent, args, { driver, user }) => {
|
||||
return new Promise(async resolve => {
|
||||
return new Promise(resolve => {
|
||||
const session = driver.session()
|
||||
const queries = {
|
||||
countUsers:
|
||||
@ -54,18 +54,24 @@ export default {
|
||||
countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows',
|
||||
countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts',
|
||||
}
|
||||
let data = {
|
||||
countUsers: (await queryOne(queries.countUsers, session)).countUsers.low,
|
||||
countPosts: (await queryOne(queries.countPosts, session)).countPosts.low,
|
||||
countComments: (await queryOne(queries.countComments, session)).countComments.low,
|
||||
countNotifications: (await queryOne(queries.countNotifications, session))
|
||||
.countNotifications.low,
|
||||
countOrganizations: (await queryOne(queries.countOrganizations, session))
|
||||
.countOrganizations.low,
|
||||
countProjects: (await queryOne(queries.countProjects, session)).countProjects.low,
|
||||
countInvites: (await queryOne(queries.countInvites, session)).countInvites.low,
|
||||
countFollows: (await queryOne(queries.countFollows, session)).countFollows.low,
|
||||
countShouts: (await queryOne(queries.countShouts, session)).countShouts.low,
|
||||
const data = {
|
||||
countUsers: queryOne(queries.countUsers, session).then(res => res.countUsers.low),
|
||||
countPosts: queryOne(queries.countPosts, session).then(res => res.countPosts.low),
|
||||
countComments: queryOne(queries.countComments, session).then(
|
||||
res => res.countComments.low,
|
||||
),
|
||||
countNotifications: queryOne(queries.countNotifications, session).then(
|
||||
res => res.countNotifications.low,
|
||||
),
|
||||
countOrganizations: queryOne(queries.countOrganizations, session).then(
|
||||
res => res.countOrganizations.low,
|
||||
),
|
||||
countProjects: queryOne(queries.countProjects, session).then(
|
||||
res => res.countProjects.low,
|
||||
),
|
||||
countInvites: queryOne(queries.countInvites, session).then(res => res.countInvites.low),
|
||||
countFollows: queryOne(queries.countFollows, session).then(res => res.countFollows.low),
|
||||
countShouts: queryOne(queries.countShouts, session).then(res => res.countShouts.low),
|
||||
}
|
||||
resolve(data)
|
||||
})
|
||||
|
||||
@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthenticationError } from 'apollo-server'
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import { neode } from '../../bootstrap/neo4j'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
@ -21,8 +24,8 @@ export default {
|
||||
// }
|
||||
const session = driver.session()
|
||||
const result = await session.run(
|
||||
'MATCH (user:User {email: $userEmail}) ' +
|
||||
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1',
|
||||
'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
|
||||
'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
|
||||
{
|
||||
userEmail: email,
|
||||
},
|
||||
@ -46,41 +49,24 @@ export default {
|
||||
}
|
||||
},
|
||||
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
|
||||
const session = driver.session()
|
||||
let result = await session.run(
|
||||
`MATCH (user:User {email: $userEmail})
|
||||
RETURN user {.id, .email, .encryptedPassword}`,
|
||||
{
|
||||
userEmail: user.email,
|
||||
},
|
||||
)
|
||||
const currentUser = await instance.find('User', user.id)
|
||||
|
||||
const [currentUser] = result.records.map(function(record) {
|
||||
return record.get('user')
|
||||
})
|
||||
|
||||
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
|
||||
const encryptedPassword = currentUser.get('encryptedPassword')
|
||||
if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
|
||||
throw new AuthenticationError('Old password is not correct')
|
||||
}
|
||||
|
||||
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) {
|
||||
if (await bcrypt.compareSync(newPassword, encryptedPassword)) {
|
||||
throw new AuthenticationError('Old password and new password should be different')
|
||||
} else {
|
||||
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
session.run(
|
||||
`MATCH (user:User {email: $userEmail})
|
||||
SET user.encryptedPassword = $newEncryptedPassword
|
||||
RETURN user
|
||||
`,
|
||||
{
|
||||
userEmail: user.email,
|
||||
newEncryptedPassword,
|
||||
},
|
||||
)
|
||||
session.close()
|
||||
|
||||
return encode(currentUser)
|
||||
}
|
||||
|
||||
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
|
||||
await currentUser.update({
|
||||
encryptedPassword: newEncryptedPassword,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return encode(await currentUser.toJson())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -296,7 +296,7 @@ describe('change password', () => {
|
||||
|
||||
describe('correct password', () => {
|
||||
it('changes the password if given correct credentials "', async () => {
|
||||
let response = await client.request(
|
||||
const response = await client.request(
|
||||
mutation({
|
||||
oldPassword: '1234',
|
||||
newPassword: '12345',
|
||||
|
||||
@ -2,69 +2,20 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import fileUpload from './fileUpload'
|
||||
import { neode } from '../../bootstrap/neo4j'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
const instance = neode()
|
||||
|
||||
const _has = (resolvers, { key, connection }, { returnType }) => {
|
||||
return async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent[key] !== 'undefined') return parent[key]
|
||||
const { id } = parent
|
||||
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
let response = result.records.map(r => r.get('related').properties)
|
||||
if (returnType === 'object') response = response[0] || null
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
const count = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent[key] !== 'undefined') return parent[key]
|
||||
const { id } = parent
|
||||
const statement = `
|
||||
MATCH(u:User {id: {id}})${connection}
|
||||
WHERE NOT related.deleted = true AND NOT related.disabled = true
|
||||
RETURN COUNT(DISTINCT(related)) as count
|
||||
`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
const [response] = result.records.map(r => r.get('count').toNumber())
|
||||
return response
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
const undefinedToNull = list => {
|
||||
const resolvers = {}
|
||||
list.forEach(key => {
|
||||
resolvers[key] = async (parent, params, context, resolveInfo) => {
|
||||
return typeof parent[key] === 'undefined' ? null : parent[key]
|
||||
}
|
||||
})
|
||||
return resolvers
|
||||
}
|
||||
|
||||
export const hasMany = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
export const hasOne = obj => {
|
||||
const resolvers = {}
|
||||
for (const [key, connection] of Object.entries(obj)) {
|
||||
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
User: async (object, args, context, resolveInfo) => {
|
||||
const { email } = args
|
||||
if (email) {
|
||||
const e = await instance.first('EmailAddress', { email })
|
||||
let user = e.get('belongsTo')
|
||||
user = await user.toJson()
|
||||
return [user.node]
|
||||
}
|
||||
return neo4jgraphql(object, args, context, resolveInfo, false)
|
||||
},
|
||||
},
|
||||
@ -72,7 +23,7 @@ export default {
|
||||
UpdateUser: async (object, args, context, resolveInfo) => {
|
||||
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
|
||||
try {
|
||||
let user = await instance.find('User', args.id)
|
||||
const user = await instance.find('User', args.id)
|
||||
if (!user) return null
|
||||
await user.update(args)
|
||||
return user.toJson()
|
||||
@ -104,42 +55,52 @@ export default {
|
||||
},
|
||||
},
|
||||
User: {
|
||||
...undefinedToNull([
|
||||
'actorId',
|
||||
'avatar',
|
||||
'coverImg',
|
||||
'deleted',
|
||||
'disabled',
|
||||
'locationName',
|
||||
'about',
|
||||
]),
|
||||
...count({
|
||||
contributionsCount: '-[:WROTE]->(related:Post)',
|
||||
friendsCount: '<-[:FRIENDS]->(related:User)',
|
||||
followingCount: '-[:FOLLOWS]->(related:User)',
|
||||
followedByCount: '<-[:FOLLOWS]-(related:User)',
|
||||
commentsCount: '-[:WROTE]->(r:Comment)',
|
||||
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
|
||||
shoutedCount: '-[:SHOUTED]->(related:Post)',
|
||||
badgesCount: '<-[:REWARDED]-(related:Badge)',
|
||||
}),
|
||||
...hasOne({
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
disabledBy: '<-[:DISABLED]-(related:User)',
|
||||
}),
|
||||
...hasMany({
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
following: '-[:FOLLOWS]->(related:User)',
|
||||
friends: '-[:FRIENDS]-(related:User)',
|
||||
blacklisted: '-[:BLACKLISTED]->(related:User)',
|
||||
socialMedia: '-[:OWNED]->(related:SocialMedia)',
|
||||
contributions: '-[:WROTE]->(related:Post)',
|
||||
comments: '-[:WROTE]->(related:Comment)',
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
|
||||
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
email: async (parent, params, context, resolveInfo) => {
|
||||
if (typeof parent.email !== 'undefined') return parent.email
|
||||
const { id } = parent
|
||||
const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e`
|
||||
const result = await instance.cypher(statement, { id })
|
||||
const [{ email }] = result.records.map(r => r.get('e').properties)
|
||||
return email
|
||||
},
|
||||
...Resolver('User', {
|
||||
undefinedToNull: [
|
||||
'actorId',
|
||||
'avatar',
|
||||
'coverImg',
|
||||
'deleted',
|
||||
'disabled',
|
||||
'locationName',
|
||||
'about',
|
||||
],
|
||||
count: {
|
||||
contributionsCount: '-[:WROTE]->(related:Post)',
|
||||
friendsCount: '<-[:FRIENDS]->(related:User)',
|
||||
followingCount: '-[:FOLLOWS]->(related:User)',
|
||||
followedByCount: '<-[:FOLLOWS]-(related:User)',
|
||||
commentsCount: '-[:WROTE]->(r:Comment)',
|
||||
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
|
||||
shoutedCount: '-[:SHOUTED]->(related:Post)',
|
||||
badgesCount: '<-[:REWARDED]-(related:Badge)',
|
||||
},
|
||||
hasOne: {
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
disabledBy: '<-[:DISABLED]-(related:User)',
|
||||
},
|
||||
hasMany: {
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
following: '-[:FOLLOWS]->(related:User)',
|
||||
friends: '-[:FRIENDS]-(related:User)',
|
||||
blacklisted: '-[:BLACKLISTED]->(related:User)',
|
||||
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
|
||||
contributions: '-[:WROTE]->(related:Post)',
|
||||
comments: '-[:WROTE]->(related:Comment)',
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
|
||||
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
19
backend/src/schema/types/embed.gql
Normal file
19
backend/src/schema/types/embed.gql
Normal file
@ -0,0 +1,19 @@
|
||||
type Embed {
|
||||
type: String
|
||||
title: String
|
||||
author: String
|
||||
publisher: String
|
||||
date: String
|
||||
description: String
|
||||
url: String
|
||||
image: String
|
||||
audio: String
|
||||
video: String
|
||||
lang: String
|
||||
html: String
|
||||
sources: [String]
|
||||
}
|
||||
|
||||
type Query {
|
||||
embed(url: String!): Embed
|
||||
}
|
||||
@ -21,7 +21,7 @@ const findGqlFiles = dir => {
|
||||
return results
|
||||
}
|
||||
|
||||
let typeDefs = []
|
||||
const typeDefs = []
|
||||
|
||||
findGqlFiles(__dirname).forEach(file => {
|
||||
typeDefs.push(fs.readFileSync(file).toString('utf-8'))
|
||||
|
||||
@ -132,8 +132,3 @@ type SharedInboxEndpoint {
|
||||
uri: String
|
||||
}
|
||||
|
||||
type SocialMedia {
|
||||
id: ID!
|
||||
url: String
|
||||
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ type Mutation {
|
||||
): Comment
|
||||
UpdateComment(
|
||||
id: ID!
|
||||
content: String
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
|
||||
11
backend/src/schema/types/type/SocialMedia.gql
Normal file
11
backend/src/schema/types/type/SocialMedia.gql
Normal file
@ -0,0 +1,11 @@
|
||||
type SocialMedia {
|
||||
id: ID!
|
||||
url: String
|
||||
ownedBy: User! @relation(name: "OWNED_BY", direction: "IN")
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
CreateSocialMedia(id: ID, url: String!): SocialMedia
|
||||
UpdateSocialMedia(id: ID!, url: String!): SocialMedia
|
||||
DeleteSocialMedia(id: ID!): SocialMedia
|
||||
}
|
||||
@ -2,7 +2,7 @@ type User {
|
||||
id: ID!
|
||||
actorId: String
|
||||
name: String
|
||||
email: String!
|
||||
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
|
||||
slug: String!
|
||||
avatar: String
|
||||
coverImg: String
|
||||
@ -17,7 +17,7 @@ type User {
|
||||
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
|
||||
locationName: String
|
||||
about: String
|
||||
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT")
|
||||
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
|
||||
|
||||
#createdAt: DateTime
|
||||
#updatedAt: DateTime
|
||||
|
||||
@ -40,15 +40,13 @@ export const cleanDatabase = async (options = {}) => {
|
||||
const cypher = 'MATCH (n) DETACH DELETE n'
|
||||
try {
|
||||
return await session.run(cypher)
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
export default function Factory(options = {}) {
|
||||
let {
|
||||
const {
|
||||
seedServerHost = 'http://127.0.0.1:4001',
|
||||
neo4jDriver = getDriver(),
|
||||
neodeInstance = neode(),
|
||||
|
||||
@ -21,7 +21,11 @@ export default function create() {
|
||||
...args,
|
||||
}
|
||||
args = await encryptPassword(args)
|
||||
return neodeInstance.create('User', args)
|
||||
const user = await neodeInstance.create('User', args)
|
||||
const email = await neodeInstance.create('EmailAddress', { email: args.email })
|
||||
await user.relateTo(email, 'primaryEmail')
|
||||
await email.relateTo(user, 'belongsTo')
|
||||
return user
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,17 +37,17 @@ const difficulties = ['easy', 'medium', 'hard']
|
||||
|
||||
export default {
|
||||
randomItem: (items, filter) => {
|
||||
let ids = filter
|
||||
const ids = filter
|
||||
? Object.keys(items).filter(id => {
|
||||
return filter(items[id])
|
||||
})
|
||||
: _.keys(items)
|
||||
let randomIds = _.shuffle(ids)
|
||||
const randomIds = _.shuffle(ids)
|
||||
return items[randomIds.pop()]
|
||||
},
|
||||
randomItems: (items, key = 'id', min = 1, max = 1) => {
|
||||
let randomIds = _.shuffle(_.keys(items))
|
||||
let res = []
|
||||
const randomIds = _.shuffle(_.keys(items))
|
||||
const res = []
|
||||
|
||||
const count = _.random(min, max)
|
||||
|
||||
@ -86,8 +86,8 @@ export default {
|
||||
if (allowEmpty === false && count === 0) {
|
||||
count = 1
|
||||
}
|
||||
let categorieIds = _.shuffle(_.keys(seederstore.categories))
|
||||
let ids = []
|
||||
const categorieIds = _.shuffle(_.keys(seederstore.categories))
|
||||
const ids = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(categorieIds.pop())
|
||||
}
|
||||
@ -95,7 +95,7 @@ export default {
|
||||
},
|
||||
randomAddresses: () => {
|
||||
const count = Math.round(Math.random() * 3)
|
||||
let addresses = []
|
||||
const addresses = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
addresses.push({
|
||||
city: faker.address.city(),
|
||||
@ -116,7 +116,7 @@ export default {
|
||||
* @param key the field key that is represented in the values (slug, name, etc.)
|
||||
*/
|
||||
mapIdsByKey: (items, values, key) => {
|
||||
let res = []
|
||||
const res = []
|
||||
values.forEach(value => {
|
||||
res.push(_.find(items, [key, value]).id.toString())
|
||||
})
|
||||
|
||||
1808
backend/yarn.lock
1808
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -1,73 +1,70 @@
|
||||
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
|
||||
When('I search for {string}', value => {
|
||||
cy.get('#nav-search')
|
||||
import { When, Then } from "cypress-cucumber-preprocessor/steps";
|
||||
When("I search for {string}", value => {
|
||||
cy.get("#nav-search")
|
||||
.focus()
|
||||
.type(value)
|
||||
})
|
||||
.type(value);
|
||||
});
|
||||
|
||||
Then('I should have one post in the select dropdown', () => {
|
||||
cy.get('.ds-select-dropdown').should($li => {
|
||||
expect($li).to.have.length(1)
|
||||
})
|
||||
})
|
||||
Then("I should have one post in the select dropdown", () => {
|
||||
cy.get(".input .ds-select-dropdown").should($li => {
|
||||
expect($li).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
Then('I should see the following posts in the select dropdown:', table => {
|
||||
Then("I should see the following posts in the select dropdown:", table => {
|
||||
table.hashes().forEach(({ title }) => {
|
||||
cy.get('.ds-select-dropdown').should('contain', title)
|
||||
})
|
||||
})
|
||||
cy.get(".ds-select-dropdown").should("contain", title);
|
||||
});
|
||||
});
|
||||
|
||||
When('I type {string} and press Enter', value => {
|
||||
cy.get('#nav-search')
|
||||
When("I type {string} and press Enter", value => {
|
||||
cy.get("#nav-search")
|
||||
.focus()
|
||||
.type(value)
|
||||
.type('{enter}', { force: true })
|
||||
})
|
||||
.type("{enter}", { force: true });
|
||||
});
|
||||
|
||||
When('I type {string} and press escape', value => {
|
||||
cy.get('#nav-search')
|
||||
When("I type {string} and press escape", value => {
|
||||
cy.get("#nav-search")
|
||||
.focus()
|
||||
.type(value)
|
||||
.type('{esc}')
|
||||
})
|
||||
.type("{esc}");
|
||||
});
|
||||
|
||||
Then('the search field should clear', () => {
|
||||
cy.get('#nav-search').should('have.text', '')
|
||||
})
|
||||
Then("the search field should clear", () => {
|
||||
cy.get("#nav-search").should("have.text", "");
|
||||
});
|
||||
|
||||
When('I select an entry', () => {
|
||||
cy.get('.ds-select-dropdown ul li')
|
||||
When("I select an entry", () => {
|
||||
cy.get(".input .ds-select-dropdown ul li")
|
||||
.first()
|
||||
.trigger('click')
|
||||
})
|
||||
.trigger("click");
|
||||
});
|
||||
|
||||
Then("I should be on the post's page", () => {
|
||||
cy.location('pathname').should(
|
||||
'contain',
|
||||
'/post/'
|
||||
)
|
||||
cy.location('pathname').should(
|
||||
'eq',
|
||||
'/post/p1/101-essays-that-will-change-the-way-you-think'
|
||||
)
|
||||
})
|
||||
cy.location("pathname").should("contain", "/post/");
|
||||
cy.location("pathname").should(
|
||||
"eq",
|
||||
"/post/p1/101-essays-that-will-change-the-way-you-think"
|
||||
);
|
||||
});
|
||||
|
||||
Then(
|
||||
'I should see posts with the searched-for term in the select dropdown',
|
||||
"I should see posts with the searched-for term in the select dropdown",
|
||||
() => {
|
||||
cy.get('.ds-select-dropdown').should(
|
||||
'contain',
|
||||
'101 Essays that will change the way you think'
|
||||
)
|
||||
cy.get(".ds-select-dropdown").should(
|
||||
"contain",
|
||||
"101 Essays that will change the way you think"
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Then(
|
||||
'I should not see posts without the searched-for term in the select dropdown',
|
||||
"I should not see posts without the searched-for term in the select dropdown",
|
||||
() => {
|
||||
cy.get('.ds-select-dropdown').should(
|
||||
'not.contain',
|
||||
'No searched for content'
|
||||
)
|
||||
cy.get(".ds-select-dropdown").should(
|
||||
"not.contain",
|
||||
"No searched for content"
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -79,7 +79,7 @@ Then('I should be on the {string} page', page => {
|
||||
})
|
||||
|
||||
When('I add a social media link', () => {
|
||||
cy.get("input[name='social-media']")
|
||||
cy.get('input#addSocialMedia')
|
||||
.type('https://freeradical.zone/peter-pan')
|
||||
.get('button')
|
||||
.contains('Add link')
|
||||
@ -98,7 +98,7 @@ Then('the new social media link shows up on the page', () => {
|
||||
|
||||
Given('I have added a social media link', () => {
|
||||
cy.openPage('/settings/my-social-media')
|
||||
.get("input[name='social-media']")
|
||||
.get('input#addSocialMedia')
|
||||
.type('https://freeradical.zone/peter-pan')
|
||||
.get('button')
|
||||
.contains('Add link')
|
||||
@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => {
|
||||
cy.get('.iziToast-message')
|
||||
.should('contain', 'Deleted social media')
|
||||
})
|
||||
|
||||
When('I start editing a social media link', () => {
|
||||
cy.get("a[name='edit']")
|
||||
.click()
|
||||
})
|
||||
|
||||
Then('I can cancel editing', () => {
|
||||
cy.get('button#cancel')
|
||||
.click()
|
||||
.get('input#editSocialMedia')
|
||||
.should('have.length', 0)
|
||||
})
|
||||
|
||||
When('I edit and save the link', () => {
|
||||
cy.get('input#editSocialMedia')
|
||||
.clear()
|
||||
.type('https://freeradical.zone/tinkerbell')
|
||||
.get('button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
})
|
||||
|
||||
Then('the new url is displayed', () => {
|
||||
cy.get("a[href='https://freeradical.zone/tinkerbell']")
|
||||
.should('have.length', 1)
|
||||
})
|
||||
|
||||
Then('the old url is not displayed', () => {
|
||||
cy.get("a[href='https://freeradical.zone/peter-pan']")
|
||||
.should('have.length', 0)
|
||||
})
|
||||
|
||||
@ -15,7 +15,7 @@ Feature: List Social Media Accounts
|
||||
Then it gets saved successfully
|
||||
And the new social media link shows up on the page
|
||||
|
||||
Scenario: Other user's viewing my Social Media
|
||||
Scenario: Other users viewing my Social Media
|
||||
Given I have added a social media link
|
||||
When people visit my profile page
|
||||
Then they should be able to see my social media links
|
||||
@ -27,3 +27,16 @@ Feature: List Social Media Accounts
|
||||
Given I have added a social media link
|
||||
When I delete a social media link
|
||||
Then it gets deleted successfully
|
||||
|
||||
Scenario: Editing Social Media
|
||||
Given I am on the "settings" page
|
||||
And I click on the "Social media" link
|
||||
Then I should be on the "/settings/my-social-media" page
|
||||
Given I have added a social media link
|
||||
When I start editing a social media link
|
||||
Then I can cancel editing
|
||||
When I start editing a social media link
|
||||
And I edit and save the link
|
||||
Then it gets saved successfully
|
||||
And the new url is displayed
|
||||
But the old url is not displayed
|
||||
|
||||
3
deployment/human-connection/maintenance/Dockerfile
Normal file
3
deployment/human-connection/maintenance/Dockerfile
Normal file
@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY ./onourjourney.svg /usr/share/nginx/html/
|
||||
COPY ./maintenance.html /usr/share/nginx/html/index.html
|
||||
43
deployment/human-connection/maintenance/README.md
Normal file
43
deployment/human-connection/maintenance/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Maintenance mode
|
||||
|
||||
> Despite our best efforts, systems sometimes require downtime for a variety of reasons.
|
||||
|
||||
Quote from [here](https://www.nrmitchi.com/2017/11/easy-maintenance-mode-in-kubernetes/)
|
||||
|
||||
We use our maintenance mode for manual database backup and restore. Also we
|
||||
bring the database into maintenance mode for manual database migrations.
|
||||
|
||||
## Deploy the service
|
||||
|
||||
We prepared sample configuration, so you can simply run:
|
||||
```sh
|
||||
# in folder deployment/
|
||||
kubectl apply -f human-connection/maintenance
|
||||
```
|
||||
|
||||
This will fire up a maintenance service.
|
||||
|
||||
## Bring application into maintenance mode
|
||||
|
||||
Now if you want to have a controlled downtime and you want to bring your
|
||||
application into maintenance mode, you can edit your global ingress server.
|
||||
|
||||
E.g. in file `deployment/digital-ocean/https/ingress.yaml` change the following:
|
||||
```yaml
|
||||
...
|
||||
|
||||
- host: nitro-staging.human-connection.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
# serviceName: nitro-web
|
||||
serviceName: maintenance
|
||||
# servicePort: 3000
|
||||
servicePort: 80
|
||||
```
|
||||
|
||||
Then run ` kubectl apply -f deployment/digital-ocean/https/ingress.yaml`. If you
|
||||
want to deactivate the maintenance server, just undo the edit and apply the
|
||||
configuration again.
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: maintenance
|
||||
namespace: human-connection
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
human-connection.org/selector: deployment-human-connection-maintenance
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
human-connection.org/commit: COMMIT
|
||||
human-connection.org/selector: deployment-human-connection-maintenance
|
||||
name: maintenance
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
env:
|
||||
- name: HOST
|
||||
value: 0.0.0.0
|
||||
image: humanconnection/maintenance:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
imagePullPolicy: Always
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
61
deployment/human-connection/maintenance/maintenance.html
Normal file
61
deployment/human-connection/maintenance/maintenance.html
Normal file
@ -0,0 +1,61 @@
|
||||
<head>
|
||||
<style>
|
||||
.body {
|
||||
background-color: #f5f4f6;
|
||||
}
|
||||
.left-header,
|
||||
.right-header {
|
||||
font-family: "LatoWeb", sans-serif;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@media only screen and (min-width: 960px) {
|
||||
.outer-div {
|
||||
margin: 3em;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
}
|
||||
.left-header {
|
||||
float: left;
|
||||
width: 25%;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.image {
|
||||
width: 35em;
|
||||
}
|
||||
.right-header {
|
||||
float: right;
|
||||
width: 25%;
|
||||
margin: 12em 1em 5em 2em;
|
||||
}
|
||||
.email-link {
|
||||
margin-left: -5em;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 960px) {
|
||||
.outer-div {
|
||||
margin: 3em;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
}
|
||||
.image {
|
||||
margin: 2em 0em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="body">
|
||||
<div class="outer-div">
|
||||
<h1 class="left-header">
|
||||
At the moment we are doing some scheduled maintenance, please try again
|
||||
later.
|
||||
</h1>
|
||||
<img src="./onourjourney.svg" alt="Maintenance mode image" class="image" />
|
||||
<h1 class="right-header">
|
||||
Any Questions or concerns, send an email to <br />
|
||||
<a href="mailto:info@human-connection.org" class="email-link"
|
||||
>info@human-connection.org</a
|
||||
>
|
||||
</h1>
|
||||
</div>
|
||||
</body>
|
||||
360
deployment/human-connection/maintenance/onourjourney.svg
Normal file
360
deployment/human-connection/maintenance/onourjourney.svg
Normal file
@ -0,0 +1,360 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Back" viewBox="0 0 600 570">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1,.cls-12{fill:#fff;}.cls-2,.cls-4{fill:#dfeff4;}.cls-3{fill:#fddb00;}.cls-12,.cls-13,.cls-14,.cls-15,.cls-16,.cls-18,.cls-19,.cls-20,.cls-21,.cls-23,.cls-24,.cls-26,.cls-27,.cls-28,.cls-29,.cls-30,.cls-32,.cls-34,.cls-35,.cls-36,.cls-37,.cls-38,.cls-39,.cls-4,.cls-40,.cls-41,.cls-42,.cls-44,.cls-45,.cls-46,.cls-47,.cls-48,.cls-50,.cls-51,.cls-52,.cls-53,.cls-55,.cls-56,.cls-57,.cls-58,.cls-59,.cls-64,.cls-67,.cls-68,.cls-9,.cls-96,.cls-97{fill-rule:evenodd;}.cls-5{mask:url(#mask);}.cls-6,.cls-7{fill:#9dca00;}.cls-6{opacity:0.5;}.cls-14,.cls-8{fill:#471e04;}.cls-9{fill:#70a30a;}.cls-10,.cls-16{fill:#f5b800;}.cls-11,.cls-21{fill:#ff9;}.cls-13{fill:#5b8408;}.cls-15{fill:#fc0;}.cls-17,.cls-48{fill:#f1502d;}.cls-18{fill:#f6aa32;}.cls-19{fill:#cc3;}.cls-20,.cls-63{fill:#f5e68a;}.cls-22,.cls-58{fill:#9cc;}.cls-23,.cls-62{fill:#a44624;}.cls-24,.cls-43{fill:#333;}.cls-25,.cls-45{fill:#d4a15e;}.cls-26{fill:#c4d621;}.cls-27{fill:#4e2111;}.cls-28{fill:#78331a;}.cls-30{fill:#058e7f;}.cls-31{fill:#fcda9a;}.cls-32{fill:#07b09d;}.cls-33,.cls-40{fill:#d63d3d;}.cls-34{fill:#3b696a;}.cls-35{fill:#a6d0d1;}.cls-36{fill:#d48439;}.cls-37{fill:#9e5a29;}.cls-38{fill:#e44b4b;}.cls-39{fill:#b0642e;}.cls-41{fill:#f66;}.cls-42,.cls-61{fill:#f2d291;}.cls-44{fill:#fcc;}.cls-46{fill:#edba68;}.cls-47{fill:#366;}.cls-49,.cls-50{fill:#1f0f05;}.cls-51{fill:#804921;}.cls-52,.cls-65{fill:#ff6;}.cls-53{fill:#f69883;}.cls-54,.cls-55{fill:#ff1f1f;}.cls-56{fill:#aac905;}.cls-57{fill:#f5e97d;}.cls-59,.cls-60{fill:#bf3611;}.cls-64{fill:#eccf7c;}.cls-66{fill:#ff441f;}.cls-67{fill:#88b02c;}.cls-68{fill:#500d00;}.cls-69{fill:#216194;}.cls-70{fill:#017f9f;}.cls-71{fill:#76b629;}.cls-72{fill:#a53589;}.cls-73{fill:#024f9d;}.cls-74{fill:#50568c;}.cls-75{fill:#c6d200;}.cls-76{fill:#0369b2;}.cls-77{fill:#007d75;}.cls-78{fill:#008d6d;}.cls-79{fill:#008051;}.cls-80{fill:#46942b;}.cls-81{fill:#d5146e;}.cls-82{fill:#15a338;}.cls-83{fill:#0080c7;}.cls-84{fill:#0089d0;}.cls-85{fill:#e9bb00;}.cls-86{fill:#007632;}.cls-87{fill:#c51553;}.cls-88{fill:#593872;}.cls-89{fill:#f19000;}.cls-90{fill:#f9b800;}.cls-91{fill:#e43311;}.cls-92{fill:#b7c200;}.cls-93{fill:#e64e10;}.cls-94{fill:#079de1;}.cls-95{fill:#93bf1f;}.cls-96{fill:#8fdbf3;}.cls-97{fill:#c20212;}.cls-98{fill:#152e56;}
|
||||
</style>
|
||||
<mask id="mask" width="1087.71" height="713.35" x="-161.4" y="91.4" maskUnits="userSpaceOnUse">
|
||||
<circle cx="301.72" cy="330.39" r="238.99" class="cls-1"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<circle cx="300" cy="332.01" r="238.99" class="cls-2"/>
|
||||
<circle cx="438.86" cy="295.31" r="80.84" class="cls-3"/>
|
||||
<path d="M538.13 168.86a24.22 24.22 0 0 0-19.13 9.39 19.58 19.58 0 0 0-19 16.1 11.59 11.59 0 1 0-1.77 23c28.22 0 62.52-1.53 93.54-1.53a8.94 8.94 0 1 0-1.8-17.7 18.13 18.13 0 0 0-29.2-13.77 24.27 24.27 0 0 0-22.64-15.49zM82.63 147.25a30.27 30.27 0 0 1 24 11.73 24.46 24.46 0 0 1 23.7 20.12 14.48 14.48 0 1 1 2.21 28.79c-35.26 0-78.12-1.92-116.88-1.92a11.17 11.17 0 1 1 2.25-22.11 22.66 22.66 0 0 1 36.48-17.21 30.33 30.33 0 0 1 28.24-19.4zM89.91 50.71a10.35 10.35 0 0 0-8.19 4 8.36 8.36 0 0 0-8.1 6.88 5 5 0 0 0-.75-.06 5 5 0 1 0 0 9.9c12.06 0 26.71-.65 40-.65a3.82 3.82 0 1 0 0-7.64 3.87 3.87 0 0 0-.77.08 7.75 7.75 0 0 0-12.47-5.88 10.37 10.37 0 0 0-9.72-6.63z" class="cls-4"/>
|
||||
<g class="cls-5">
|
||||
<ellipse cx="189.6" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
|
||||
<ellipse cx="254.61" cy="625.53" class="cls-7" rx="349.38" ry="127.57"/>
|
||||
<ellipse cx="575.32" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
|
||||
<ellipse cx="370.48" cy="648.66" class="cls-7" rx="272.66" ry="127.57"/>
|
||||
</g>
|
||||
<path d="M152.94 477.67h4.13v25.81h-4.13z" class="cls-8"/>
|
||||
<path d="M135.69 472a5.9 5.9 0 0 1 .8-2.44 6.45 6.45 0 0 1-2.81-5.92 5.8 5.8 0 0 1 3-4.56 6.22 6.22 0 0 1-.37-2.81 5.81 5.81 0 0 1 5.14-5.24 6.2 6.2 0 0 1-.18-2.22 5.85 5.85 0 0 1 6.74-5.22 6.39 6.39 0 0 1 2.22.75c2.55-3.23 9.66-4.25 12.46.19a6 6 0 0 1 1.52-.51 6.48 6.48 0 0 1 7.41 4.92 6.14 6.14 0 0 1 0 2.6 6.55 6.55 0 0 1 4.54 4.78 6 6 0 0 1-1.33 5.35 6.45 6.45 0 0 1 1.26 2.58 5.89 5.89 0 0 1-3.1 6.69 6.43 6.43 0 0 1 .89 2.08 5.86 5.86 0 0 1-4.6 7.19 6.15 6.15 0 0 1-2.31 0c-3.64 7-11.92 3.56-12.59.39a5.87 5.87 0 0 1-6.2 3.26 6.54 6.54 0 0 1-5.38-4.86 6.18 6.18 0 0 1-1.63 0 6.49 6.49 0 0 1-5.48-7z" class="cls-9"/>
|
||||
<circle cx="152.62" cy="452.42" r="2.69" class="cls-10"/>
|
||||
<circle cx="163.65" cy="456.62" r="2.69" class="cls-10"/>
|
||||
<circle cx="147.37" cy="471.06" r="2.69" class="cls-10"/>
|
||||
<circle cx="159.98" cy="472.64" r="2.69" class="cls-10"/>
|
||||
<circle cx="154.46" cy="463.45" r="2.69" class="cls-10"/>
|
||||
<circle cx="167.46" cy="466.07" r="2.69" class="cls-10"/>
|
||||
<circle cx="144.88" cy="460.16" r="2.69" class="cls-10"/>
|
||||
<path d="M396.3 510.23h3.08v19.27h-3.08z" class="cls-8"/>
|
||||
<path d="M383.42 506a4.4 4.4 0 0 1 .6-1.82 4.82 4.82 0 0 1-2.1-4.42 4.33 4.33 0 0 1 2.24-3.41 4.64 4.64 0 0 1-.28-2.1 4.34 4.34 0 0 1 3.84-3.91 4.63 4.63 0 0 1-.14-1.66 4.37 4.37 0 0 1 5-3.89 4.77 4.77 0 0 1 1.65.56c1.9-2.41 7.21-3.17 9.31.14a4.44 4.44 0 0 1 1.14-.38 4.84 4.84 0 0 1 5.53 3.67 4.58 4.58 0 0 1 0 1.94 4.89 4.89 0 0 1 3.39 3.57 4.45 4.45 0 0 1-1 4 4.82 4.82 0 0 1 .94 1.93 4.4 4.4 0 0 1-2.31 5 4.8 4.8 0 0 1 .66 1.55 4.38 4.38 0 0 1-3.43 5.37 4.59 4.59 0 0 1-1.72 0c-2.72 5.24-8.9 2.66-9.4.29a4.38 4.38 0 0 1-4.63 2.43 4.88 4.88 0 0 1-4-3.63 4.62 4.62 0 0 1-1.22 0 4.85 4.85 0 0 1-4.07-5.23z" class="cls-9"/>
|
||||
<circle cx="396.06" cy="491.37" r="2.01" class="cls-11"/>
|
||||
<circle cx="404.3" cy="494.51" r="2.01" class="cls-11"/>
|
||||
<circle cx="392.14" cy="505.3" r="2.01" class="cls-11"/>
|
||||
<circle cx="401.56" cy="506.47" r="2.01" class="cls-11"/>
|
||||
<circle cx="397.44" cy="499.61" r="2.01" class="cls-11"/>
|
||||
<circle cx="407.15" cy="501.57" r="2.01" class="cls-11"/>
|
||||
<circle cx="390.28" cy="497.16" r="2.01" class="cls-11"/>
|
||||
<path d="M446.95 488.05h1.66v10.4h-1.66z" class="cls-8"/>
|
||||
<path d="M440 485.76a2.38 2.38 0 0 1 .32-1 2.6 2.6 0 0 1-1.13-2.38 2.34 2.34 0 0 1 1.21-1.84 2.51 2.51 0 0 1-.15-1.13 2.34 2.34 0 0 1 2.07-2.11 2.5 2.5 0 0 1-.07-.9 2.36 2.36 0 0 1 2.72-2.1 2.58 2.58 0 0 1 .89.3c1-1.3 3.89-1.71 5 .08a2.4 2.4 0 0 1 .61-.2 2.61 2.61 0 0 1 3 2 2.47 2.47 0 0 1 0 1 2.64 2.64 0 0 1 1.83 1.93 2.4 2.4 0 0 1-.53 2.16 2.6 2.6 0 0 1 .51 1 2.37 2.37 0 0 1-1.25 2.7 2.59 2.59 0 0 1 .36.84 2.36 2.36 0 0 1-1.85 2.9 2.48 2.48 0 0 1-.93 0c-1.47 2.83-4.8 1.43-5.08.16a2.37 2.37 0 0 1-2.5 1.31 2.64 2.64 0 0 1-2.17-2 2.49 2.49 0 0 1-.66 0 2.62 2.62 0 0 1-2.2-2.72z" class="cls-9"/>
|
||||
<circle cx="446.83" cy="477.87" r="1.09" class="cls-11"/>
|
||||
<circle cx="451.27" cy="479.56" r="1.09" class="cls-11"/>
|
||||
<circle cx="444.71" cy="485.39" r="1.09" class="cls-11"/>
|
||||
<circle cx="449.79" cy="486.02" r="1.09" class="cls-11"/>
|
||||
<circle cx="447.57" cy="482.32" r="1.09" class="cls-11"/>
|
||||
<circle cx="452.81" cy="483.38" r="1.09" class="cls-11"/>
|
||||
<circle cx="443.7" cy="480.99" r="1.09" class="cls-11"/>
|
||||
<path d="M193.42 486.85h2.94v18.37h-2.94z" class="cls-8"/>
|
||||
<path d="M181.15 482.81a4.2 4.2 0 0 1 .57-1.74 4.59 4.59 0 0 1-2-4.21 4.13 4.13 0 0 1 2.14-3.25 4.43 4.43 0 0 1-.27-2 4.14 4.14 0 0 1 3.66-3.73 4.41 4.41 0 0 1-.13-1.58 4.16 4.16 0 0 1 4.8-3.71 4.55 4.55 0 0 1 1.58.53c1.81-2.3 6.88-3 8.87.13a4.23 4.23 0 0 1 1.08-.36 4.61 4.61 0 0 1 5.27 3.5 4.37 4.37 0 0 1 0 1.85 4.66 4.66 0 0 1 3.23 3.4 4.24 4.24 0 0 1-.94 3.81 4.59 4.59 0 0 1 .9 1.84 4.19 4.19 0 0 1-2.21 4.77 4.58 4.58 0 0 1 .63 1.48 4.17 4.17 0 0 1-3.27 5.12 4.38 4.38 0 0 1-1.64 0c-2.59 5-8.48 2.53-9 .28a4.18 4.18 0 0 1-4.41 2.32 4.66 4.66 0 0 1-3.83-3.46 4.4 4.4 0 0 1-1.16 0 4.62 4.62 0 0 1-3.87-4.99z" class="cls-9"/>
|
||||
<circle cx="193.2" cy="468.87" r="1.92" class="cls-10"/>
|
||||
<circle cx="201.05" cy="471.86" r="1.92" class="cls-10"/>
|
||||
<circle cx="189.46" cy="482.14" r="1.92" class="cls-10"/>
|
||||
<circle cx="198.43" cy="483.27" r="1.92" class="cls-10"/>
|
||||
<circle cx="194.51" cy="476.72" r="1.92" class="cls-10"/>
|
||||
<circle cx="203.76" cy="478.59" r="1.92" class="cls-10"/>
|
||||
<circle cx="187.68" cy="474.38" r="1.92" class="cls-10"/>
|
||||
<path d="M532.92 91.83a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44z" class="cls-4"/>
|
||||
<path d="M449.81 370.21a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44zM153 369.27a15.23 15.23 0 0 0-12.05 5.9A12.31 12.31 0 0 0 129 385.3a7.4 7.4 0 0 0-1.11-.08 7.29 7.29 0 1 0 0 14.57c17.74 0 39.31-1 58.82-1a5.62 5.62 0 1 0 0-11.24 5.7 5.7 0 0 0-1.13.11 11.4 11.4 0 0 0-18.34-8.66 15.26 15.26 0 0 0-14.24-9.73zM215.67 220.63a23.91 23.91 0 0 0-18.92 9.26A19.32 19.32 0 0 0 178 245.78a11.61 11.61 0 0 0-1.74-.13 11.44 11.44 0 1 0 0 22.87c27.85 0 61.7-1.51 92.32-1.51a8.82 8.82 0 1 0 0-17.65 8.94 8.94 0 0 0-1.77.18A17.9 17.9 0 0 0 238 235.95a24 24 0 0 0-22.33-15.32z" class="cls-12"/>
|
||||
<path d="M196.6 523.67h8.37l.04-8.41-8.41 8.41z" class="cls-13"/>
|
||||
<path d="M199.32 529.07H205a.81.81 0 1 1 0 1.61h-5.65v2.37h-1.61v-12.59a.81.81 0 1 1 1.61 0v5.38H205a.81.81 0 0 1 0 1.61h-5.65v1.61z" class="cls-14"/>
|
||||
<path d="M204.97 533.1v-19.36l19.36-19.36 25.81 25.82v12.9h-45.17z" class="cls-15"/>
|
||||
<path d="M250.14 520.2v12.9h35.85v-19.36h-23.64l6.04 6.46h-18.25z" class="cls-16"/>
|
||||
<path d="M270 494.38h-45.67l25.81 25.82h18.25l-6.04-6.46h27.01L270 494.38z" class="cls-13"/>
|
||||
<path d="M250.34 485.34h10.67v17.28h-10.67z" class="cls-10"/>
|
||||
<path d="M247.38 480.93h16.6v4.71h-16.6z" class="cls-10"/>
|
||||
<path d="M210.84 514h5.77v13.22h-5.77zM221.14 514h5.77v13.22h-5.77z" class="cls-1"/>
|
||||
<path d="M233.13 517.09h5.77v16.01h-5.77z" class="cls-17"/>
|
||||
<path d="M256.24 523.79h5.77v5.17h-5.77zM272.49 518.03h9.61v10.14h-9.61z" class="cls-1"/>
|
||||
<path d="M272.49 522.58a.81.81 0 0 1 0-1.61h9.61a.81.81 0 0 1 0 1.61h-9.61z" class="cls-18"/>
|
||||
<path d="M223.61 493.63a1.07 1.07 0 0 1 1.52 0L251 519.54a1.07 1.07 0 0 1-1.52 1.52l-25.1-25.16-28.28 28.28a1.07 1.07 0 1 1-1.52-1.52z" class="cls-19"/>
|
||||
<path d="M250.14 521.27a1.07 1.07 0 0 1 0-2.15h18.25a1.07 1.07 0 0 1 0 2.15h-18.25zM262.35 514.82a1.07 1.07 0 0 1 0-2.15h27a1.07 1.07 0 0 1 0 2.15h-27z" class="cls-19"/>
|
||||
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
|
||||
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
|
||||
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
|
||||
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
|
||||
<path d="M332.887 408.086l44.848-153.565 3.388.99-44.847 153.565zM269.147 242.175l3.526-.168 7.648 159.797-3.525.168zM356.93 408.03l59.335-148.57 3.278 1.31-59.334 148.57zM217.413 236.783l3.494-.505 22.9 158.333-3.493.506z" class="cls-22"/>
|
||||
<path d="M314.19 383.78a12 12 0 0 0 4.1 3c2.34-5.06 5.21-14.71 8-23.87l-5.25-.62a150.41 150.41 0 0 1-6.85 21.49z" class="cls-23"/>
|
||||
<path d="M322.53 385.92l22.27 2.64 3.2-27.26a5 5 0 0 0-4.35-5.53l-12.4-1.47a5 5 0 0 0-5.53 4.35z" class="cls-16"/>
|
||||
<path d="M320 411.45l8.78 1a19 19 0 0 1-.15 6.56l-15.36-1.82c.42-3.49 6.52-3.58 6.73-5.74z" class="cls-24"/>
|
||||
<path d="M313.073 417.213l.152-1.28 15.78 1.873-.153 1.28z" class="cls-25"/>
|
||||
<path d="M341.25 414l-8.78-1a19 19 0 0 0-1.39 6.42l15.36 1.82c.47-3.6-5.44-5.11-5.19-7.24z" class="cls-24"/>
|
||||
<path d="M330.913 419.318l.152-1.28 15.78 1.872-.153 1.28z" class="cls-25"/>
|
||||
<path d="M345.19 385.57l-22.33-2.65-3.38 28.51 10.07 1.19 2.12-24.41 3.72.44-3.65 24.23 10.07 1.19z" class="cls-13"/>
|
||||
<path d="M326.46 383.6l1.13 1.25-4.54 4.37-1.13-1.25 4.54-4.37zM341.52 385.39l-1.39.95 3.39 5.31 1.39-.95-3.39-5.31z" class="cls-26"/>
|
||||
<path d="M343.56 355.76v.34l-.08.2a6.28 6.28 0 0 1-12-1.43v-.55l2.88.34a3.42 3.42 0 0 0 1 1.72 3.42 3.42 0 0 0 4.09.49 3.42 3.42 0 0 0 1.34-1.45z" class="cls-12"/>
|
||||
<path d="M333.27 351.79l9.07 1.08c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-9.28-1.1l.3-2.56z" class="cls-23"/>
|
||||
<path d="M333.31 352.53l9 .34c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-3.91 3z" class="cls-27"/>
|
||||
<path d="M320.14 363.94l5.38 1.68 3.44-11c-4.05-1.2-6.42 2.49-7.05 4.29z" class="cls-16"/>
|
||||
<path d="M341.24 323.41c-3-.29-12.18-1.09-13.6 9.52s.39 14.77 1.64 16.53a11 11 0 0 0 5.42 4.54 4.44 4.44 0 0 0 2.73 1.57z" class="cls-23"/>
|
||||
<path d="M341.24 323.41c3 .43 12.1 1.8 11 12.44s-3.84 14.27-5.46 15.69a11 11 0 0 1-6.32 3.09 4.44 4.44 0 0 1-3 .89z" class="cls-28"/>
|
||||
<path d="M328.65 338.55a1.89 1.89 0 0 0-1.32-1.56c-.86-.29-2.5 0-2.79 2.46s1.53 4.87 2.88 4.73 1.27-4.95 1.23-5.63z" class="cls-23"/>
|
||||
<path d="M328 339.3a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.95 1.55 2.86a1 1 0 0 0 .57-.84 1.15 1.15 0 0 1-.53-1.4 1.26 1.26 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM349.74 341.07a2.42 2.42 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.63 4.38-3.9 3.93-.09-5.73-.09-5.73z" class="cls-28"/>
|
||||
<path d="M350.42 342a1.47 1.47 0 0 1 1.13-.73c.55 0 1.29.31 1.11 1.82s-1.4 2.7-2.18 2.42a1 1 0 0 1-.36-1 1.15 1.15 0 0 0 .84-1.24 1.26 1.26 0 0 0-.59-1z" class="cls-27"/>
|
||||
<path d="M343.81 338.4l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
|
||||
<path d="M343.89 346.63c-.24 2-2.83 3.42-5.77 3.07s-5.14-2.29-4.89-4.33z" class="cls-12"/>
|
||||
<path d="M333.23 337.15l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
|
||||
<path d="M327.54 335.9c-1.74-11.29 6.39-15.55 14-14.65s14.47 6.94 10.14 17.51c.22-2.3.42-5.11-.49-6.73-1.51-2.75-5.69-4.92-10.3-5.29-4.56-.72-9.14.41-11.25 2.73-1.3 1.37-1.77 4.14-2.1 6.43z" class="cls-29"/>
|
||||
<circle cx="344.9" cy="341.69" r="1.03" transform="rotate(-83.23 344.905 341.69)"/>
|
||||
<path d="M346.31 341.73a.32.32 0 1 0 .45-.45 2.48 2.48 0 0 0-3.65-.37.32.32 0 0 0 .35.53 1.93 1.93 0 0 1 2.85.29z" class="cls-29"/>
|
||||
<circle cx="333.4" cy="340.32" r="1.03" transform="rotate(-83.23 333.4 340.322)"/>
|
||||
<path d="M332 340a.32.32 0 1 1-.33-.54 2.48 2.48 0 0 1 3.63.5.32.32 0 1 1-.47.43 1.93 1.93 0 0 0-2.84-.39z" class="cls-29"/>
|
||||
<path d="M353.64 388.46a12 12 0 0 1-4.69 1.95c-1.09-5.47-1.62-15.52-2.21-25.09l5.25.62a150.41 150.41 0 0 0 1.65 22.52z" class="cls-23"/>
|
||||
<path d="M352.5 367.78l-5.62.37-.76-11.53c4.22-.22 5.66 3.93 5.85 5.83z" class="cls-16"/>
|
||||
<path d="M306.22 407.49l-5.71-.68a12.06 12.06 0 0 0-1.61 5.37l11.87 1.41c.39-2.97-3.84-4.91-4.55-6.1z" class="cls-30"/>
|
||||
<path d="M298.71 412.18l.087-.724 12.214 1.45-.085.725z" class="cls-31"/>
|
||||
<path d="M308.6 409.89a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.47-1.95 3.7-1.48 3.71-1.48zM307.54 408.74a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.48-1.95 3.7-1.48 3.71-1.48z" class="cls-32"/>
|
||||
<path d="M290.69 405.65l5.71.68a12.06 12.06 0 0 1 .31 5.6l-11.87-1.41c.31-2.99 4.88-3.88 5.85-4.87z" class="cls-30"/>
|
||||
<path d="M284.68 410.527l.086-.725 12.214 1.45-.086.725z" class="cls-8"/>
|
||||
<path d="M287.81 407.43a.43.43 0 1 1-.1-.85s1.78-.22 4 2.63a.43.43 0 0 1-.68.53c-1.91-2.48-3.22-2.31-3.22-2.31zM289.11 406.55a.43.43 0 0 1-.09-.85s1.78-.23 4 2.63a.43.43 0 1 1-.68.53c-1.92-2.48-3.23-2.31-3.23-2.31z" class="cls-32"/>
|
||||
<path d="M299.47 407.756l3.483-29.324 9.602 1.14-3.48 29.324zM287.693 406.35l3.48-29.325 9.603 1.14-3.48 29.324z" class="cls-33"/>
|
||||
<path d="M312.26 399l-25.38-3 6.77-44a5.06 5.06 0 0 1 5.54-4.36l12.43 1.48a5.06 5.06 0 0 1 4.36 5.54z" class="cls-34"/>
|
||||
<path d="M321.51 381.33a12 12 0 0 1-4.69 1.95c-1.41-7.09-1.88-21.87-2.77-33.33 4.19-.22 5.75 3.89 5.81 5.78a156.51 156.51 0 0 0 1.65 25.6zM282.27 376.68a12 12 0 0 0 4.1 3c3-6.56 6.95-20.82 10.5-31.75-4-1.19-6.5 2.44-7 4.26a156.47 156.47 0 0 1-7.6 24.49z" class="cls-35"/>
|
||||
<path d="M300.47 342.12l11.05 1.31-1.18 10c-.87 7.31-11.92 6-11.05-1.31z" class="cls-36"/>
|
||||
<path d="M309.58 355.74l-9.34-11.68.23-1.94 11.05 1.31-1.18 10a5.94 5.94 0 0 1-.76 2.31z" class="cls-37"/>
|
||||
<path d="M320.12 323.59c-1.53 2.93-15.8 14-22.78 20-1.36 1.17-4.8 2.13-5.57 5.17-1.29 5.07 7.07 8.61 11.35 9.08 7.38.8 14.37 1.86 17.42-4.44.54-1.12.26-4.56-2.47-5.31 2.93-6.73 2.76-19.86 2.05-24.5z" class="cls-38"/>
|
||||
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4 4 0 0 1-5.71-.68 11 11 0 0 1-5.39-4.46c-1.24-1.75-3-5.89-1.63-16.44s10.5-9.76 13.52-9.47z" class="cls-36"/>
|
||||
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4.41 4.41 0 0 1-3 .88z" class="cls-39"/>
|
||||
<path d="M317.4 339.38a11.66 11.66 0 0 1-8.91 6.8 4 4 0 0 1-5.71-.68 11.66 11.66 0 0 1-7.07-8.7c0 3.54 4.74 11.7 6.88 16.24 9.82 1.22 13.25-8.25 14.82-13.66z" class="cls-40"/>
|
||||
<path d="M296.76 320.82c-2.3 15.88 1.36 32.08 21.32 27.3-4.51 8.89-12.34 7.84-14 7.48a12.33 12.33 0 0 1-9.61-11c-1.05-8.32-.78-16.28 2.29-23.78z" class="cls-41"/>
|
||||
<path d="M296.52 332.37c1.95-7.11 6.09-8.85 11.72-8.44l.84-7.1c-9.08-1.03-13.46 8.84-12.56 15.54z" class="cls-12"/>
|
||||
<path d="M295.7 336.81c1-12.27 5.88-16.18 12.86-15.67l.71-6c-3-.29-12.11-1.08-13.52 9.47-.05.39-1.92 7.39-.05 12.2z" class="cls-41"/>
|
||||
<path d="M317.65 334.87c-.23-7.37-3.85-10-9.42-11l.84-7.1c8.92 1.12 11 12.06 8.76 18.42-.04.17-.14-.04-.18-.32z" class="cls-12"/>
|
||||
<path d="M317.4 339.38c1.86-12.17-1.93-17.11-8.84-18.25l.71-6c3 .42 12 1.79 10.93 12.37-.04.44.14 7.66-2.8 11.88z" class="cls-38"/>
|
||||
<path d="M309.72 339.69a3.41 3.41 0 0 1-6.62-.79z" class="cls-12"/>
|
||||
<path d="M314.19 334.39a.32.32 0 0 0 .45-.45 2.49 2.49 0 0 0-3.67-.37.32.32 0 1 0 .36.53 2 2 0 0 1 2.86.29z" class="cls-29"/>
|
||||
<circle cx="312.68" cy="334.19" r=".96" transform="rotate(-83.23 312.683 334.193)"/>
|
||||
<path d="M314.38 333.24a.19.19 0 1 0-.32-.23l-.42.58a.19.19 0 1 0 .32.23z" class="cls-29"/>
|
||||
<path d="M314.81 333.46a.19.19 0 1 0-.25-.29l-.65.56a.19.19 0 1 0 .26.29zM312.1 330.14a.46.46 0 1 0-.07.91 8.33 8.33 0 0 1 2.07.72.46.46 0 0 0 .41-.82 6.64 6.64 0 0 0-2.41-.81zM300 332.7a.32.32 0 0 1-.33-.55 2.49 2.49 0 0 1 3.65.5.32.32 0 0 1-.47.43 2 2 0 0 0-2.85-.39z" class="cls-29"/>
|
||||
<circle cx="301.51" cy="332.86" r=".96" transform="rotate(-83.23 301.517 332.864)"/>
|
||||
<path d="M300.08 331.54a.19.19 0 1 1 .36-.15l.27.66a.19.19 0 1 1-.36.15z" class="cls-29"/>
|
||||
<path d="M299.61 331.66a.19.19 0 1 1 .32-.23l.5.7a.19.19 0 1 1-.32.23zM303 329.07a.46.46 0 1 1-.14.9 8.34 8.34 0 0 0-2.19.22.46.46 0 0 1-.21-.89 6.65 6.65 0 0 1 2.54-.23z" class="cls-29"/>
|
||||
<path d="M258.49 354.506l3.575-30.118 23.892 2.836-3.575 30.118z"/>
|
||||
<path d="M269.64 377.64l5.58.66-3.97 30.02-5.08-.79 1.44-12.76 2.03-17.13zM265.25 377.12l-5.58-.67-3.17 30.12 5.12.42 1.59-12.74 2.04-17.13z" class="cls-42"/>
|
||||
<path d="M256.45 405.19l5.61.67a11.85 11.85 0 0 1 .31 5.51L250.69 410c.31-3 4.81-3.83 5.76-4.81z" class="cls-34"/>
|
||||
<path d="M250.546 409.986l.085-.715 12.007 1.426-.085.715z" class="cls-43"/>
|
||||
<path d="M271.72 407l-5.61-.67a11.85 11.85 0 0 0-1.59 5.28l11.67 1.39c.39-2.92-3.77-4.83-4.47-6z" class="cls-34"/>
|
||||
<path d="M264.33 411.615l.086-.715 12.005 1.426-.084.715z" class="cls-43"/>
|
||||
<path d="M256.39 376.72l22 2.61 2.84-23.91a4.92 4.92 0 0 0-4.29-5.45l-12.22-1.45a4.92 4.92 0 0 0-5.45 4.29z" class="cls-44"/>
|
||||
<path d="M259 357.59s-3.76 14.65-5.65 21.58h-1.67l-2.27-2.6c2-7.5 5.22-20 5.23-20z" class="cls-42"/>
|
||||
<path d="M253.89 357.56l5.25 1.64 3.36-10.77c-4-1.17-6.27 2.43-6.88 4.19z" class="cls-44"/>
|
||||
<path d="M252.55 382.3l-.69-2.88a.92.92 0 0 1 .68-1.1.92.92 0 0 1 1.1.68l.69 2.88a.92.92 0 0 1-.68 1.1.92.92 0 0 1-1.1-.68z" class="cls-42"/>
|
||||
<path d="M250.61 379.13l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
|
||||
<path d="M249.65 377.65l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
|
||||
<path d="M249.34 376.33l-.47 5.67a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-.99.87z" class="cls-42"/>
|
||||
<path d="M279.06 350.4v.64a6.47 6.47 0 0 1-3.12 4.69 9.62 9.62 0 0 1-11.61-1.38 6.47 6.47 0 0 1-1.93-5.29c0-.15 0-.33.09-.54v-.08a12.09 12.09 0 0 1 3.36 0l2.52.3-.13 1.1-1.1-.13-1.42-.17c-.35 0-.7-.07-1-.09h-.09a4.32 4.32 0 0 0 1.33 3.39 7.44 7.44 0 0 0 8.83 1 4.32 4.32 0 0 0 2.08-3h-.09c-.34-.07-.69-.12-1-.16l-1.42-.17-1.1-.13.13-1.1 2.52.3a12.06 12.06 0 0 1 3.15.82z" class="cls-12"/>
|
||||
<path d="M277.94 350a4.82 4.82 0 0 1 0 .89c-.38 3.23-3.93 5.47-7.92 5s-6.92-3.48-6.53-6.71a4.82 4.82 0 0 1 .19-.87 13.87 13.87 0 0 1 2.18.1l1.42.17.47-3.94.11-.27 7 .83v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.13.4z" class="cls-42"/>
|
||||
<path d="M275.3 354.8l-7.64-9.73v-.22h1.59l.11-.27 5.41.64v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.14.42 4.82 4.82 0 0 1 0 .89 5.39 5.39 0 0 1-2.56 3.87z" class="cls-45"/>
|
||||
<path d="M274.5 317.74c-2.89-.28-11.62-1-13 9.08s.37 14.09 1.56 15.77a10.54 10.54 0 0 0 5.17 4.28 3.81 3.81 0 0 0 5.48.65 10.54 10.54 0 0 0 6-3c1.55-1.35 4.16-4.81 5.21-15s-7.54-11.38-10.42-11.78z" class="cls-42"/>
|
||||
<path d="M274.5 317.74c2.88.41 11.54 1.71 10.49 11.87s-3.66 13.61-5.21 15a10.54 10.54 0 0 1-6 2.95 4.23 4.23 0 0 1-2.88.85z" class="cls-46"/>
|
||||
<path d="M262.49 332.17a1.8 1.8 0 0 0-1.26-1.49c-.82-.28-2.39 0-2.67 2.34s1.46 4.65 2.74 4.51 1.24-4.71 1.19-5.36z" class="cls-42"/>
|
||||
<path d="M280.12 324.23c.87 2.49.33 6.23 4.55 9.19 4.3-10.68-2.83-16.71-10.27-17.43-7.87-.76-14.2 3.76-13.24 14.87 2.84-3.13 17.39-5.26 18.96-6.63z" class="cls-29"/>
|
||||
<path d="M275.74 341.17a4 4 0 0 1-7.92-.94z" class="cls-12"/>
|
||||
<path d="M276.66 379l-18.57-2.2-1.39 10.84 18.79 2.23z" class="cls-47"/>
|
||||
<path d="M272.74 331.5a49.29 49.29 0 0 1-11.06-2.89l1.08-6.1c3.38-7.4 11.74-5.82 11.74-5.82s8.49.42 10.05 8.41l-.37 6.18c-.75.12-2.1.15-2.92.22-.48 0 .23-2.51-1.1-5.63.38 3.13-.2 5.79-.78 5.79-1.98.04-4.75.01-6.64-.16z" class="cls-29"/>
|
||||
<path d="M266.7 361.4a8 8 0 0 0-2.17-2.13c-.62-.31-.7 3-.55 3.87-.08 1-.72 2.26.56 4.56 1.2 2.15 2.74 3.16 3.71 2.76.85.62 2.59 0 4.26-1.81s1.45-3.29 1.61-4.3c.35-.83 1.05-4.05.37-3.89a8 8 0 0 0-2.6 1.56 5.79 5.79 0 0 0-5.19-.62z" class="cls-12"/>
|
||||
<path d="M267.47 367.78a.28.28 0 1 1-.53.16 2.34 2.34 0 0 0-1.87-1.46.28.28 0 1 1 .11-.54 2.91 2.91 0 0 1 2.29 1.84zM269.9 368.3a.28.28 0 0 1-.48-.28 2.91 2.91 0 0 1 2.66-1.26.28.28 0 1 1 0 .55 2.35 2.35 0 0 0-2.18.99zM268.3 370.14a2.81 2.81 0 0 1-.66-.72c0-.51 1.56-.33 1.46.17a2.8 2.8 0 0 1-.8.55z" class="cls-44"/>
|
||||
<path d="M249.73 374.84l4.29 1.33a.89.89 0 0 1 .59 1.12.89.89 0 0 1-1.12.59l-4.29-1.33a.89.89 0 0 1-.59-1.12.89.89 0 0 1 1.12-.59z" class="cls-48"/>
|
||||
<path d="M261.86 332.85a1.41 1.41 0 0 0-.88-.93c-.5-.17-1.26 0-1.43 1.44s.7 2.81 1.48 2.73a1 1 0 0 0 .54-.81 1.09 1.09 0 0 1-.5-1.34 1.19 1.19 0 0 1 .78-.84c.01-.15.01-.25.01-.25z" class="cls-46"/>
|
||||
<path d="M278.11 334.62a1 1 0 0 1 .26.07l.23-.33a.19.19 0 0 1 .31.21l-.23.33.34-.2a.19.19 0 0 1 .19.32l-.32.19h.41a.19.19 0 0 1 0 .37h-.38a.92.92 0 0 1 0 .1 1 1 0 1 1-.81-1.06zM277.76 332.55a.5.5 0 1 1 .08-1 5.43 5.43 0 0 1 1.83.53.5.5 0 0 1-.45.89 4.31 4.31 0 0 0-1.46-.42zM267.06 333.31a1 1 0 0 0-.27 0l-.15-.38a.19.19 0 0 0-.35.13l.15.38-.28-.27a.19.19 0 0 0-.26.27l.27.26-.38-.14a.19.19 0 0 0-.13.35l.36.13a.92.92 0 0 0 0 .1 1 1 0 1 0 1.04-.83zM268 330.39a.5.5 0 1 1-.16 1 4.31 4.31 0 0 0-1.52.08.5.5 0 0 1-.23-1 5.43 5.43 0 0 1 1.91-.08z" class="cls-29"/>
|
||||
<path d="M279.44 335.78a.32.32 0 1 0 .34-.55 2.52 2.52 0 0 0-3.7.46.32.32 0 0 0 .47.44 2 2 0 0 1 2.89-.36zM265.48 334.12a.32.32 0 1 1-.21-.61 2.52 2.52 0 0 1 3.49 1.32.32.32 0 0 1-.56.32 2 2 0 0 0-2.72-1z" class="cls-29"/>
|
||||
<path d="M280 360.16a165.74 165.74 0 0 0 4.56 22.84 1.49 1.49 0 0 0 .49.87l1-1.19 2.43-2.49c-1.66-3.06-3.83-19.46-3.66-20.09z" class="cls-42"/>
|
||||
<path d="M285.33 361.29l-5.48.36-.75-11.25c4.12-.21 5.53 3.83 5.71 5.69z" class="cls-44"/>
|
||||
<path d="M281.7 326.71l4.24.5-1.7 30.15c-.13 2.35-4.42 5.09-6.75 4.81z" class="cls-29"/>
|
||||
<path d="M336.33 406.29l2.11-17.75a5 5 0 0 1 5.48-4.31l12.29 1.46a4.94 4.94 0 0 1 4.31 5.48l-2.11 17.75z" class="cls-48"/>
|
||||
<circle cx="340.18" cy="361.87" r="6.71" class="cls-49" transform="rotate(-83.23 340.185 361.876)"/>
|
||||
<circle cx="365.07" cy="364.83" r="6.71" class="cls-49" transform="rotate(-83.23 365.073 364.83)"/>
|
||||
<path d="M355.19 380.64l-9.11-1.08-.74 6.23 3.93 5.85 5.18-4.77.74-6.23z" class="cls-36"/>
|
||||
<path d="M352.36 389.23l-6.47-8.08.19-1.59 9.11 1.08-.74 6.23-2.09 2.36z" class="cls-37"/>
|
||||
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 3.88 3.88 0 0 0 5.58.66 10.72 10.72 0 0 0 6.14-3c1.58-1.38 4.23-4.9 5.3-15.22s-7.75-11.58-10.67-12.03z" class="cls-37"/>
|
||||
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 4.31 4.31 0 0 0 2.65 1.53z" class="cls-36"/>
|
||||
<path d="M341.88 367.27a2.35 2.35 0 0 0-1.47-1.55c-.83-.28-2.43 0-2.71 2.38s1.48 4.73 2.79 4.59 1.39-5.42 1.39-5.42z" class="cls-36"/>
|
||||
<path d="M341 368a1.43 1.43 0 0 0-.9-.95c-.51-.17-1.28 0-1.46 1.47s.71 2.86 1.51 2.78a1 1 0 0 0 .55-.82 1.11 1.11 0 0 1-.51-1.36 1.21 1.21 0 0 1 .8-.85c.01-.21.01-.27.01-.27z" class="cls-37"/>
|
||||
<path d="M347.34 364.89a.51.51 0 1 0 .16-1 5.48 5.48 0 0 0-1.94.09.51.51 0 1 0 .24 1 4.39 4.39 0 0 1 1.54-.09zM357.48 365.07a.51.51 0 1 0-.08 1 4.39 4.39 0 0 1 1.48.44.51.51 0 1 0 .46-.91 5.48 5.48 0 0 0-1.86-.53z" class="cls-50"/>
|
||||
<path d="M346.81 357.63c-1.45 2.25-1.81 6.08-6.69 8-1.71-11.59 6.78-15.85 14.31-14.79 8 1.12 14.53 7.37 10.93 18.12-5.22-1.76-7.74-4.79-9.94-8.09.55 2.65 2.07 4.13 1.64 4-5.01-1.87-6.82-2.37-10.25-7.24z" class="cls-50"/>
|
||||
<path d="M362.35 369.68a1.84 1.84 0 0 1 1.6-1.17c.87-.08 2.37.54 2.08 3s-2.55 4.25-3.79 3.81-.09-5.01.11-5.64z" class="cls-37"/>
|
||||
<path d="M362.82 370.55a1.43 1.43 0 0 1 1.09-.71c.53 0 1.25.3 1.07 1.77s-1.36 2.62-2.11 2.35a1 1 0 0 1-.35-.93 1.11 1.11 0 0 0 .82-1.2 1.22 1.22 0 0 0-.57-1z" class="cls-51"/>
|
||||
<path d="M353.63 375.59a2.45 2.45 0 1 1-4.86-.58z" class="cls-12"/>
|
||||
<path d="M349.27 391.64l5.21-.88.56-5.21-.43-.05-5.34 6.14zM349.27 391.64l-4.87-2.08.68-5.19.42.05 3.77 7.22z" class="cls-52"/>
|
||||
<path d="M359.11 370.24a.32.32 0 0 0 .46-.46 2.53 2.53 0 0 0-3.72-.37.32.32 0 1 0 .36.54 2 2 0 0 1 2.91.29z" class="cls-29"/>
|
||||
<circle cx="357.58" cy="370.03" r=".97" transform="rotate(-83.23 357.584 370.03)"/>
|
||||
<path d="M359.3 369.07a.2.2 0 1 0-.32-.23l-.43.59a.2.2 0 1 0 .32.23z" class="cls-29"/>
|
||||
<path d="M359.74 369.3a.2.2 0 0 0-.26-.3l-.66.57a.2.2 0 1 0 .26.3zM344.71 368.53a.32.32 0 1 1-.34-.55 2.53 2.53 0 0 1 3.71.51.32.32 0 1 1-.48.44 2 2 0 0 0-2.89-.39z" class="cls-29"/>
|
||||
<circle cx="346.24" cy="368.69" r=".97" transform="rotate(-83.23 346.247 368.69)"/>
|
||||
<path d="M344.79 367.35a.2.2 0 1 1 .37-.15l.27.67a.2.2 0 1 1-.37.15z" class="cls-29"/>
|
||||
<path d="M344.32 367.47a.2.2 0 0 1 .32-.23l.51.71a.2.2 0 0 1-.32.23z" class="cls-29"/>
|
||||
<path d="M335.15 406.15c1.38-4.24 2.81-9 4.24-13.62l-5.33-.63c-1.23 5-2.48 9.35-3.94 13.65z" class="cls-36"/>
|
||||
<path d="M333.12 393.59l5.46 1.7 3.49-11.2c-4.11-1.22-6.52 2.53-7.16 4.36z" class="cls-48"/>
|
||||
<path d="M333.3 392.9l5.67 1.83-.61 1.81-5.67-1.83.61-1.81z" class="cls-52"/>
|
||||
<path d="M271.3 398.57l1.54-13a5.08 5.08 0 0 1 5.63-4.44l12.63 1.5a5.08 5.08 0 0 1 4.44 5.63l-1.54 13z" class="cls-53"/>
|
||||
<path d="M278.19 381.16l.61 1.06 3.89 7.27 1 1.84 1.38-1.55 5.48-6.16.77-.9h-.2l-12.63-1.5z" class="cls-12"/>
|
||||
<path d="M290.21 376.23l-9.36-1.11-.76 6.41 3.88 7.27 5.48-6.16.76-6.41z" class="cls-23"/>
|
||||
<path d="M287.3 385.06l-6.65-8.3.2-1.64 9.36 1.11-.76 6.41-2.15 2.42z" class="cls-27"/>
|
||||
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4 4 0 0 1-5.7-.68 11 11 0 0 1-5.38-4.45c-1.24-1.75-3-5.88-1.63-16.41s10.49-9.75 13.5-9.45z" class="cls-23"/>
|
||||
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4.4 4.4 0 0 1-3 .88z" class="cls-28"/>
|
||||
<path d="M289.86 373.27a3.57 3.57 0 0 1-7.08-.84z" class="cls-12"/>
|
||||
<path d="M292.56 364.16a.52.52 0 1 1 .08-1 5.66 5.66 0 0 1 1.91.56.52.52 0 1 1-.47.93 4.49 4.49 0 0 0-1.52-.49zM282.44 361.91a.52.52 0 1 1-.16 1 4.49 4.49 0 0 0-1.58.08.52.52 0 1 1-.24-1 5.66 5.66 0 0 1 1.98-.08z" class="cls-29"/>
|
||||
<path d="M283.12 396a3 3 0 0 1 3.12-1.58 2.76 2.76 0 0 1 2.58 2.9 5.66 5.66 0 0 1-1.76 3.13l-8.81-1a5.66 5.66 0 0 1-1-3.46 2.76 2.76 0 0 1 3.19-2.22 3 3 0 0 1 2.68 2.23z" class="cls-12"/>
|
||||
<path d="M294.3 367.68a.33.33 0 0 0 .46-.46c-.65-.65-1.74-1.73-3.76-.38a.33.33 0 1 0 .36.54 2 2 0 0 1 2.93.3z" class="cls-29"/>
|
||||
<circle cx="292.75" cy="367.48" r=".98" transform="rotate(-83.23 292.76 367.48)"/>
|
||||
<path d="M294.49 366.51a.2.2 0 1 0-.32-.23l-.43.6a.2.2 0 0 0 .32.23z" class="cls-29"/>
|
||||
<path d="M294.93 366.74a.2.2 0 0 0-.26-.3l-.67.56a.2.2 0 1 0 .26.3zM279.77 366a.33.33 0 1 1-.34-.56c.78-.48 2.09-1.28 3.74.51a.33.33 0 1 1-.48.44 2 2 0 0 0-2.92-.4z" class="cls-29"/>
|
||||
<circle cx="281.32" cy="366.12" r=".98" transform="rotate(-83.23 281.326 366.125)"/>
|
||||
<path d="M279.85 364.77a.2.2 0 1 1 .37-.15l.28.68a.2.2 0 0 1-.37.15z" class="cls-29"/>
|
||||
<path d="M279.37 364.89a.2.2 0 0 1 .32-.23l.51.72a.2.2 0 1 1-.32.23zM303.18 353.32a.93.93 0 1 1 .42 1.82 6.3 6.3 0 0 0-3.8 2.71.93.93 0 0 1-1.62-.93 8.27 8.27 0 0 1 5-3.6zM304.89 364.63a.93.93 0 1 1-1.13 1.49 4.08 4.08 0 0 0-3.92-.46.93.93 0 1 1-.72-1.72 6 6 0 0 1 5.77.69z" class="cls-29"/>
|
||||
<ellipse cx="303.53" cy="364.93" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 303.537 364.93)"/>
|
||||
<path d="M304.82 358.93a.93.93 0 0 1-.57 1.78 4.05 4.05 0 0 0-3.81 1 .94.94 0 1 1-1.29-1.35 6 6 0 0 1 5.67-1.43z" class="cls-29"/>
|
||||
<ellipse cx="303.65" cy="359.69" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-14.05 303.772 359.798)"/>
|
||||
<ellipse cx="302.49" cy="354.41" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 302.444 354.37)"/>
|
||||
<path d="M299.34 348.84a.94.94 0 0 1 1.33 1.32c-.14.15-.34.32-.56.52a12 12 0 0 0-2.67 3.31.93.93 0 0 1-1.62-.93 13.69 13.69 0 0 1 3-3.77c.18-.15.35-.29.52-.45z" class="cls-29"/>
|
||||
<ellipse cx="299.35" cy="349.99" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.53 299.335 349.985)"/>
|
||||
<path d="M294.61 345.85a.93.93 0 0 1 1.66.86c-.11.21-.24.43-.38.66a12 12 0 0 0-1.58 3.95.93.93 0 0 1-1.82-.41 13.69 13.69 0 0 1 1.8-4.49c.12-.2.23-.42.32-.57z" class="cls-29"/>
|
||||
<ellipse cx="295.01" cy="346.94" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 295.067 346.982)"/>
|
||||
<path d="M273.66 351.58a.93.93 0 0 1 .84-1.67 8.27 8.27 0 0 1 4 4.66.93.93 0 0 1-1.79.52 6.3 6.3 0 0 0-3.05-3.51z" class="cls-29"/>
|
||||
<path d="M299.58 366.46c4.24-10.54-2.63-16.24-10-17.2-8.66-1.12-16.21 3.3-14.43 14.61 0 0 1.3.58 1.44-.12.47-3.49.78-5.06 2-6.39 1.66-1.87 8.74-.84 14.9-2.11a5 5 0 0 1 3.2 2.37c1.63 3.07 2.57 4.51 2.13 8.76a4.36 4.36 0 0 0 .76.08z" class="cls-29"/>
|
||||
<path d="M297.1 352.84a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.36a.79.79 0 0 1 1.11 0zM299 354.27a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.35a.79.79 0 0 1 1.11-.01z" class="cls-55"/>
|
||||
<path d="M270.94 362.22a.93.93 0 0 1-.75-1.71 6 6 0 0 1 5.78.68.93.93 0 0 1-1.11 1.5 4.08 4.08 0 0 0-3.92-.47z" class="cls-29"/>
|
||||
<ellipse cx="271.44" cy="361.12" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 271.442 361.12)"/>
|
||||
<path d="M271.72 356.85a.93.93 0 0 1-.13-1.86 6 6 0 0 1 5.18 2.69.94.94 0 1 1-1.57 1 4.05 4.05 0 0 0-3.48-1.83z" class="cls-29"/>
|
||||
<ellipse cx="272.55" cy="356" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-62.4 272.587 356.01)"/>
|
||||
<ellipse cx="274.91" cy="351.14" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.95 274.927 351.15)"/>
|
||||
<path d="M277.68 347.43a.94.94 0 0 1 1.6-1c.12.2.24.37.36.55a13.67 13.67 0 0 1 2.07 4.38.93.93 0 0 1-1.79.52 12 12 0 0 0-1.82-3.84c-.17-.22-.32-.44-.42-.61z" class="cls-29"/>
|
||||
<ellipse cx="279" cy="347.58" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 279.006 347.586)"/>
|
||||
<path d="M282.76 345.1a.93.93 0 1 1 1.81-.45c.05.2.11.4.18.63a13.69 13.69 0 0 1 .7 4.79.93.93 0 0 1-1.87 0 12 12 0 0 0-.61-4.21c-.08-.28-.15-.53-.21-.76z" class="cls-29"/>
|
||||
<ellipse cx="283.93" cy="345.62" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.94 283.966 345.642)"/>
|
||||
<path d="M288.19 344.47a.94.94 0 1 1 1.87 0v1.13c0 .86.12 2 .09 4a.93.93 0 0 1-1.87 0c0-1.83 0-3-.09-3.84.02-.52 0-.9 0-1.29z" class="cls-29"/>
|
||||
<ellipse cx="289.12" cy="345.25" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 289.126 345.25)"/>
|
||||
<path d="M276.61 364.38a1.88 1.88 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.44s1.52 4.84 2.85 4.7 1.28-4.91 1.23-5.59z" class="cls-23"/>
|
||||
<path d="M275.93 365.13a1.46 1.46 0 0 0-.92-1c-.52-.17-1.31 0-1.49 1.5s.73 2.93 1.54 2.84a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.14.01-.24.01-.24zM297.54 366.89a2.4 2.4 0 0 1 1.84-1.19c.89-.08 2.42.55 2.12 3s-2.61 4.35-3.87 3.9-.09-5.71-.09-5.71z" class="cls-28"/>
|
||||
<path d="M298.22 367.78a1.46 1.46 0 0 1 1.12-.73c.54 0 1.28.31 1.1 1.81s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.14 1.14 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-27"/>
|
||||
<path d="M272.4 391.4c-.69 2.17-1.53 4.56-2.49 7l-5.16-.61c1.6-3.87 2.82-7.11 2.82-7.39z" class="cls-23"/>
|
||||
<path d="M267.15 391l5.59 1.74 3.58-11.48c-4.21-1.25-6.68 2.59-7.34 4.47z" class="cls-53"/>
|
||||
<path d="M294.55 394c.16 2.27.42 4.79.78 7.39l5.16.61c-.65-4.14-1.08-7.57-1-7.85z" class="cls-23"/>
|
||||
<path d="M299.74 394.87l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-53"/>
|
||||
<path d="M247 378.52c-.32 0-4 .31-4.35.3-9.81-.32-14.89-7.43-17.46-15.91-.06-.27-.12-.56-.18-.87v-.16A4.43 4.43 0 0 0 226 360l.66-2.19a.94.94 0 0 0-.66-1.16 1 1 0 0 0-1.07.46l-.14-.08-.06-4.14a.94.94 0 0 0-1-.93.94.94 0 0 0-.93 1v1.36l-.5-2.32a1 1 0 0 0-1.13-.7.94.94 0 0 0-.7 1.13l1.07 4.54-1.44-4.28a.94.94 0 0 0-1.19-.59.94.94 0 0 0-.59 1.19l1.45 4.3-1.28-2.17a.94.94 0 0 0-1.29-.33.94.94 0 0 0-.33 1.29l1.22 2.07a.44.44 0 0 0 0 .54l1.41 3a2 2 0 0 0 1 1.26c2.26 10.76 8.25 20.26 22.25 21.83z" class="cls-42"/>
|
||||
<path d="M245.23 377.81c-.32 0-3.94-.27-4.24-.28a17.89 17.89 0 0 1-5-.84l-1.47 7a28.71 28.71 0 0 0 6.36 1.42z" class="cls-56"/>
|
||||
<path d="M259.86 355c1.06.46 4-5.77 5.06-3.27 5.59 5.39-1.21 19.27 5.27 19.09-2.74 7.69-15.85 10.28-20.88 4.8 0 0 6.36-18.54 10.55-20.62z" class="cls-57"/>
|
||||
<path d="M251.22 354c-1.14.2-3.12-6.77-4.73-4.58-6.69 3.94-2.76 19.24-9 17.54.87 8.12 13 13.71 19.18 9.55-.03-.02-1.87-19.51-5.45-22.51z" class="cls-57"/>
|
||||
<path d="M239.4 393.69l1.36-11.45a5.07 5.07 0 0 1 5.62-4.43l12.6 1.5a5.07 5.07 0 0 1 4.43 5.62L262 396.38z" class="cls-56"/>
|
||||
<path d="M246.1 377.79l.61 1.05 3.88 7.25 1 1.83 1.38-1.55 5.47-6.14.77-.89h-.2l-12.6-1.5z" class="cls-12"/>
|
||||
<path d="M258.09 372.88l-9.34-1.11-.76 6.39 3.88 7.26 5.46-6.15.76-6.39z" class="cls-42"/>
|
||||
<path d="M255.18 381.69l-6.62-8.29.19-1.63 9.34 1.11-.76 6.39-2.15 2.42z" class="cls-45"/>
|
||||
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4 4 0 0 1-5.69-.68 10.94 10.94 0 0 1-5.37-4.44c-1.23-1.74-3-5.87-1.62-16.37s10.47-9.71 13.47-9.42z" class="cls-42"/>
|
||||
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4.39 4.39 0 0 1-3 .88z" class="cls-46"/>
|
||||
<path d="M244.22 359.82a1.87 1.87 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.43s1.51 4.82 2.85 4.68 1.27-4.88 1.23-5.56z" class="cls-42"/>
|
||||
<path d="M243.54 360.57a1.45 1.45 0 0 0-.91-1c-.52-.17-1.31 0-1.49 1.5s.72 2.92 1.53 2.83a1 1 0 0 0 .57-.84 1.13 1.13 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.13.01-.23.01-.23zM265.1 362.32a2.4 2.4 0 0 1 1.83-1.19c.89-.08 2.42.55 2.12 3s-2.6 4.34-3.86 3.89-.09-5.7-.09-5.7z" class="cls-46"/>
|
||||
<path d="M265.77 363.21a1.46 1.46 0 0 1 1.11-.72c.54 0 1.27.3 1.09 1.8s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.13 1.13 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-45"/>
|
||||
<path d="M262.51 351.59c.9 2.58.35 6.47 4.72 9.54 4.46-11.09-2.93-17.34-10.66-18.09-8.17-.79-16.17 3.84-15.17 15.37 4.71.68 7.15-4.46 7.41-4.49 5.25.61 9.13 1.67 13.7-2.33z" class="cls-21"/>
|
||||
<path d="M257.43 368.69c-.41 1.22-2 2-3.79 1.79s-3.16-1.35-3.27-2.63z" class="cls-12"/>
|
||||
<path d="M260.13 359.6a.52.52 0 1 1 .08-1 5.64 5.64 0 0 1 1.9.55.52.52 0 0 1-.47.93 4.45 4.45 0 0 0-1.51-.48zM250 357.36a.52.52 0 0 1-.16 1 4.46 4.46 0 0 0-1.58.08.52.52 0 0 1-.24-1 5.64 5.64 0 0 1 1.98-.08z" class="cls-45"/>
|
||||
<path d="M262 362.82a.33.33 0 0 0 .47-.47c-.65-.65-1.75-1.75-3.8-.38a.33.33 0 1 0 .37.55 2 2 0 0 1 3 .3z" class="cls-14"/>
|
||||
<circle cx="260.43" cy="362.61" r=".99" class="cls-8" transform="rotate(-83.23 260.438 362.615)"/>
|
||||
<path d="M262.19 361.63a.2.2 0 1 0-.33-.23l-.43.6a.2.2 0 0 0 .33.23z" class="cls-14"/>
|
||||
<path d="M262.63 361.86a.2.2 0 1 0-.26-.3l-.67.58a.2.2 0 0 0 .26.3zM247.32 361.08a.33.33 0 0 1-.34-.56c.79-.48 2.12-1.29 3.78.52a.33.33 0 1 1-.49.45 2 2 0 0 0-2.95-.4z" class="cls-14"/>
|
||||
<circle cx="248.88" cy="361.24" r=".99" class="cls-8" transform="rotate(-83.23 248.886 361.245)"/>
|
||||
<path d="M247.4 359.87a.2.2 0 0 1 .37-.15l.28.69a.2.2 0 1 1-.37.15z" class="cls-14"/>
|
||||
<path d="M246.91 360a.2.2 0 1 1 .33-.23l.52.73a.2.2 0 0 1-.33.23z" class="cls-14"/>
|
||||
<path d="M246.78 349.88a.78.78 0 0 1 1.05.34l1.77 3.45a.78.78 0 0 1-.34 1.05.79.79 0 0 1-1.05-.34l-1.77-3.45a.79.79 0 0 1 .34-1.05z" class="cls-48"/>
|
||||
<path d="M263.59 390.63c.13 1.86.33 3.89.59 6l5.12.61a63.72 63.72 0 0 1-.79-6.44z" class="cls-42"/>
|
||||
<path d="M268.78 391.47l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-56"/>
|
||||
<path d="M326.77 401l1.5-12.61a5 5 0 0 0-4.4-5.58l-12.52-1.49a5 5 0 0 0-5.58 4.4l-1.5 12.61z" class="cls-58"/>
|
||||
<path d="M323.75 382.77v.35l-.08.2a6.35 6.35 0 0 1-12.16-1.44v-.56l2.9.34a3.45 3.45 0 0 0 1 1.74 3.45 3.45 0 0 0 4.13.49 3.45 3.45 0 0 0 1.36-1.47z" class="cls-12"/>
|
||||
<path d="M313.36 378.76l9.16 1.09c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-9.37-1.11l.31-2.58c.05-.08.09-.21.15-.32z" class="cls-42"/>
|
||||
<path d="M313.39 379.51l9.13.34c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-4 3z" class="cls-45"/>
|
||||
<path d="M321.38 350.33c-3-.29-12.16-1.08-13.58 9.51s.39 14.75 1.64 16.51a11 11 0 0 0 5.41 4.48 4.43 4.43 0 0 0 2.72 1.57z" class="cls-42"/>
|
||||
<path d="M321.38 350.33c3 .42 12.08 1.79 11 12.42s-3.83 14.25-5.45 15.66a11 11 0 0 1-6.31 3.09 4.43 4.43 0 0 1-3 .89z" class="cls-46"/>
|
||||
<path d="M328.92 356.45c-.42-1-1-1.93-4-1.13s-8 3-13.34 1.88c-2.24 3.14-2.41 9.39-2.41 9.39l-2.83-2.36s.34-7.09 1.74-9.22c-.89-.26-1.54-1.26-1.2-3.44 1.12.29 1.69 1.27 3.35.24s5.47-4.72 11.5-3.85 8.3 4.44 9 5.82c3.78 4.08 2.91 8.4 1.92 13.55l-3.32 1.67c.45-2.15.73-9.93-.41-12.55z" class="cls-59"/>
|
||||
<path d="M308.81 365.43a1.89 1.89 0 0 0-1.32-1.56c-.85-.29-2.5 0-2.79 2.45s1.53 4.86 2.87 4.72 1.28-4.93 1.24-5.61z" class="cls-42"/>
|
||||
<path d="M308.13 366.19a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.94 1.55 2.86a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.53-1.4 1.25 1.25 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM329.86 368a2.41 2.41 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.62 4.37-3.9 3.92-.09-5.72-.09-5.72z" class="cls-46"/>
|
||||
<path d="M330.55 368.85a1.47 1.47 0 0 1 1.12-.73c.55 0 1.28.31 1.1 1.82s-1.4 2.69-2.17 2.42a1 1 0 0 1-.36-1 1.14 1.14 0 0 0 .84-1.24 1.25 1.25 0 0 0-.59-1z" class="cls-45"/>
|
||||
<rect width="1.32" height="4.18" x="313.49" y="360.4" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 314.154 362.488)"/>
|
||||
<rect width="1.32" height="4.18" x="324.9" y="361.75" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 325.56 363.84)"/>
|
||||
<path d="M321.2 373.17a2.52 2.52 0 1 1-5-.59z" class="cls-12"/>
|
||||
<circle cx="325.58" cy="367.79" r="1.11" class="cls-8" transform="rotate(-83.23 325.58 367.785)"/>
|
||||
<path d="M327.09 367.83a.34.34 0 0 0 .48-.48c-.68-.67-1.81-1.81-3.92-.39a.34.34 0 1 0 .38.57 2.08 2.08 0 0 1 3.06.31z" class="cls-14"/>
|
||||
<circle cx="313.21" cy="366.32" r="1.11" class="cls-8" transform="rotate(-83.23 313.208 366.32)"/>
|
||||
<path d="M311.72 366a.34.34 0 0 1-.36-.58c.81-.5 2.19-1.33 3.91.54a.34.34 0 1 1-.5.46 2.08 2.08 0 0 0-3-.42z" class="cls-14"/>
|
||||
<path d="M303.59 398.22c.8-2.59 1.61-5.25 2.42-7.88l-5.43-.64c-.69 2.79-1.39 5.4-2.12 7.91z" class="cls-46"/>
|
||||
<path d="M299.62 391.42l5.56 1.73 3.56-11.41c-4.19-1.24-6.64 2.58-7.29 4.44z" class="cls-58"/>
|
||||
<path d="M299.582 391.507l.487-1.386 5.64 1.984-.487 1.387z" class="cls-1"/>
|
||||
<path d="M327.5 401.06c-.17-2.71-.34-5.49-.51-8.23l5.43.64c0 2.87.08 5.57.21 8.19z" class="cls-46"/>
|
||||
<path d="M332.95 395.38l-5.81.38-.79-11.93c4.36-.23 5.86 4.06 6.05 6z" class="cls-58"/>
|
||||
<path d="M326.872 394.63l5.95-.607.148 1.463-5.95.607z" class="cls-1"/>
|
||||
<path d="M240.28 390.81l122.07 14.49-13.55 56.4c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49z" class="cls-15"/>
|
||||
<path d="M349.57 458.48l-.77 3.22c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49v-3.31c.9 5.48 3.76 11.74 10.27 12.48l86.16 10.23c6.52.77 10.75-4.63 12.9-9.75zM240.28 390.81l122.07 14.49-3.54 14.7-118.54-14.07.01-15.12z" class="cls-16"/>
|
||||
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34A6.63 6.63 0 0 1 360 415l-119.73-14.22a6.63 6.63 0 0 1-5.78-7.34 6.63 6.63 0 0 1 7.34-5.78z" class="cls-20"/>
|
||||
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34 6.56 6.56 0 0 1-.27 1.25 6.88 6.88 0 0 1-4.94 1.32L239 397.15a6.88 6.88 0 0 1-4.49-2.44 6.59 6.59 0 0 1 0-1.28 6.63 6.63 0 0 1 7.32-5.77z" class="cls-21"/>
|
||||
<rect width="4.95" height="2.12" x="303.99" y="394.68" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 306.474 395.745)"/>
|
||||
<rect width="4.95" height="2.12" x="306.1" y="394.93" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 308.58 395.994)"/>
|
||||
<path d="M311 393.79a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93z" class="cls-42"/>
|
||||
<path d="M313.08 394a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18zM321.85 395.08a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93zM324 395.33a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
|
||||
<rect width="4.95" height="2.12" x="323.29" y="396.97" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 325.77 398.033)"/>
|
||||
<path d="M319.74 394.83a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
|
||||
<path d="M272.38 389.21a1.06 1.06 0 0 1 .93 1.18l-.31 2.8a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.16-.92z" class="cls-23"/>
|
||||
<rect width="4.95" height="2.12" x="271.72" y="390.85" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 274.19 391.91)"/>
|
||||
<rect width="4.95" height="2.12" x="273.82" y="391.1" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 276.302 392.165)"/>
|
||||
<rect width="4.95" height="2.12" x="275.93" y="391.35" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 278.407 392.413)"/>
|
||||
<rect width="4.95" height="2.12" x="281.54" y="392.02" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 284.02 393.08)"/>
|
||||
<path d="M286.41 390.87a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
|
||||
<rect width="4.95" height="2.12" x="285.75" y="392.52" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 288.23 393.576)"/>
|
||||
<path d="M290.62 391.37a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
|
||||
<path d="M266.86 409.48s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.78-3.87zM301.23 413.56s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.79-3.87zM335.76 417.65s16.1 12.57 23.06 2.3l1.46 1c-8 11.84-25.55-1.85-25.6-1.89z" class="cls-21"/>
|
||||
<path d="M264.27 412.88c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
|
||||
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
|
||||
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
|
||||
<path d="M298.68 417c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17zM333.34 421.07c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
|
||||
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
|
||||
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
|
||||
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
|
||||
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1zM263.57 409.08c-.05 0-18.86 8.36-23.22-3.25l-1.39.7c5 13.39 25.27 4.18 25.33 4.15z" class="cls-21"/>
|
||||
<circle cx="299.56" cy="412.87" r="2.18" class="cls-63" transform="rotate(-83.23 299.563 412.878)"/>
|
||||
<circle cx="265.19" cy="408.79" r="2.18" class="cls-63" transform="rotate(-83.23 265.19 408.8)"/>
|
||||
<circle cx="333.93" cy="416.95" r="2.18" class="cls-63" transform="rotate(-83.23 333.935 416.956)"/>
|
||||
<path d="M299.81 410.71a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM265.44 406.63a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM334.18 414.79a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91z" class="cls-64"/>
|
||||
<path d="M359.32 417.89a2.18 2.18 0 0 1-1 4.15zM240.27 408a2.18 2.18 0 0 1 0-4.27V408z" class="cls-20"/>
|
||||
<path d="M369.24 397.41l2.07 1.24c.59.35.93-.12 1.83-.68.69-.43 1.27-.29 2.63-.31a4.64 4.64 0 0 0 1.76-.67.89.89 0 0 0 .69-.8.78.78 0 0 0 .54-.87 1 1 0 0 1-.32-1.2.78.78 0 0 0-1-.45l-.14.06a.73.73 0 0 0-.8-.32 10.73 10.73 0 0 1-2.6.51.77.77 0 0 0-.13-.71.77.77 0 0 0-1.09-.11l-1.07.94a2.06 2.06 0 0 0-1.33.5 4.51 4.51 0 0 0-1.04 2.87z" class="cls-36"/>
|
||||
<path d="M364 395.85c.1 1.88.85 6.82.86 6.82s1.51-2.23 4.88-6.84l2.36 2.78c-5.41 9-7.48 9.71-9.78 8-.88-.94-1.29-3.33-1.77-5.64a25.48 25.48 0 0 0-.91-3.54z" class="cls-36"/>
|
||||
<path d="M364.57 397.44l-5.52.37-.75-11.32c4.14-.21 5.56 3.85 5.74 5.72z" class="cls-48"/>
|
||||
<path d="M359.32 397.504l5.62-.49.165 1.903-5.62.49z" class="cls-65"/>
|
||||
<circle cx="375.66" cy="391.02" r="4.54" class="cls-66" transform="rotate(-83.23 375.666 391.018)"/>
|
||||
<path d="M373.7 386a2.64 2.64 0 1 0-2.32-.68 2.63 2.63 0 0 0 2.32.68z" class="cls-67"/>
|
||||
<path d="M374.06 387.82a.23.23 0 1 1-.46.09 7.62 7.62 0 0 1 .67-3.77.23.23 0 1 1 .44.16 7.18 7.18 0 0 0-.65 3.52z" class="cls-68"/>
|
||||
<circle cx="332.39" cy="150.75" r="145.02" class="cls-2" transform="rotate(-83.23 332.39 150.748)"/>
|
||||
<path d="M405.16 72.93A203.65 203.65 0 0 0 377.3 62.8c.41 4.76.62 9.81.83 14.87 12.07-1.81 18.57-.56 27-4.75" class="cls-69"/>
|
||||
<path d="M425.64 127.47a30.49 30.49 0 0 1 3.59 1.24c-.18-2.62-.17-5.38-.53-7.86a11.19 11.19 0 0 1-1 2 24 24 0 0 0-2 4.63" class="cls-70"/>
|
||||
<path d="M310 254.66a25.25 25.25 0 0 1-.23-4.9 1.45 1.45 0 0 0-.07-.82 143.69 143.69 0 0 1-14.1-3c3.31 28.15 10.57 46.38 20.65 47.58a8 8 0 0 0 3.94-.34c-1.13-4.19-2.64-9.24-3.82-13-3.8-13.11-6.09-21.18-6.38-25.6" class="cls-71"/>
|
||||
<path d="M268.12 55.36a200.3 200.3 0 0 1 45.05-6.18c7.06-19.78 15.52-35 24.35-42.4l-.52-.06c-26.12 2.58-50.68 20.77-68.92 48.64" class="cls-72"/>
|
||||
<path d="M371 55.56C367.72 27.25 360.45 9.18 350.21 8S328.66 22.61 319 49.39a219.09 219.09 0 0 1 52 6.18" class="cls-73"/>
|
||||
<path d="M339.25 92.05c7.6-6.57 10.54-5.4 20.9-9.21a129.19 129.19 0 0 1 12.65-4c-.24-6.2-.67-12.09-1.15-17.67a224 224 0 0 0-54.59-6.48c-2.22 6.39-4.16 13.14-6.13 20.21 10.73 2.57 20.94 6.87 28.32 17.16" class="cls-74"/>
|
||||
<path d="M293.63 209.69c.17 10.9.55 21.33 1.23 30.67 4.73 1.21 9.49 2.1 14.24 3.15a31.34 31.34 0 0 0-2.33-7.74c-1.7-3.45-7.57-15.51-13.14-26.07" class="cls-75"/>
|
||||
<path d="M363.1 10.14C369 18 373.47 32.15 376 52.25a24.12 24.12 0 0 1 .41 4.76 199.77 199.77 0 0 1 33.89 12.3 34.31 34.31 0 0 0 4.65-5c-11.25-27-29.36-46.67-51.85-54.21" class="cls-76"/>
|
||||
<path d="M434.69 130.49c19.78 7.06 35 15.52 42.4 24.35l.06-.48c-2-21.5-14.52-41.82-34.6-58.65-2.79 4.38-5.66 9.39-9 15.65.54 6.4 1.08 12.79 1.14 19.13" class="cls-70"/>
|
||||
<path d="M475.89 167.69c1.2-10.09-14.65-21.54-41.43-31.22a219.09 219.09 0 0 1-6.18 52c28.31-3.3 46.38-10.57 47.6-20.81" class="cls-77"/>
|
||||
<path d="M394.74 175.75l-9.68 9.08c-.83 1.52-1.86 3.35-2.91 5.34a368.06 368.06 0 0 0 40.74-1.33 224 224 0 0 0 6.48-54.59c-1.7-.69-3.75-1.26-5.77-2-.06.48-.26.78-.31 1.26-4.09 11.2-8.67 23.81-28.55 42.23" class="cls-78"/>
|
||||
<path d="M427.13 194.2a202.69 202.69 0 0 1-16.55 42.35c31.54-11.35 55-31 63.26-55.64v-.16c-7.86 6-22 10.53-42.15 13.18a26.83 26.83 0 0 0-4.58.27" class="cls-79"/>
|
||||
<path d="M373 275.12A123.43 123.43 0 0 0 398.26 246c-5.87 1.58-11.84 2.65-18.15 3.85a54.87 54.87 0 0 1-3.77 14c-1.06 3.45-2.28 6.87-3.29 11.3" class="cls-80"/>
|
||||
<path d="M214.35 81.61C227.3 71 243.51 63 260.2 57.83c12.37-20.77 29.47-39 50.13-48.92A145 145 0 0 0 206 83.38a69.68 69.68 0 0 1 8.33-1.77" class="cls-81"/>
|
||||
<path d="M363.77 224.67l-.22.46c8.12 4 16.35 11.36 16.87 17.91-.06.48.07.82 0 1.3a168 168 0 0 0 22.2-5.16A191.78 191.78 0 0 0 421 194.94a410.77 410.77 0 0 1-42.18 1.16l-2.94 5.66c-5.18 9.45-10.62 19.68-12.14 22.91" class="cls-82"/>
|
||||
<path d="M445.38 91c12.56 10.42 23.28 22.73 29.74 36.65a142.73 142.73 0 0 0-19.3-52.13c-1.21 2-2.75 4.06-4.3 6.15-1.64 2.89-3.8 6-6.14 9.34" class="cls-83"/>
|
||||
<path d="M388.94 18.08c12.56 10.42 22.59 24.43 29.94 40.41A77.24 77.24 0 0 0 425 40a138.12 138.12 0 0 0-36-22" class="cls-84"/>
|
||||
<path d="M240.31 224c-20.77-12.37-39-29.47-48.92-50.13a144.52 144.52 0 0 0 86.34 109.6c-17.79-14.29-30.1-36.37-37.42-59.48" class="cls-85"/>
|
||||
<path d="M406.48 243.7c-9.28 15.29-21.21 29.63-34.93 39.85-.41 2.06-.64 4-1.22 6.19a144.16 144.16 0 0 0 95.76-83.28c-14.61 17.75-36.37 30.1-59.62 37.24" class="cls-86"/>
|
||||
<path d="M255.76 65.09a127 127 0 0 0-29.68 14.82A212.18 212.18 0 0 0 251 75.08c1.54-3.39 3.24-6.76 4.76-10" class="cls-87"/>
|
||||
<path d="M273.64 69c17.82 3.09 31.71 5.06 31.71 5.06 1.76-6.61 3.68-13.2 5.84-19.11a189.68 189.68 0 0 0-47.32 7.53c-1.86 3.35-3.72 6.7-5.6 10.21 8.48-3.05 7-4.36 15.37-3.69" class="cls-88"/>
|
||||
<path d="M269.73 169c-6.85-5.2-12.47-11.22-17.8-16.89a132.84 132.84 0 0 0-13.08-12.92 263.93 263.93 0 0 0-1.67 27.72 379.55 379.55 0 0 0 40.74 11 15.6 15.6 0 0 0-2.62-3.88 34.76 34.76 0 0 0-5.57-5" class="cls-89"/>
|
||||
<path d="M237.84 216.06a200.3 200.3 0 0 1-6.17-45.06c-19.16-6.82-33.8-14.73-41.54-23.44l-.88.55c3.28 25.71 21.19 49.92 48.59 67.94" class="cls-90"/>
|
||||
<path d="M281.36 184.53A426.21 426.21 0 0 1 237.44 173a189.68 189.68 0 0 0 7.56 47.31 191.78 191.78 0 0 0 44.25 18.4 327.53 327.53 0 0 1-.95-39.89c-2-3.65-3.85-8.57-6.91-14.29" class="cls-3"/>
|
||||
<path d="M211.14 134.64l-.18.14-15.78 9.82c6.32 6.92 19.28 14 36.7 20.43a201 201 0 0 1 2-29.47 28.08 28.08 0 0 0-10.9-3.89 15.51 15.51 0 0 0-11.88 3" class="cls-91"/>
|
||||
<path d="M289.81 244.63a202.69 202.69 0 0 1-42.35-16.55c11.35 31.54 31 55 55.64 63.26h.16c-6-7.86-10.53-22-13.18-42.15a16.72 16.72 0 0 0-.27-4.58" class="cls-92"/>
|
||||
<path d="M223.71 125.9c13.45 1.6 24 12.75 32.75 22.23 5.35 5.51 10.65 11.49 17 16.47a28.42 28.42 0 0 1 6.4 6.28 26.83 26.83 0 0 1 4.38 6.85c1.3 2.75 2.37 4.66 3.17 4.76 1.6.19 5.33-1.15 6.14-3.82 1.93-5.29-2.75-11-6.45-15.38-5-6.11-12.6-16.92-14.47-21.69-15-38.14 30.95-62.4 53.95-38.73 2.85-2.1 5.79-5 8-6.85-11.84-16.5-31.79-15.3-51.47-19.1-2.86-.5-10.79-2.09-13.11-1.72a28.57 28.57 0 0 0-6.6 2.3c-19.45 7.92-44.85 7.18-61 13.7a144.06 144.06 0 0 0-11.87 42.59c-.29 2.4-.57 4.8-.71 7.38L208 130a21 21 0 0 1 15.75-4.14" class="cls-93"/>
|
||||
<path d="M374.24 84.51c-8 2-14.58 5.25-19.24 6.16-8.85 2-10.25 4.3-17.31 10.45a93 93 0 0 1-7.78 6.71 3.59 3.59 0 0 0 .55 2.18c1.86 3.47 5.2 2.73 8.4.35 33.68-25.22 54.89 2.62 56.14 18 .62 7.05 0 17.86-10.39 34.16 3.83 1.92 6.43 4.66 6.39 9 25.39-23.77 23.77-36.14 31.61-51.61C437.2 90.2 446 80.69 452.87 70.31a142.92 142.92 0 0 0-22.42-25.88C419.61 88 393.11 80.1 374.24 84.51" class="cls-94"/>
|
||||
<path d="M367.08 274.74c2.36-13 8.36-18.49 7.46-31.42-.26-3.28-7.33-10.77-16-14.08l-2.82-.82 2.86-6.32c1.79-4.17 9.68-18.66 16.52-31.16.81-1.36 9.38-16.09 9.38-18.85-.2-2.46-2.73-4.38-4.65-4.61s-4.26 3.07-6.76 6.34c-3.43 4.3-5.73 8.57-8.66 12.77-3.13 4.5-6.62 9.28-10.31 14.36q-5.56 7.86-12.85 18a9.13 9.13 0 0 1-9.11 4.28c-3.2-.38-6.77-1.78-7.64-2.69s-21.65-31-26.91-37.29a12.8 12.8 0 0 1-8.51 4.67c7 13.81 20.46 39.6 23.07 44.95 3.91 8.09 3.18 12.88 3.81 21.23.26 6 7.51 27 10.81 40.24A143.35 143.35 0 0 0 364 291.1c1-5.56 2.13-11.11 3.08-16.36" class="cls-95"/>
|
||||
<path d="M163 44.29a25.89 25.89 0 0 0-20.48 10 20.92 20.92 0 0 0-20.27 17.21 12.57 12.57 0 0 0-1.89-.14 12.38 12.38 0 1 0 0 24.77c30.16 0 66.81-1.64 100-1.64a9.55 9.55 0 1 0 0-19.11 9.68 9.68 0 0 0-1.92.19 19.38 19.38 0 0 0-31.2-14.72A25.94 25.94 0 0 0 163 44.29z" class="cls-4"/>
|
||||
<path d="M167.72 295.05c2.43.86 4.29 2.11 7 1.43s5.42-1.17 6.39-.5 8.28 8 12.53 20.36-1.93 2.83-4 1.35-7.11-10.44-9.28-11.65-8.44.11-14.24-2c-1.7-.63-4.15.45-5.16 3.15s-1.93 6.53-1.93 6.53-5.38 1-10.92-5.66c1.81-1.85 6.19-4.06 7.18-5.23.41-1.12-.83-2.46-2-3.37a64.77 64.77 0 0 1-7.81-6.77c-2.3-2.63-4.87-1.72-7.8-1.58s-8.72 1.25-11.19 1.08-6.23.22-.57-2.3 19.5-7.48 24.5-6.46c3.25.67 2.67 1.71 4.19 3.69s4.68 6.38 6.9 4.7a17.2 17.2 0 0 1 4.86-3c1.49-.52 2.92-.15 2.4 1.56s-1.05 4.67-1.05 4.67z" class="cls-96"/>
|
||||
<path d="M171.42 288.28a4 4 0 0 0-3.2.9c-.66.75.57.91.82.78a26 26 0 0 1 2.38-1.68z" class="cls-97"/>
|
||||
<ellipse cx="166.11" cy="289.09" class="cls-98" rx=".48" ry=".38" transform="rotate(-34.42 166.098 289.075)"/>
|
||||
<path d="M118.86 326c1.88.3 3.4 1 5.29.11s3.78-1.57 4.57-1.21 7.07 4.68 11.8 13.12-1 2.31-2.69 1.5-6.55-6.63-8.28-7.23-6.11 1.2-10.61.42c-1.31-.23-3 .87-3.33 3s-.54 5-.54 5-3.78 1.42-8.68-2.66c1.07-1.58 4-3.77 4.52-4.75.15-.87-.93-1.67-1.87-2.19a47.81 47.81 0 0 1-6.57-3.88c-2-1.61-3.77-.61-5.87-.11s-6.17 2.06-8 2.27-4.5 1-.72-1.6 13.17-8 16.93-7.94c2.45.05 2.17.89 3.54 2.12s4.25 4 5.64 2.5a12.69 12.69 0 0 1 3.13-2.81c1-.57 2.1-.49 1.95.81s-.21 3.53-.21 3.53z" class="cls-96"/>
|
||||
<path d="M120.65 320.64a2.92 2.92 0 0 0-2.21 1.08c-.38.63.53.59.7.46a19.22 19.22 0 0 1 1.51-1.54z" class="cls-97"/>
|
||||
<ellipse cx="116.9" cy="321.94" class="cls-98" rx=".35" ry=".28" transform="rotate(-44.77 116.913 321.942)"/>
|
||||
<path d="M117.91 237.8c1.29-.47 2.69-1.4 3.91-4.27s2-2.28 4.38-3.45a25.09 25.09 0 0 1 8.88-1.92c3-.18-2.8 3.39-4 4s-3.8 1.4-4.09 2.34a18.45 18.45 0 0 1-2.63 4.5c-.76.76-3.16 2.63-2.34 3.68s3.91 2.1 4.27 2.22-1 3.8-4.5 4.33a23.51 23.51 0 0 1-1.76-4c-.29-.65-1.87-1-3.39.47s-4.32 2.1-5.49 2.4-3 3.27-4.55 4.38-3.45 3-4.33 3.27.53-4 1.11-5a63.46 63.46 0 0 1 5.37-7c.88-.7 2.87-.64 4.62-1.75s1.75-1.23 1.69-1.81-1.87-2.16-2.1-2.75.24-.93 1-.87a11.35 11.35 0 0 1 3.95 1.23z" class="cls-96"/>
|
||||
<path d="M111.55 236.06a15.21 15.21 0 0 1 1.52 1.25c.22.25.6-.38.55-.58a2.87 2.87 0 0 0-2.07-.67z" class="cls-97"/>
|
||||
<ellipse cx="114.62" cy="236.83" class="cls-98" rx=".27" ry=".21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 55 KiB |
@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: maintenance
|
||||
namespace: human-connection
|
||||
labels:
|
||||
human-connection.org/selector: deployment-human-connection-maintenance
|
||||
spec:
|
||||
ports:
|
||||
- name: web
|
||||
port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
human-connection.org/selector: deployment-human-connection-maintenance
|
||||
@ -1 +1,2 @@
|
||||
.ssh/
|
||||
ssh/
|
||||
@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
|
||||
u.updatedAt = user.updatedAt.`$date`,
|
||||
u.deleted = user.deletedAt IS NOT NULL,
|
||||
u.disabled = false
|
||||
MERGE (e:EmailAddress {
|
||||
email: user.email,
|
||||
createdAt: toString(datetime()),
|
||||
verifiedAt: toString(datetime())
|
||||
})
|
||||
MERGE (e)-[:BELONGS_TO]->(u)
|
||||
MERGE (u)<-[:PRIMARY_EMAIL]-(e)
|
||||
WITH u, user, user.badgeIds AS badgeIds
|
||||
UNWIND badgeIds AS badgeId
|
||||
MATCH (b:Badge {id: badgeId})
|
||||
|
||||
@ -9,4 +9,4 @@
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storage: 5Gi
|
||||
|
||||
@ -23,11 +23,7 @@ So, all we have to do is edit the kubernetes deployment of our Neo4J database
|
||||
and set a custom `command` every time we have to carry out tasks like backup,
|
||||
restore, seed etc.
|
||||
|
||||
{% hint style="info" %}
|
||||
TODO: implement maintenance mode
|
||||
{% endhint %}
|
||||
|
||||
First bring the application into maintenance mode to ensure there are no
|
||||
First bring the application into [maintenance mode](https://github.com/Human-Connection/Human-Connection/blob/master/deployment/human-connection/maintenance/README.md) to ensure there are no
|
||||
database connections left and nobody can access the application.
|
||||
|
||||
Run the following:
|
||||
|
||||
@ -42,6 +42,14 @@ services:
|
||||
context: neo4j
|
||||
networks:
|
||||
- hc-network
|
||||
maintenance:
|
||||
image: humanconnection/maintenance:latest
|
||||
build:
|
||||
context: deployment/human-connection/maintenance
|
||||
networks:
|
||||
- hc-network
|
||||
ports:
|
||||
- 80:80
|
||||
|
||||
networks:
|
||||
hc-network:
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
FROM neo4j:3.5.5
|
||||
FROM neo4j:3.5.8
|
||||
LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
|
||||
|
||||
ARG BUILD_COMMIT
|
||||
ENV BUILD_COMMIT=$BUILD_COMMIT
|
||||
|
||||
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.1/apoc-3.5.0.1-all.jar -P plugins/
|
||||
RUN apk add --no-cache --quiet procps
|
||||
COPY db_setup.sh /usr/local/bin/db_setup
|
||||
COPY entrypoint.sh /docker-entrypoint-wrapper.sh
|
||||
RUN apt-get update && apt-get -y install procps wget
|
||||
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/
|
||||
ENTRYPOINT ["/docker-entrypoint-wrapper.sh"]
|
||||
|
||||
@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
|
||||
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
|
||||
|
||||
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
|
||||
' | cypher-shell
|
||||
|
||||
echo '
|
||||
|
||||
@ -22,15 +22,15 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"codecov": "^3.5.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"cypress": "^3.4.0",
|
||||
"cypress-cucumber-preprocessor": "^1.12.0",
|
||||
"cypress-file-upload": "^3.3.1",
|
||||
"cypress": "^3.4.1",
|
||||
"cypress-cucumber-preprocessor": "^1.13.0",
|
||||
"cypress-file-upload": "^3.3.3",
|
||||
"cypress-plugin-retries": "^1.2.2",
|
||||
"dotenv": "^8.0.0",
|
||||
"faker": "Marak/faker.js#master",
|
||||
"graphql-request": "^1.8.2",
|
||||
"neo4j-driver": "^1.7.5",
|
||||
"neode": "^0.2.16",
|
||||
"neode": "^0.3.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"slug": "^1.1.0"
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@ docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t huma
|
||||
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-web:latest $TRAVIS_BUILD_DIR/webapp
|
||||
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT -t humanconnection/neo4j:latest $TRAVIS_BUILD_DIR/neo4j
|
||||
docker build -t humanconnection/maintenance-worker:latest $TRAVIS_BUILD_DIR/deployment/legacy-migration/maintenance-worker
|
||||
docker build -t humanconnection/maintenance:latest $TRAVIS_BUILD_DIR/deployment/human-connection/maintenance
|
||||
docker push humanconnection/nitro-backend:latest
|
||||
docker push humanconnection/nitro-web:latest
|
||||
docker push humanconnection/neo4j:latest
|
||||
docker push humanconnection/maintenance-worker:latest
|
||||
docker push humanconnection/maintenance-worker:latest
|
||||
docker push humanconnection/maintenance:latest
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:12.6-alpine as base
|
||||
FROM node:12.7-alpine as base
|
||||
LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@ -23,49 +23,61 @@
|
||||
:modalsData="menuModalsData"
|
||||
style="float-right"
|
||||
:is-owner="isAuthor(author.id)"
|
||||
@showEditCommentMenu="editCommentMenu"
|
||||
/>
|
||||
</no-ssr>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<!-- TODO: replace editor content with tiptap render view -->
|
||||
|
||||
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
||||
<div
|
||||
v-show="comment.content !== comment.contentExcerpt"
|
||||
style="text-align: right; margin-right: 20px; margin-top: -12px;"
|
||||
>
|
||||
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
|
||||
{{ $t('comment.show.more') }}
|
||||
</a>
|
||||
<ds-space margin-bottom="small" />
|
||||
<div v-if="openEditCommentMenu">
|
||||
<hc-edit-comment-form
|
||||
:comment="comment"
|
||||
:post="post"
|
||||
@showEditCommentMenu="editCommentMenu"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
|
||||
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
|
||||
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
|
||||
{{ $t('comment.show.less') }}
|
||||
</a>
|
||||
<div v-show="!openEditCommentMenu">
|
||||
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
|
||||
<div
|
||||
v-show="comment.content !== comment.contentExcerpt"
|
||||
style="text-align: right; margin-right: 20px; margin-top: -12px;"
|
||||
>
|
||||
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
|
||||
{{ $t('comment.show.more') }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
|
||||
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
|
||||
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
|
||||
{{ $t('comment.show.less') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-space margin-bottom="small" />
|
||||
</ds-card>
|
||||
</div>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import HcUser from '~/components/User'
|
||||
import ContentMenu from '~/components/ContentMenu'
|
||||
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
|
||||
|
||||
export default {
|
||||
data: function() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
openEditCommentMenu: false,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
HcUser,
|
||||
ContentMenu,
|
||||
HcEditCommentForm,
|
||||
},
|
||||
props: {
|
||||
post: { type: Object, default: () => {} },
|
||||
comment: {
|
||||
type: Object,
|
||||
default() {
|
||||
@ -112,9 +124,16 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setEditPending: 'editor/SET_EDIT_PENDING',
|
||||
}),
|
||||
isAuthor(id) {
|
||||
return this.user.id === id
|
||||
},
|
||||
editCommentMenu(showMenu) {
|
||||
this.openEditCommentMenu = showMenu
|
||||
this.setEditPending(showMenu)
|
||||
},
|
||||
async deleteCommentCallback() {
|
||||
try {
|
||||
var gqlMutation = gql`
|
||||
|
||||
@ -76,14 +76,13 @@ export default {
|
||||
}
|
||||
|
||||
if (this.isOwner && this.resourceType === 'comment') {
|
||||
// routes.push({
|
||||
// name: this.$t(`comment.menu.edit`),
|
||||
// callback: () => {
|
||||
// /* eslint-disable-next-line no-console */
|
||||
// console.log('EDIT COMMENT')
|
||||
// },
|
||||
// icon: 'edit'
|
||||
// })
|
||||
routes.push({
|
||||
name: this.$t(`comment.menu.edit`),
|
||||
callback: () => {
|
||||
this.$emit('showEditCommentMenu', true)
|
||||
},
|
||||
icon: 'edit',
|
||||
})
|
||||
routes.push({
|
||||
name: this.$t(`comment.menu.delete`),
|
||||
callback: () => {
|
||||
|
||||
@ -6,6 +6,9 @@ import PostMutations from '~/graphql/PostMutations.js'
|
||||
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
import TeaserImage from '~/components/TeaserImage/TeaserImage'
|
||||
import MutationObserver from 'mutation-observer'
|
||||
|
||||
global.MutationObserver = MutationObserver
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
@ -14,6 +17,8 @@ localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
|
||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||
|
||||
describe('ContributionForm.vue', () => {
|
||||
let wrapper
|
||||
@ -24,7 +29,17 @@ describe('ContributionForm.vue', () => {
|
||||
let mocks
|
||||
let propsData
|
||||
const postTitle = 'this is a title for a post'
|
||||
const postTitleTooShort = 'xx'
|
||||
let postTitleTooLong = ''
|
||||
for (let i = 0; i < 65; i++) {
|
||||
postTitleTooLong += 'x'
|
||||
}
|
||||
const postContent = 'this is a post'
|
||||
const postContentTooShort = 'xx'
|
||||
let postContentTooLong = ''
|
||||
for (let i = 0; i < 2001; i++) {
|
||||
postContentTooLong += 'x'
|
||||
}
|
||||
const imageUpload = {
|
||||
file: {
|
||||
filename: 'avataar.svg',
|
||||
@ -37,22 +52,17 @@ describe('ContributionForm.vue', () => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
CreatePost: {
|
||||
title: postTitle,
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
},
|
||||
mutate: jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
CreatePost: {
|
||||
title: postTitle,
|
||||
slug: 'this-is-a-title-for-a-post',
|
||||
content: postContent,
|
||||
contentExcerpt: postContent,
|
||||
language: 'en',
|
||||
},
|
||||
})
|
||||
.mockRejectedValue({
|
||||
message: 'Not Authorised!',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
@ -74,6 +84,13 @@ describe('ContributionForm.vue', () => {
|
||||
'editor/placeholder': () => {
|
||||
return 'some cool placeholder'
|
||||
},
|
||||
'auth/user': () => {
|
||||
return {
|
||||
id: '4711',
|
||||
name: 'You yourself',
|
||||
slug: 'you-yourself',
|
||||
}
|
||||
},
|
||||
}
|
||||
const store = new Vuex.Store({
|
||||
getters,
|
||||
@ -109,16 +126,53 @@ describe('ContributionForm.vue', () => {
|
||||
})
|
||||
|
||||
describe('invalid form submission', () => {
|
||||
it('title required for form submission', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
it('title and content should not be empty ', async () => {
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('content required for form submission', async () => {
|
||||
wrapper.vm.updateEditorContent(postContent)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
it('title should not be empty', async () => {
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('title should not be too long', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitleTooLong)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('title should not be too short', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitleTooShort)
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('content should not be empty', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('content should not be too short', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContentTooShort)
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('content should not be too long', async () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
await wrapper.vm.updateEditorContent(postContentTooLong)
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -139,15 +193,16 @@ describe('ContributionForm.vue', () => {
|
||||
}
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
wrapper.vm.updateEditorContent(postContent)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
})
|
||||
|
||||
it('with title and content', () => {
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("sends a fallback language based on a user's locale", () => {
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
@ -155,7 +210,7 @@ describe('ContributionForm.vue', () => {
|
||||
expectedParams.variables.language = 'de'
|
||||
deutschOption = wrapper.findAll('li').at(0)
|
||||
deutschOption.trigger('click')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
@ -163,22 +218,26 @@ describe('ContributionForm.vue', () => {
|
||||
const categoryIds = ['cat12', 'cat15', 'cat37']
|
||||
expectedParams.variables.categoryIds = categoryIds
|
||||
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it('supports adding a teaser image', async () => {
|
||||
expectedParams.variables.imageUpload = imageUpload
|
||||
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
it("pushes the user to the post's page", async () => {
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
await mocks.$apollo.mutate
|
||||
expect(mocks.$router.push).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows a success toaster', () => {
|
||||
it('shows a success toaster', async () => {
|
||||
wrapper.find('.submit-button-for-test').trigger('click')
|
||||
await mocks.$apollo.mutate
|
||||
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -194,18 +253,19 @@ describe('ContributionForm.vue', () => {
|
||||
describe('handles errors', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers()
|
||||
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({
|
||||
message: 'Not Authorised!',
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
wrapper.vm.updateEditorContent(postContent)
|
||||
// second submission causes mutation to reject
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.updateEditorContent(postContent)
|
||||
})
|
||||
|
||||
it('shows an error toaster when apollo mutation rejects', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
await mocks.$apollo.mutate
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
|
||||
await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -263,7 +323,7 @@ describe('ContributionForm.vue', () => {
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
postTitleInput.setValue(postTitle)
|
||||
wrapper.vm.updateEditorContent(postContent)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
|
||||
@ -274,7 +334,7 @@ describe('ContributionForm.vue', () => {
|
||||
wrapper.vm.updateEditorContent(postContent)
|
||||
expectedParams.variables.categoryIds = categoryIds
|
||||
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('.submit-button-for-test').trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-form ref="contributionForm" v-model="form" :schema="formSchema" @submit="submit">
|
||||
<ds-form ref="contributionForm" v-model="form" :schema="formSchema">
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-card>
|
||||
<hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage">
|
||||
@ -9,7 +9,11 @@
|
||||
:src="contribution.image | proxyApiUrl"
|
||||
/>
|
||||
</hc-teaser-image>
|
||||
<ds-space />
|
||||
<hc-user :user="currentUser" :trunc="35" />
|
||||
<ds-space />
|
||||
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
||||
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
|
||||
<no-ssr>
|
||||
<hc-editor
|
||||
:users="users"
|
||||
@ -17,6 +21,7 @@
|
||||
:value="form.content"
|
||||
@input="updateEditorContent"
|
||||
/>
|
||||
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
|
||||
</no-ssr>
|
||||
<ds-space margin-bottom="xxx-large" />
|
||||
<hc-categories-select
|
||||
@ -41,18 +46,20 @@
|
||||
<div slot="footer" style="text-align: right">
|
||||
<ds-button
|
||||
class="cancel-button"
|
||||
:disabled="loading || disabled"
|
||||
:disabled="loading"
|
||||
ghost
|
||||
@click.prevent="$router.back()"
|
||||
>
|
||||
{{ $t('actions.cancel') }}
|
||||
</ds-button>
|
||||
<ds-button
|
||||
class="submit-button-for-test"
|
||||
type="submit"
|
||||
icon="check"
|
||||
:loading="loading"
|
||||
:disabled="disabled || errors"
|
||||
:disabled="disabledByContent || errors"
|
||||
primary
|
||||
@click.prevent="submit"
|
||||
>
|
||||
{{ $t('actions.save') }}
|
||||
</ds-button>
|
||||
@ -65,18 +72,21 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import { mapGetters } from 'vuex'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import locales from '~/locales'
|
||||
import PostMutations from '~/graphql/PostMutations.js'
|
||||
import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
|
||||
import HcTeaserImage from '~/components/TeaserImage/TeaserImage'
|
||||
import HcUser from '~/components/User'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HcEditor,
|
||||
HcCategoriesSelect,
|
||||
HcTeaserImage,
|
||||
HcUser,
|
||||
},
|
||||
props: {
|
||||
contribution: { type: Object, default: () => {} },
|
||||
@ -86,6 +96,7 @@ export default {
|
||||
form: {
|
||||
title: '',
|
||||
content: '',
|
||||
contentLength: 0,
|
||||
teaserImage: null,
|
||||
image: null,
|
||||
language: null,
|
||||
@ -94,13 +105,16 @@ export default {
|
||||
},
|
||||
formSchema: {
|
||||
title: { required: true, min: 3, max: 64 },
|
||||
content: { required: true, min: 3 },
|
||||
content: [{ required: true }],
|
||||
},
|
||||
id: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
disabledByContent: true,
|
||||
slug: null,
|
||||
users: [],
|
||||
contentMin: 3,
|
||||
contentMax: 2000,
|
||||
|
||||
hashtags: [],
|
||||
}
|
||||
},
|
||||
@ -113,8 +127,9 @@ export default {
|
||||
}
|
||||
this.id = contribution.id
|
||||
this.slug = contribution.slug
|
||||
this.form.content = contribution.content
|
||||
this.form.title = contribution.title
|
||||
this.form.content = contribution.content
|
||||
this.manageContent(this.form.content)
|
||||
this.form.image = contribution.image
|
||||
this.form.categoryIds = this.categoryIds(contribution.categories)
|
||||
},
|
||||
@ -128,6 +143,9 @@ export default {
|
||||
: locales.find(loc => this.$i18n.locale() === loc.code)
|
||||
return locale.name
|
||||
},
|
||||
...mapGetters({
|
||||
currentUser: 'auth/user',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.availableLocales()
|
||||
@ -160,7 +178,7 @@ export default {
|
||||
.then(res => {
|
||||
this.loading = false
|
||||
this.$toast.success(this.$t('contribution.success'))
|
||||
this.disabled = true
|
||||
this.disabledByContent = true
|
||||
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
|
||||
|
||||
this.$router.push({
|
||||
@ -171,12 +189,21 @@ export default {
|
||||
.catch(err => {
|
||||
this.$toast.error(err.message)
|
||||
this.loading = false
|
||||
this.disabled = false
|
||||
this.disabledByContent = false
|
||||
})
|
||||
},
|
||||
updateEditorContent(value) {
|
||||
// this.form.content = value
|
||||
// TODO: Do smth????? what is happening
|
||||
this.$refs.contributionForm.update('content', value)
|
||||
this.manageContent(value)
|
||||
},
|
||||
manageContent(content) {
|
||||
// filter HTML out of content value
|
||||
const str = content.replace(/<\/?[^>]+(>|$)/gm, '')
|
||||
// Set counter length of text
|
||||
this.form.contentLength = str.length
|
||||
// Enable save button if requirements are met
|
||||
this.disabledByContent = !(this.contentMin <= str.length && str.length <= this.contentMax)
|
||||
},
|
||||
availableLocales() {
|
||||
orderBy(locales, 'name').map(locale => {
|
||||
@ -233,6 +260,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.smallTag {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
left: 90%;
|
||||
}
|
||||
.post-title {
|
||||
margin-top: $space-x-small;
|
||||
margin-bottom: $space-xx-small;
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import Editor from './Editor'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import MutationObserver from 'mutation-observer'
|
||||
|
||||
global.MutationObserver = MutationObserver
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(Vuex)
|
||||
|
||||
@ -2,6 +2,9 @@ import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import CommentForm from './CommentForm.vue'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Vuex from 'vuex'
|
||||
import MutationObserver from 'mutation-observer'
|
||||
|
||||
global.MutationObserver = MutationObserver
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(Vuex)
|
||||
@ -48,6 +51,7 @@ describe('CommentForm.vue', () => {
|
||||
'editor/placeholder': () => {
|
||||
return 'some cool placeholder'
|
||||
},
|
||||
'editor/editPending': () => false,
|
||||
}
|
||||
const store = new Vuex.Store({
|
||||
getters,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-form v-model="form" @submit="handleSubmit">
|
||||
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-card>
|
||||
<hc-editor
|
||||
@ -33,6 +33,7 @@ import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
|
||||
import CommentMutations from '~/graphql/CommentMutations.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -52,6 +53,11 @@ export default {
|
||||
users: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
editPending: 'editor/editPending',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
updateEditorContent(value) {
|
||||
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
|
||||
@ -16,11 +16,12 @@
|
||||
</span>
|
||||
</h3>
|
||||
<ds-space margin-bottom="large" />
|
||||
<div v-if="comments && comments.length" class="comments">
|
||||
<div v-if="comments && comments.length" id="comments" class="comments">
|
||||
<comment
|
||||
v-for="(comment, index) in comments"
|
||||
:key="comment.id"
|
||||
:comment="comment"
|
||||
:post="post"
|
||||
@deleteComment="comments.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
108
webapp/components/comments/EditCommentForm/EditCommentForm.vue
Normal file
108
webapp/components/comments/EditCommentForm/EditCommentForm.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<ds-form v-model="form" @submit="handleSubmit">
|
||||
<template slot-scope="{ errors }">
|
||||
<ds-card>
|
||||
<!-- with no-ssr the content is not shown -->
|
||||
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
||||
<ds-space />
|
||||
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
|
||||
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />
|
||||
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
|
||||
<ds-button ghost class="cancelBtn" @click.prevent="closeEditWindow">
|
||||
{{ $t('actions.cancel') }}
|
||||
</ds-button>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
|
||||
<ds-button type="submit" :loading="loading" :disabled="disabled || errors" primary>
|
||||
{{ $t('post.comment.submit') }}
|
||||
</ds-button>
|
||||
</ds-flex-item>
|
||||
</ds-flex>
|
||||
</ds-card>
|
||||
</template>
|
||||
</ds-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcEditor from '~/components/Editor/Editor'
|
||||
import { mapMutations } from 'vuex'
|
||||
import CommentMutations from '~/graphql/CommentMutations.js'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HcEditor,
|
||||
},
|
||||
props: {
|
||||
comment: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabled: true,
|
||||
loading: false,
|
||||
form: {
|
||||
content: this.comment.content,
|
||||
},
|
||||
users: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setEditPending: 'editor/SET_EDIT_PENDING',
|
||||
}),
|
||||
updateEditorContent(value) {
|
||||
const sanitizedContent = value.replace(/<(?:.|\n)*?>/gm, '').trim()
|
||||
this.disabled = value === this.comment.content || sanitizedContent.length < 1
|
||||
this.form.content = value
|
||||
},
|
||||
closeEditWindow() {
|
||||
this.$emit('showEditCommentMenu', false)
|
||||
},
|
||||
handleSubmit() {
|
||||
this.loading = true
|
||||
this.disabled = true
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: CommentMutations().UpdateComment,
|
||||
variables: {
|
||||
content: this.form.content,
|
||||
id: this.comment.id,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.loading = false
|
||||
|
||||
this.$toast.success(this.$t('post.comment.updated'))
|
||||
this.disabled = false
|
||||
this.$emit('showEditCommentMenu', false)
|
||||
this.setEditPending(false)
|
||||
})
|
||||
.catch(err => {
|
||||
this.$toast.error(err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
User: {
|
||||
query() {
|
||||
return gql`
|
||||
{
|
||||
User(orderBy: slug_asc) {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
result({ data: { User } }) {
|
||||
this.users = User
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -20,5 +20,14 @@ export default () => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
UpdateComment: gql`
|
||||
mutation($content: String!, $id: ID!) {
|
||||
UpdateComment(content: $content, id: $id) {
|
||||
id
|
||||
content
|
||||
contentExcerpt
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import gql from 'graphql-tag'
|
||||
|
||||
export default app => {
|
||||
const lang = app.$i18n.locale().toUpperCase()
|
||||
return gql(`
|
||||
return gql`
|
||||
query Comment($postId: ID) {
|
||||
Comment(postId: $postId) {
|
||||
id
|
||||
@ -30,5 +30,5 @@ export default app => {
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
`
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import gql from 'graphql-tag'
|
||||
|
||||
export default i18n => {
|
||||
const lang = i18n.locale().toUpperCase()
|
||||
return gql(`
|
||||
return gql`
|
||||
query Post($slug: String!) {
|
||||
Post(slug: $slug) {
|
||||
id
|
||||
@ -73,14 +73,14 @@ export default i18n => {
|
||||
shoutedByCurrentUser
|
||||
}
|
||||
}
|
||||
`)
|
||||
`
|
||||
}
|
||||
|
||||
export const filterPosts = i18n => {
|
||||
const lang = i18n.locale().toUpperCase()
|
||||
return gql(`
|
||||
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
|
||||
Post(filter: $filter, first: $first, offset: $offset) {
|
||||
return gql`
|
||||
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
|
||||
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
|
||||
id
|
||||
title
|
||||
contentExcerpt
|
||||
@ -118,5 +118,5 @@ export const filterPosts = i18n => {
|
||||
shoutedCount
|
||||
}
|
||||
}
|
||||
`)
|
||||
`
|
||||
}
|
||||
|
||||
@ -23,6 +23,12 @@
|
||||
"bank": "Bankverbindung",
|
||||
"germany": "Deutschland"
|
||||
},
|
||||
"sorting": {
|
||||
"newest": "Neuste",
|
||||
"oldest": "Älteste",
|
||||
"popular": "Beliebt",
|
||||
"commented": "meist Kommentiert"
|
||||
},
|
||||
"login": {
|
||||
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
|
||||
"login": "Einloggen",
|
||||
@ -168,7 +174,8 @@
|
||||
},
|
||||
"social-media": {
|
||||
"name": "Soziale Medien",
|
||||
"placeholder": "Füge eine Social-Media URL hinzu",
|
||||
"placeholder": "Deine Social-Media URL",
|
||||
"requireUnique": "Dieser Link existiert bereits",
|
||||
"submit": "Link hinzufügen",
|
||||
"successAdd": "Social-Media hinzugefügt. Profil aktualisiert!",
|
||||
"successDelete": "Social-Media gelöscht. Profil aktualisiert!"
|
||||
@ -286,6 +293,7 @@
|
||||
"reportContent": "Melden",
|
||||
"validations": {
|
||||
"email": "muss eine gültige E-Mail Adresse sein",
|
||||
"url": "muss eine gültige URL sein",
|
||||
"verification-code": "muss genau 6 Buchstaben lang sein"
|
||||
}
|
||||
},
|
||||
|
||||
@ -18,11 +18,17 @@
|
||||
"tribunal": "Registry court",
|
||||
"register": "Registry number",
|
||||
"director": "Managing Director",
|
||||
"taxident": "Value added tax identification number according to § 27 a Value Added Tax Act (Germany)",
|
||||
"responsible": "Responsible according to § 55 Abs. 2 RStV (Germany) ",
|
||||
"taxident": "USt-ID. according to §27a of the German Sales Tax Law:",
|
||||
"responsible": "responsible for contents of this page (§ 55 Abs. 2 RStV)",
|
||||
"bank": "bank account",
|
||||
"germany": "Germany"
|
||||
},
|
||||
"sorting": {
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"popular": "Popular",
|
||||
"commented": "most Commented"
|
||||
},
|
||||
"login": {
|
||||
"copy": "If you already have a human-connection account, login here.",
|
||||
"login": "Login",
|
||||
@ -169,7 +175,8 @@
|
||||
},
|
||||
"social-media": {
|
||||
"name": "Social media",
|
||||
"placeholder": "Add social media url",
|
||||
"placeholder": "Your social media url",
|
||||
"requireUnique": "You added this url already",
|
||||
"submit": "Add link",
|
||||
"successAdd": "Added social media. Updated user profile!",
|
||||
"successDelete": "Deleted social media. Updated user profile!"
|
||||
@ -246,7 +253,8 @@
|
||||
},
|
||||
"comment": {
|
||||
"submit": "Comment",
|
||||
"submitted": "Comment Submitted"
|
||||
"submitted": "Comment Submitted",
|
||||
"updated": "Changes Saved"
|
||||
}
|
||||
},
|
||||
"comment": {
|
||||
@ -287,6 +295,7 @@
|
||||
"reportContent": "Report",
|
||||
"validations": {
|
||||
"email": "must be a valid email address",
|
||||
"url": "must be a valid URL",
|
||||
"verification-code": "must be 6 characters long"
|
||||
}
|
||||
},
|
||||
|
||||
@ -305,15 +305,6 @@ module.exports = {
|
||||
tokenName: 'human-connection-token', // optional, default: apollo-token
|
||||
tokenExpires: 3, // optional, default: 7 (days)
|
||||
// includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder)
|
||||
// optional
|
||||
errorHandler(error) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(
|
||||
'%cError',
|
||||
'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;',
|
||||
error.message,
|
||||
)
|
||||
},
|
||||
|
||||
// Watch loading state for all queries
|
||||
// See 'Smart Query > options > watchLoading' for detail
|
||||
|
||||
@ -50,16 +50,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@human-connection/styleguide": "0.5.17",
|
||||
"@nuxtjs/apollo": "4.0.0-rc4.2",
|
||||
"@nuxtjs/apollo": "^4.0.0-rc10",
|
||||
"@nuxtjs/axios": "~5.5.4",
|
||||
"@nuxtjs/dotenv": "~1.3.0",
|
||||
"@nuxtjs/style-resources": "~0.1.2",
|
||||
"accounting": "~0.4.1",
|
||||
"apollo-cache-inmemory": "~1.5.1",
|
||||
"apollo-cache-inmemory": "~1.6.2",
|
||||
"apollo-client": "~2.6.3",
|
||||
"cookie-universal-nuxt": "~2.0.16",
|
||||
"cookie-universal-nuxt": "~2.0.17",
|
||||
"cross-env": "~5.2.0",
|
||||
"date-fns": "2.0.0-beta.2",
|
||||
"date-fns": "2.0.0-beta.3",
|
||||
"express": "~4.17.1",
|
||||
"graphql": "~14.4.2",
|
||||
"isemail": "^3.2.0",
|
||||
@ -70,21 +70,21 @@
|
||||
"nuxt-env": "~0.1.0",
|
||||
"stack-utils": "^1.0.2",
|
||||
"string-hash": "^1.1.3",
|
||||
"tiptap": "1.21.0",
|
||||
"tiptap-extensions": "1.22.2",
|
||||
"tiptap": "~1.24.0",
|
||||
"tiptap-extensions": "~1.26.0",
|
||||
"v-tooltip": "~2.0.2",
|
||||
"vue-count-to": "~1.0.13",
|
||||
"vue-izitoast": "1.1.2",
|
||||
"vue-sweetalert-icons": "~3.2.0",
|
||||
"vuex-i18n": "~1.11.0",
|
||||
"vuex-i18n": "~1.13.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "~7.5.4",
|
||||
"@babel/core": "~7.5.5",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/preset-env": "~7.5.4",
|
||||
"@vue/cli-shared-utils": "~3.9.0",
|
||||
"@vue/eslint-config-prettier": "~4.0.1",
|
||||
"@babel/preset-env": "~7.5.5",
|
||||
"@vue/cli-shared-utils": "~3.10.0",
|
||||
"@vue/eslint-config-prettier": "~5.0.0",
|
||||
"@vue/server-test-utils": "~1.0.0-beta.29",
|
||||
"@vue/test-utils": "~1.0.0-beta.29",
|
||||
"babel-core": "~7.0.0-bridge.0",
|
||||
@ -94,20 +94,22 @@
|
||||
"eslint-config-prettier": "~6.0.0",
|
||||
"eslint-config-standard": "~12.0.0",
|
||||
"eslint-loader": "~2.2.1",
|
||||
"eslint-plugin-import": "~2.18.0",
|
||||
"eslint-plugin-jest": "~22.8.0",
|
||||
"eslint-plugin-import": "~2.18.2",
|
||||
"eslint-plugin-jest": "~22.14.1",
|
||||
"eslint-plugin-node": "~9.1.0",
|
||||
"eslint-plugin-prettier": "~3.1.0",
|
||||
"eslint-plugin-promise": "~4.2.1",
|
||||
"eslint-plugin-standard": "~4.0.0",
|
||||
"eslint-plugin-vue": "~5.2.3",
|
||||
"flush-promises": "^1.0.2",
|
||||
"fuse.js": "^3.4.5",
|
||||
"jest": "~24.8.0",
|
||||
"mutation-observer": "^1.0.3",
|
||||
"node-sass": "~4.12.0",
|
||||
"nodemon": "~1.19.1",
|
||||
"prettier": "~1.18.2",
|
||||
"sass-loader": "~7.1.0",
|
||||
"tippy.js": "^4.3.4",
|
||||
"tippy.js": "^4.3.5",
|
||||
"vue-jest": "~3.0.4",
|
||||
"vue-svg-loader": "~0.12.0"
|
||||
}
|
||||
|
||||
140
webapp/pages/index.spec.js
Normal file
140
webapp/pages/index.spec.js
Normal file
@ -0,0 +1,140 @@
|
||||
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
|
||||
import PostIndex from './index.vue'
|
||||
import Vuex from 'vuex'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
import VTooltip from 'v-tooltip'
|
||||
import FilterMenu from '~/components/FilterMenu/FilterMenu'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Vuex)
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
localVue.use(VTooltip)
|
||||
|
||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
||||
config.stubs['router-link'] = '<span><slot /></span>'
|
||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||
|
||||
describe('PostIndex', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let store
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Vuex.Store({
|
||||
getters: {
|
||||
'posts/posts': () => {
|
||||
return [
|
||||
{
|
||||
id: 'p23',
|
||||
name: 'It is a post',
|
||||
author: {
|
||||
id: 'u1',
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
'auth/user': () => {
|
||||
return { id: 'u23' }
|
||||
},
|
||||
},
|
||||
})
|
||||
mocks = {
|
||||
$t: key => key,
|
||||
$filters: {
|
||||
truncate: a => a,
|
||||
removeLinks: jest.fn(),
|
||||
},
|
||||
// If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
|
||||
$router: {
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
push: jest.fn(),
|
||||
},
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
mutate: jest.fn().mockResolvedValue(),
|
||||
queries: {
|
||||
Post: {
|
||||
refetch: jest.fn(),
|
||||
fetchMore: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'p23',
|
||||
name: 'It is a post',
|
||||
author: {
|
||||
id: 'u1',
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
$route: {
|
||||
query: {},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('shallowMount', () => {
|
||||
Wrapper = () => {
|
||||
return shallowMount(PostIndex, {
|
||||
store,
|
||||
mocks,
|
||||
localVue,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('refetches Posts when changeFilterBubble is emitted', () => {
|
||||
wrapper.find(FilterMenu).vm.$emit('changeFilterBubble')
|
||||
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the search when the filter menu emits clearSearch', () => {
|
||||
wrapper.find(FilterMenu).vm.$emit('clearSearch')
|
||||
expect(wrapper.vm.hashtag).toBeNull()
|
||||
})
|
||||
|
||||
it('calls the changeFilterBubble if there are hasthags in the route query', () => {
|
||||
mocks.$route.query.hashtag = { id: 'hashtag' }
|
||||
wrapper = Wrapper()
|
||||
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = mount(PostIndex, {
|
||||
store,
|
||||
mocks,
|
||||
localVue,
|
||||
})
|
||||
})
|
||||
|
||||
it('sets the post in the store when there are posts', () => {
|
||||
wrapper
|
||||
.findAll('li')
|
||||
.at(0)
|
||||
.trigger('click')
|
||||
expect(wrapper.vm.sorting).toEqual('createdAt_desc')
|
||||
})
|
||||
|
||||
it('loads more posts when a user clicks on the load more button', () => {
|
||||
wrapper
|
||||
.findAll('button')
|
||||
.at(2)
|
||||
.trigger('click')
|
||||
expect(mocks.$apollo.queries.Post.fetchMore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,6 +9,17 @@
|
||||
@clearSearch="clearSearch"
|
||||
/>
|
||||
</ds-flex-item>
|
||||
<ds-flex-item>
|
||||
<div class="sorting-dropdown">
|
||||
<ds-select
|
||||
v-model="selected"
|
||||
:options="sortingOptions"
|
||||
size="large"
|
||||
v-bind:icon-right="sortingIcon"
|
||||
@input="toggleOnlySorting"
|
||||
></ds-select>
|
||||
</div>
|
||||
</ds-flex-item>
|
||||
<hc-post-card
|
||||
v-for="(post, index) in posts"
|
||||
:key="post.id"
|
||||
@ -53,6 +64,36 @@ export default {
|
||||
pageSize: 12,
|
||||
filter: {},
|
||||
hashtag,
|
||||
placeholder: this.$t('sorting.newest'),
|
||||
selected: this.$t('sorting.newest'),
|
||||
sortingIcon: 'sort-amount-desc',
|
||||
sorting: 'createdAt_desc',
|
||||
sortingOptions: [
|
||||
{
|
||||
label: this.$t('sorting.newest'),
|
||||
value: 'Newest',
|
||||
icons: 'sort-amount-desc',
|
||||
order: 'createdAt_desc',
|
||||
},
|
||||
{
|
||||
label: this.$t('sorting.oldest'),
|
||||
value: 'Oldest',
|
||||
icons: 'sort-amount-asc',
|
||||
order: 'createdAt_asc',
|
||||
},
|
||||
{
|
||||
label: this.$t('sorting.popular'),
|
||||
value: 'Popular',
|
||||
icons: 'fire',
|
||||
order: 'shoutedCount_desc',
|
||||
},
|
||||
{
|
||||
label: this.$t('sorting.commented'),
|
||||
value: 'Commented',
|
||||
icons: 'comment',
|
||||
order: 'commentsCount_desc',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -89,7 +130,12 @@ export default {
|
||||
}
|
||||
}
|
||||
this.filter = filter
|
||||
this.$apollo.queries.Post.refresh()
|
||||
this.$apollo.queries.Post.refetch()
|
||||
},
|
||||
toggleOnlySorting(x) {
|
||||
this.sortingIcon = x.icons
|
||||
this.sorting = x.order
|
||||
this.$apollo.queries.Post.refetch()
|
||||
},
|
||||
clearSearch() {
|
||||
this.$router.push({ path: '/' })
|
||||
@ -144,6 +190,7 @@ export default {
|
||||
filter: this.filter,
|
||||
first: this.pageSize,
|
||||
offset: 0,
|
||||
orderBy: this.sorting,
|
||||
}
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
@ -161,4 +208,11 @@ export default {
|
||||
transform: translate(-120%, -120%);
|
||||
box-shadow: $box-shadow-x-large;
|
||||
}
|
||||
|
||||
.sorting-dropdown {
|
||||
width: 250px;
|
||||
position: relative;
|
||||
float: right;
|
||||
padding: 0 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -134,9 +134,9 @@
|
||||
</ds-text>
|
||||
<template>
|
||||
<ds-space v-for="link in socialMediaLinks" :key="link.username" margin="x-small">
|
||||
<a :href="link.url">
|
||||
<a :href="link.url" target="_blank">
|
||||
<ds-avatar :image="link.favicon" />
|
||||
{{ 'link.username' }}
|
||||
{{ link.username }}
|
||||
</a>
|
||||
</ds-space>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import flushPromises from 'flush-promises'
|
||||
import MySocialMedia from './my-social-media.vue'
|
||||
import Vuex from 'vuex'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
@ -12,23 +13,17 @@ localVue.use(Filters)
|
||||
|
||||
describe('my-social-media.vue', () => {
|
||||
let wrapper
|
||||
let store
|
||||
let mocks
|
||||
let getters
|
||||
let input
|
||||
let submitBtn
|
||||
const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
|
||||
const newSocialMediaUrl = 'https://twitter.com/mattwr18'
|
||||
const faviconUrl = 'https://freeradical.zone/favicon.ico'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockRejectedValue({ message: 'Ouch!' })
|
||||
.mockResolvedValueOnce({
|
||||
data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } },
|
||||
}),
|
||||
mutate: jest.fn(),
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
@ -43,79 +38,161 @@ describe('my-social-media.vue', () => {
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
let form, input, submitButton
|
||||
const Wrapper = () => {
|
||||
store = new Vuex.Store({
|
||||
const store = new Vuex.Store({
|
||||
getters,
|
||||
})
|
||||
return mount(MySocialMedia, { store, mocks, localVue })
|
||||
}
|
||||
|
||||
it('renders', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.contains('div')).toBe(true)
|
||||
})
|
||||
|
||||
describe('given currentUser has a social media account linked', () => {
|
||||
describe('adding social media link', () => {
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
'auth/user': () => {
|
||||
return {
|
||||
socialMedia: [{ id: 's1', url: socialMediaUrl }],
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it("displays a link to the currentUser's social media", () => {
|
||||
wrapper = Wrapper()
|
||||
const socialMediaLink = wrapper.find('a').attributes().href
|
||||
expect(socialMediaLink).toBe(socialMediaUrl)
|
||||
form = wrapper.find('form')
|
||||
input = wrapper.find('input#addSocialMedia')
|
||||
submitButton = wrapper.find('button')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockRejectedValue({ message: 'Ouch!' })
|
||||
.mockResolvedValueOnce({
|
||||
data: { DeleteSocialMeda: { id: 's1', url: socialMediaUrl } },
|
||||
}),
|
||||
},
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
}
|
||||
getters = {
|
||||
'auth/user': () => {
|
||||
return {
|
||||
socialMedia: [{ id: 's1', url: socialMediaUrl }],
|
||||
}
|
||||
},
|
||||
}
|
||||
it('requires the link to be a valid url', () => {
|
||||
input.setValue('some value')
|
||||
form.trigger('submit')
|
||||
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays a trash sympol after a social media and allows the user to delete it', () => {
|
||||
wrapper = Wrapper()
|
||||
const deleteSelector = wrapper.find({ name: 'delete' })
|
||||
expect(deleteSelector).toEqual({ selector: 'Component' })
|
||||
const icon = wrapper.find({ name: 'trash' })
|
||||
icon.trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
|
||||
it('displays an error message when not saved successfully', async () => {
|
||||
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
|
||||
input.setValue(newSocialMediaUrl)
|
||||
form.trigger('submit')
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('success', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate.mockResolvedValue({
|
||||
data: { CreateSocialMedia: { id: 's2', url: newSocialMediaUrl } },
|
||||
})
|
||||
input.setValue(newSocialMediaUrl)
|
||||
form.trigger('submit')
|
||||
})
|
||||
|
||||
it('sends the new url to the backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { url: newSocialMediaUrl },
|
||||
})
|
||||
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('displays a success message', async () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the form', async () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(input.value).toBe(undefined)
|
||||
expect(submitButton.vm.$attrs.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentUser does not have a social media account linked', () => {
|
||||
it('allows a user to add a social media link', () => {
|
||||
describe('given existing social media links', () => {
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
'auth/user': () => ({
|
||||
socialMedia: [{ id: 's1', url: socialMediaUrl }],
|
||||
}),
|
||||
}
|
||||
|
||||
wrapper = Wrapper()
|
||||
input = wrapper.find({ name: 'social-media' })
|
||||
input.element.value = socialMediaUrl
|
||||
input.trigger('input')
|
||||
submitBtn = wrapper.find('.ds-button')
|
||||
submitBtn.trigger('click')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
|
||||
form = wrapper.find('form')
|
||||
})
|
||||
|
||||
describe('for each link it', () => {
|
||||
it('displays the favicon', () => {
|
||||
expect(wrapper.find(`img[src="${faviconUrl}"]`).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the url', () => {
|
||||
expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the edit button', () => {
|
||||
expect(wrapper.find('a[name="edit"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the delete button', () => {
|
||||
expect(wrapper.find('a[name="delete"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not accept a duplicate url', () => {
|
||||
input = wrapper.find('input#addSocialMedia')
|
||||
|
||||
input.setValue(socialMediaUrl)
|
||||
form.trigger('submit')
|
||||
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('editing social media link', () => {
|
||||
beforeEach(() => {
|
||||
const editButton = wrapper.find('a[name="edit"]')
|
||||
editButton.trigger('click')
|
||||
input = wrapper.find('input#editSocialMedia')
|
||||
})
|
||||
|
||||
it('disables adding new links while editing', () => {
|
||||
const addInput = wrapper.find('input#addSocialMedia')
|
||||
|
||||
expect(addInput.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the new url to the backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { id: 's1', url: newSocialMediaUrl },
|
||||
})
|
||||
input.setValue(newSocialMediaUrl)
|
||||
form.trigger('submit')
|
||||
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('allows the user to cancel editing', () => {
|
||||
const cancelButton = wrapper.find('button#cancel')
|
||||
cancelButton.trigger('click')
|
||||
|
||||
expect(wrapper.find('input#editSocialMedia').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting social media link', () => {
|
||||
beforeEach(() => {
|
||||
const deleteButton = wrapper.find('a[name="delete"]')
|
||||
deleteButton.trigger('click')
|
||||
})
|
||||
|
||||
it('sends the link id to the backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { id: 's1' },
|
||||
})
|
||||
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('displays a success message', async () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,49 +1,90 @@
|
||||
<template>
|
||||
<ds-card :header="$t('settings.social-media.name')">
|
||||
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
|
||||
<ds-list>
|
||||
<ds-list-item v-for="link in socialMediaLinks" :key="link.id">
|
||||
<a :href="link.url" target="_blank">
|
||||
<img :src="link.favicon | proxyApiUrl" alt="Social Media link" width="16" height="16" />
|
||||
{{ link.url }}
|
||||
</a>
|
||||
|
||||
<span class="layout-leave-active">|</span>
|
||||
|
||||
<ds-icon name="edit" class="layout-leave-active" />
|
||||
<a name="delete" @click="handleDeleteSocialMedia(link)">
|
||||
<ds-icon name="trash" />
|
||||
</a>
|
||||
</ds-list-item>
|
||||
</ds-list>
|
||||
</ds-space>
|
||||
<ds-space margin-top="base">
|
||||
<div>
|
||||
<ds-input
|
||||
v-model="value"
|
||||
:placeholder="$t('settings.social-media.placeholder')"
|
||||
name="social-media"
|
||||
:schema="{ type: 'url' }"
|
||||
/>
|
||||
</div>
|
||||
<ds-space margin-top="base">
|
||||
<div>
|
||||
<ds-button primary @click="handleAddSocialMedia">
|
||||
{{ $t('settings.social-media.submit') }}
|
||||
</ds-button>
|
||||
</div>
|
||||
<ds-form
|
||||
v-model="formData"
|
||||
:schema="formSchema"
|
||||
@input="handleInput"
|
||||
@input-valid="handleInputValid"
|
||||
@submit="handleSubmitSocialMedia"
|
||||
>
|
||||
<ds-card :header="$t('settings.social-media.name')">
|
||||
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
|
||||
<ds-list>
|
||||
<ds-list-item v-for="link in socialMediaLinks" :key="link.id" class="list-item--high">
|
||||
<ds-input
|
||||
v-if="editingLink.id === link.id"
|
||||
id="editSocialMedia"
|
||||
model="socialMediaUrl"
|
||||
type="text"
|
||||
:placeholder="$t('settings.social-media.placeholder')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<a :href="link.url" target="_blank">
|
||||
<img :src="link.favicon" alt="Link:" height="16" width="16" />
|
||||
{{ link.url }}
|
||||
</a>
|
||||
<span class="divider">|</span>
|
||||
<a name="edit" @click="handleEditSocialMedia(link)">
|
||||
<ds-icon
|
||||
:aria-label="$t('actions.edit')"
|
||||
class="icon-button"
|
||||
name="edit"
|
||||
:title="$t('actions.edit')"
|
||||
/>
|
||||
</a>
|
||||
<a name="delete" @click="handleDeleteSocialMedia(link)">
|
||||
<ds-icon
|
||||
:aria-label="$t('actions.delete')"
|
||||
class="icon-button"
|
||||
name="trash"
|
||||
:title="$t('actions.delete')"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ds-list-item>
|
||||
</ds-list>
|
||||
</ds-space>
|
||||
</ds-space>
|
||||
</ds-card>
|
||||
|
||||
<ds-space margin-top="base">
|
||||
<ds-input
|
||||
v-if="!editingLink.id"
|
||||
id="addSocialMedia"
|
||||
model="socialMediaUrl"
|
||||
type="text"
|
||||
:placeholder="$t('settings.social-media.placeholder')"
|
||||
/>
|
||||
<ds-space margin-top="base">
|
||||
<ds-button primary :disabled="disabled">
|
||||
{{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }}
|
||||
</ds-button>
|
||||
<ds-button v-if="editingLink.id" id="cancel" ghost @click="handleCancel()">
|
||||
{{ $t('actions.cancel') }}
|
||||
</ds-button>
|
||||
</ds-space>
|
||||
</ds-space>
|
||||
</ds-card>
|
||||
</ds-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import unionBy from 'lodash/unionBy'
|
||||
import gql from 'graphql-tag'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
value: '',
|
||||
formData: {
|
||||
socialMediaUrl: '',
|
||||
},
|
||||
formSchema: {
|
||||
socialMediaUrl: {
|
||||
type: 'url',
|
||||
message: this.$t('common.validations.url'),
|
||||
},
|
||||
},
|
||||
disabled: true,
|
||||
editingLink: {},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -51,11 +92,10 @@ export default {
|
||||
currentUser: 'auth/user',
|
||||
}),
|
||||
socialMediaLinks() {
|
||||
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
|
||||
const { socialMedia = [] } = this.currentUser
|
||||
return socialMedia.map(socialMedia => {
|
||||
const { id, url } = socialMedia
|
||||
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
|
||||
const [domain] = matches || []
|
||||
return socialMedia.map(({ id, url }) => {
|
||||
const [domain] = url.match(domainRegex) || []
|
||||
const favicon = domain ? `${domain}/favicon.ico` : null
|
||||
return { id, url, favicon }
|
||||
})
|
||||
@ -65,39 +105,28 @@ export default {
|
||||
...mapMutations({
|
||||
setCurrentUser: 'auth/SET_USER',
|
||||
}),
|
||||
handleAddSocialMedia() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: gql`
|
||||
mutation($url: String!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
url: this.value,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
const socialMedia = [...this.currentUser.socialMedia, data.CreateSocialMedia]
|
||||
this.setCurrentUser({
|
||||
...this.currentUser,
|
||||
socialMedia,
|
||||
})
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success(this.$t('settings.social-media.successAdd'))
|
||||
this.value = ''
|
||||
})
|
||||
.catch(error => {
|
||||
this.$toast.error(error.message)
|
||||
})
|
||||
handleCancel() {
|
||||
this.editingLink = {}
|
||||
this.formData.socialMediaUrl = ''
|
||||
this.disabled = true
|
||||
},
|
||||
handleDeleteSocialMedia(link) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
handleEditSocialMedia(link) {
|
||||
this.editingLink = link
|
||||
this.formData.socialMediaUrl = link.url
|
||||
},
|
||||
handleInput(data) {
|
||||
this.disabled = true
|
||||
},
|
||||
handleInputValid(data) {
|
||||
if (data.socialMediaUrl.length < 1) {
|
||||
this.disabled = true
|
||||
} else {
|
||||
this.disabled = false
|
||||
}
|
||||
},
|
||||
async handleDeleteSocialMedia(link) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($id: ID!) {
|
||||
DeleteSocialMedia(id: $id) {
|
||||
@ -119,19 +148,83 @@ export default {
|
||||
})
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success(this.$t('settings.social-media.successDelete'))
|
||||
})
|
||||
.catch(error => {
|
||||
this.$toast.error(error.message)
|
||||
|
||||
this.$toast.success(this.$t('settings.social-media.successDelete'))
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
}
|
||||
},
|
||||
async handleSubmitSocialMedia() {
|
||||
const isEditing = !!this.editingLink.id
|
||||
const url = this.formData.socialMediaUrl
|
||||
|
||||
const duplicateUrl = this.socialMediaLinks.find(link => link.url === url)
|
||||
if (duplicateUrl && duplicateUrl.id !== this.editingLink.id) {
|
||||
return this.$toast.error(this.$t('settings.social-media.requireUnique'))
|
||||
}
|
||||
|
||||
let mutation = gql`
|
||||
mutation($url: String!) {
|
||||
CreateSocialMedia(url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { url }
|
||||
let successMessage = this.$t('settings.social-media.successAdd')
|
||||
|
||||
if (isEditing) {
|
||||
mutation = gql`
|
||||
mutation($id: ID!, $url: String!) {
|
||||
UpdateSocialMedia(id: $id, url: $url) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
variables.id = this.editingLink.id
|
||||
successMessage = this.$t('settings.data.success')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation,
|
||||
variables,
|
||||
update: (store, { data }) => {
|
||||
const newSocialMedia = isEditing ? data.UpdateSocialMedia : data.CreateSocialMedia
|
||||
this.setCurrentUser({
|
||||
...this.currentUser,
|
||||
socialMedia: unionBy([newSocialMedia], this.currentUser.socialMedia, 'id'),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
this.$toast.success(successMessage)
|
||||
this.formData.socialMediaUrl = ''
|
||||
this.disabled = true
|
||||
this.editingLink = {}
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-leave-active {
|
||||
.divider {
|
||||
opacity: 0.4;
|
||||
padding: 0 $space-small;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item--high {
|
||||
.ds-list-item-prefix {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export const state = () => {
|
||||
return {
|
||||
placeholder: null,
|
||||
editPending: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +9,16 @@ export const getters = {
|
||||
placeholder(state) {
|
||||
return state.placeholder
|
||||
},
|
||||
editPending(state) {
|
||||
return state.editPending
|
||||
},
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
SET_PLACEHOLDER_TEXT(state, text) {
|
||||
state.placeholder = text
|
||||
},
|
||||
SET_EDIT_PENDING(state, boolean) {
|
||||
state.editPending = boolean
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user