Merge branch 'master' into lokalise-2020-02-19_20-53-55

This commit is contained in:
Mogge 2020-03-02 23:14:57 +01:00 committed by GitHub
commit f17cf9db14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
140 changed files with 3360 additions and 3076 deletions

View File

@ -6,7 +6,8 @@ addons:
- libgconf-2-4
snaps:
- docker
firefox: "latest-esr"
install:
- yarn global add wait-on
# Install Codecov

View File

@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.4.0](https://github.com/Human-Connection/Human-Connection/compare/v0.3.1...v0.4.0)
> 21 February 2020
- fix: Favor Cypress.Promise over async/await in e2e tests [`#3115`](https://github.com/Human-Connection/Human-Connection/pull/3115)
- docs(setup): Fix links in tests [`#3120`](https://github.com/Human-Connection/Human-Connection/pull/3120)
- feat: 🍰 Expose sensitive report type to moderators only [`#3075`](https://github.com/Human-Connection/Human-Connection/pull/3075)
- refactor: migrate card component [`#2870`](https://github.com/Human-Connection/Human-Connection/pull/2870)
- build(deps): bump metascraper-youtube from 5.10.7 to 5.11.1 in /backend [`#3114`](https://github.com/Human-Connection/Human-Connection/pull/3114)
- fix(cypress): Upgrade cypress, remove log out step [`#3119`](https://github.com/Human-Connection/Human-Connection/pull/3119)
- build(deps): bump metascraper-date from 5.10.7 to 5.11.1 in /backend [`#3069`](https://github.com/Human-Connection/Human-Connection/pull/3069)
- build(deps): bump metascraper-author from 5.10.7 to 5.11.1 in /backend [`#3070`](https://github.com/Human-Connection/Human-Connection/pull/3070)
- build(deps): bump xregexp from 4.2.4 to 4.3.0 in /webapp [`#3047`](https://github.com/Human-Connection/Human-Connection/pull/3047)
- build(deps): bump metascraper-publisher from 5.10.7 to 5.11.1 in /backend [`#3068`](https://github.com/Human-Connection/Human-Connection/pull/3068)
- build(deps): bump @sentry/node from 5.12.3 to 5.12.4 in /backend [`#3113`](https://github.com/Human-Connection/Human-Connection/pull/3113)
- feat: German Translations Update By Andreas Plank [`#3109`](https://github.com/Human-Connection/Human-Connection/pull/3109)
- fix(frontend): Remove Hover Menu from User Teaser [`#3093`](https://github.com/Human-Connection/Human-Connection/pull/3093)
- build(deps-dev): bump eslint-plugin-jest from 23.6.0 to 23.7.0 in /webapp [`#3030`](https://github.com/Human-Connection/Human-Connection/pull/3030)
- fix(frontend): Post page won't crash on anonymous user [`#2981`](https://github.com/Human-Connection/Human-Connection/pull/2981)
- chore(cypress): Remove debug statements [`#3110`](https://github.com/Human-Connection/Human-Connection/pull/3110)
- build(deps): bump metascraper-audio from 5.10.7 to 5.11.1 in /backend [`#3066`](https://github.com/Human-Connection/Human-Connection/pull/3066)
- build(deps): bump @nuxtjs/sentry from 3.2.3 to 3.2.4 in /webapp [`#3081`](https://github.com/Human-Connection/Human-Connection/pull/3081)
- build(deps-dev): bump apollo-server-testing from 2.10.0 to 2.10.1 in /backend [`#3078`](https://github.com/Human-Connection/Human-Connection/pull/3078)
- fix(subscriptions): Don't publish undefined [`#3101`](https://github.com/Human-Connection/Human-Connection/pull/3101)
- build(deps): [security] bump yarn from 1.17.3 to 1.22.0 in /webapp [`#3077`](https://github.com/Human-Connection/Human-Connection/pull/3077)
- feat: Normalize locales/json files [`#3003`](https://github.com/Human-Connection/Human-Connection/pull/3003)
- 🍰feat: Delete teaser image [`#2585`](https://github.com/Human-Connection/Human-Connection/pull/2585)
- fix: swap lat and lng [`#2589`](https://github.com/Human-Connection/Human-Connection/pull/2589)
- fix(frontend): avatar image covers full circle [`#3102`](https://github.com/Human-Connection/Human-Connection/pull/3102)
- fix(jwt): Whitelist encoded JWT attributes [`#3090`](https://github.com/Human-Connection/Human-Connection/pull/3090)
- test: Write cypress tests for ImageUploader [`#3056`](https://github.com/Human-Connection/Human-Connection/pull/3056)
- build(deps-dev): bump eslint-plugin-vue from 6.1.2 to 6.2.1 in /webapp [`#3092`](https://github.com/Human-Connection/Human-Connection/pull/3092)
- build: Fix intermittent failing tests [`#3087`](https://github.com/Human-Connection/Human-Connection/pull/3087)
- fix(nuxt-env): Configuration issue with websockets [`#3089`](https://github.com/Human-Connection/Human-Connection/pull/3089)
- build(deps-dev): bump eslint-plugin-jest from 23.6.0 to 23.7.0 in /backend [`#3029`](https://github.com/Human-Connection/Human-Connection/pull/3029)
- build(deps): bump cookie-universal-nuxt from 2.1.1 to 2.1.2 in /webapp [`#3073`](https://github.com/Human-Connection/Human-Connection/pull/3073)
- build(deps): bump @nuxtjs/sentry from 3.2.2 to 3.2.3 in /webapp [`#3072`](https://github.com/Human-Connection/Human-Connection/pull/3072)
- build(deps): bump metascraper-image from 5.10.7 to 5.11.1 in /backend [`#3067`](https://github.com/Human-Connection/Human-Connection/pull/3067)
- build(deps-dev): bump vue-loader from 15.8.3 to 15.9.0 in /webapp [`#3060`](https://github.com/Human-Connection/Human-Connection/pull/3060)
- build(deps-dev): bump @storybook/addon-actions from 5.3.12 to 5.3.13 in /webapp [`#3049`](https://github.com/Human-Connection/Human-Connection/pull/3049)
- refactor(cypress): Speed up builds, avoid login through UI [`#3042`](https://github.com/Human-Connection/Human-Connection/pull/3042)
- feat: 🍰 Set up Vue-Apollo Subscriptions [`#1705`](https://github.com/Human-Connection/Human-Connection/pull/1705)
- fix: Update devops_ticket.md [`#3053`](https://github.com/Human-Connection/Human-Connection/pull/3053)
- build(deps-dev): bump @storybook/addon-notes from 5.3.12 to 5.3.13 in /webapp [`#3048`](https://github.com/Human-Connection/Human-Connection/pull/3048)
- build(deps-dev): bump @storybook/addon-a11y from 5.3.12 to 5.3.13 in /webapp [`#3050`](https://github.com/Human-Connection/Human-Connection/pull/3050)
- build(deps): Node v13 compatbility [`#3041`](https://github.com/Human-Connection/Human-Connection/pull/3041)
- build(deps): bump request from 2.88.0 to 2.88.2 in /backend [`#3045`](https://github.com/Human-Connection/Human-Connection/pull/3045)
- build(deps-dev): bump @storybook/vue from 5.3.12 to 5.3.13 in /webapp [`#3046`](https://github.com/Human-Connection/Human-Connection/pull/3046)
- feat(deployment): Add helm charts for deploy [`#1613`](https://github.com/Human-Connection/Human-Connection/pull/1613)
- build(deps-dev): bump vue-svg-loader from 0.15.0 to 0.16.0 in /webapp [`#3039`](https://github.com/Human-Connection/Human-Connection/pull/3039)
- fix: Increase body parser limit [`#3037`](https://github.com/Human-Connection/Human-Connection/pull/3037)
- chore: Update to v0.3.1 [`#3035`](https://github.com/Human-Connection/Human-Connection/pull/3035)
- fix(subscriptions): Don't publish undefined [`#3088`](https://github.com/Human-Connection/Human-Connection/issues/3088)
- locales sorted. [`fa906ef`](https://github.com/Human-Connection/Human-Connection/commit/fa906efb1f40dc5bd80c9678f33c7b607a320099)
- Upgrade cypress, remove log out step [`0df4038`](https://github.com/Human-Connection/Human-Connection/commit/0df40386dd866c6b9ce540b966dfe00089507d31)
- Refactor GQL and tests, first approach [`f380915`](https://github.com/Human-Connection/Human-Connection/commit/f380915b2c679d42e5db136ea1d923cf00bbcf10)
#### [v0.3.1](https://github.com/Human-Connection/Human-Connection/compare/v0.3.0...v0.3.1)
> 10 February 2020
@ -57,8 +114,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Fix typo [`#2966`](https://github.com/Human-Connection/Human-Connection/pull/2966)
- chore: Update to v0.3.0 [`#2941`](https://github.com/Human-Connection/Human-Connection/pull/2941)
- Replace buildList with array of Promises [`46edc3f`](https://github.com/Human-Connection/Human-Connection/commit/46edc3fdd5b83c2f00506f595b1254d7597767e0)
- build(deps-dev): bump @storybook/addon-notes in /webapp [`75137ce`](https://github.com/Human-Connection/Human-Connection/commit/75137ce716dadcc6f0ceeed6a2b0fe5c50fa7b8f)
- Update to v0.3.0 [`dbe2c4c`](https://github.com/Human-Connection/Human-Connection/commit/dbe2c4cdd5bab2195c6369b84989507b9f7da768)
- refactor TeaserImage component [`e14cbf8`](https://github.com/Human-Connection/Human-Connection/commit/e14cbf8173e3040b5285ba6a5c73e2d2d2a47860)
- refactor DeleteData template and CSS [`509892b`](https://github.com/Human-Connection/Human-Connection/commit/509892b6caee6c4ca8384fb0090122ced98edfd4)
#### [v0.3.0](https://github.com/Human-Connection/Human-Connection/compare/v0.2.1...v0.3.0)
@ -349,7 +406,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- fixes #2659 [`#2659`](https://github.com/Human-Connection/Human-Connection/issues/2659)
- Convert block/unblock to blacklist/whitelist [`c297b83`](https://github.com/Human-Connection/Human-Connection/commit/c297b83f873edc61ddec370633b9b65896c56591)
- Rename blacklist/whitelist to mute/unmute [`ba3e9e1`](https://github.com/Human-Connection/Human-Connection/commit/ba3e9e1025bf432151c9bf1002045179b338ff7f)
- build(deps-dev): bump storybook-design-token in /webapp [`88d39c4`](https://github.com/Human-Connection/Human-Connection/commit/88d39c4a427cb86527b06201f3f5e96d53ac09a0)
- manage button states and color schemes with mixin [`1b9249c`](https://github.com/Human-Connection/Human-Connection/commit/1b9249c685e34eb2e94b31ee0ec22421c6aa6a73)
#### [v0.2.0](https://github.com/Human-Connection/Human-Connection/compare/v0.1.13...v0.2.0)

View File

@ -1,6 +1,6 @@
{
"name": "human-connection-backend",
"version": "0.3.1",
"version": "0.4.0",
"description": "GraphQL Backend for Human Connection",
"main": "src/index.js",
"scripts": {
@ -38,19 +38,19 @@
},
"dependencies": {
"@hapi/joi": "^17.1.0",
"@sentry/node": "^5.12.3",
"@sentry/node": "^5.13.1",
"apollo-cache-inmemory": "~1.6.5",
"apollo-client": "~2.6.8",
"apollo-link-context": "~1.0.19",
"apollo-link-http": "~1.5.16",
"apollo-server": "~2.10.0",
"apollo-server": "~2.10.1",
"apollo-server-express": "^2.9.16",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~7.0.0",
"date-fns": "2.9.0",
"date-fns": "2.10.0",
"debug": "~4.1.1",
"dotenv": "~8.2.0",
"express": "^4.17.1",
@ -61,30 +61,30 @@
"graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1",
"graphql-redis-subscriptions": "^2.1.2",
"graphql-shield": "~7.0.11",
"graphql-shield": "~7.0.14",
"graphql-tag": "~2.10.3",
"helmet": "~3.21.2",
"ioredis": "^4.14.1",
"helmet": "~3.21.3",
"ioredis": "^4.16.0",
"jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.6",
"metascraper": "^5.11.0",
"metascraper": "^5.11.4",
"metascraper-audio": "^5.11.1",
"metascraper-author": "^5.10.7",
"metascraper-author": "^5.11.1",
"metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.10.7",
"metascraper-description": "^5.11.0",
"metascraper-date": "^5.11.1",
"metascraper-description": "^5.11.1",
"metascraper-image": "^5.11.1",
"metascraper-lang": "^5.10.7",
"metascraper-lang": "^5.11.1",
"metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.10.7",
"metascraper-publisher": "^5.10.7",
"metascraper-soundcloud": "^5.10.7",
"metascraper-title": "^5.10.7",
"metascraper-url": "^5.10.7",
"metascraper-video": "^5.10.7",
"metascraper-youtube": "^5.10.7",
"metascraper-logo": "^5.11.1",
"metascraper-publisher": "^5.11.1",
"metascraper-soundcloud": "^5.11.4",
"metascraper-title": "^5.11.1",
"metascraper-url": "^5.11.1",
"metascraper-video": "^5.11.1",
"metascraper-youtube": "^5.11.1",
"migrate": "^1.6.2",
"minimatch": "^3.0.4",
"mustache": "^4.0.0",
@ -92,29 +92,29 @@
"neo4j-graphql-js": "^2.11.5",
"neode": "^0.3.7",
"node-fetch": "~2.6.0",
"nodemailer": "^6.4.2",
"nodemailer": "^6.4.4",
"nodemailer-html-to-text": "^3.1.0",
"npm-run-all": "~4.1.5",
"request": "~2.88.2",
"sanitize-html": "~1.21.1",
"sanitize-html": "~1.22.0",
"slug": "~2.1.1",
"subscriptions-transport-ws": "^0.9.16",
"trunc-html": "~1.1.2",
"uuid": "~3.4.0",
"uuid": "~7.0.1",
"validator": "^12.2.0",
"wait-on": "~4.0.0",
"xregexp": "^4.2.4"
"wait-on": "~4.0.1",
"xregexp": "^4.3.0"
},
"devDependencies": {
"@babel/cli": "~7.8.4",
"@babel/core": "~7.8.4",
"@babel/core": "~7.8.6",
"@babel/node": "~7.8.4",
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/preset-env": "~7.8.4",
"@babel/register": "^7.8.3",
"@babel/preset-env": "~7.8.6",
"@babel/register": "^7.8.6",
"apollo-server-testing": "~2.10.1",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.3",
"babel-eslint": "~10.1.0",
"babel-jest": "~25.1.0",
"chai": "~4.2.0",
"cucumber": "~6.0.5",
@ -122,7 +122,7 @@
"eslint-config-prettier": "~6.10.0",
"eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.20.1",
"eslint-plugin-jest": "~23.7.0",
"eslint-plugin-jest": "~23.8.1",
"eslint-plugin-node": "~11.0.0",
"eslint-plugin-prettier": "~3.1.2",
"eslint-plugin-promise": "~4.2.1",

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
import faker from 'faker'
import slugify from 'slug'
import { hashSync } from 'bcryptjs'

View File

@ -152,6 +152,7 @@ export default shield(
User: {
email: or(isMyOwn, isAdmin),
},
Report: isModerator,
},
{
debug,

View File

@ -9,14 +9,6 @@ const driver = getDriver()
let query, authenticatedUser, owner, anotherRegularUser, administrator, variables, moderator
const userQuery = gql`
query($name: String) {
User(name: $name) {
email
}
}
`
describe('authorization', () => {
beforeAll(async () => {
await cleanDatabase()
@ -30,7 +22,11 @@ describe('authorization', () => {
query = createTestClient(server).query
})
describe('given two existing users', () => {
afterEach(async () => {
await cleanDatabase()
})
describe('given an owner, an other user, an admin, a moderator', () => {
beforeEach(async () => {
;[owner, anotherRegularUser, administrator, moderator] = await Promise.all([
Factory.build(
@ -79,15 +75,20 @@ describe('authorization', () => {
variables = {}
})
afterEach(async () => {
await cleanDatabase()
})
describe('access email address', () => {
const userQuery = gql`
query($name: String) {
User(name: $name) {
email
}
}
`
describe('unauthenticated', () => {
beforeEach(() => {
authenticatedUser = null
})
it("throws an error and does not expose the owner's email address", async () => {
await expect(
query({ query: userQuery, variables: { name: 'Owner' } }),
@ -143,7 +144,7 @@ describe('authorization', () => {
})
})
describe('administrator', () => {
describe('as an administrator', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})

View File

@ -153,7 +153,7 @@ describe('slugifyMiddleware', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/neo4j
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
`)
}
})

View File

@ -58,7 +58,7 @@ const reportMutation = gql`
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
reportId
}
}
`

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid },

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests

View File

@ -46,7 +46,7 @@ describe('slug', () => {
\`\`\`
Learn how to setup the database here:
https://docs.human-connection.org/human-connection/neo4j
https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints
`)
}
})

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
import Resolver from './helpers/Resolver'
export default {

View File

@ -1,7 +1,7 @@
import { createWriteStream } from 'fs'
import path from 'path'
import slug from 'slug'
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
const localFileUpload = async ({ createReadStream, uniqueFilename }) => {
await new Promise((resolve, reject) =>

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
export default function generateNonce() {
return uuid().substring(0, 6)
}

View File

@ -1,20 +1,10 @@
const transformReturnType = record => {
return {
...record.get('review').properties,
report: record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
import log from './helpers/databaseLogger'
export default {
Mutation: {
review: async (_object, params, context, _resolveInfo) => {
const { user: moderator, driver } = context
let createdRelationshipWithNestedAttributes = null // return value
const session = driver.session()
try {
const cypher = `
@ -25,10 +15,11 @@ export default {
ON CREATE SET review.createdAt = $dateTime, review.updatedAt = review.createdAt
ON MATCH SET review.updatedAt = $dateTime
SET review.disable = $params.disable
SET report.updatedAt = $dateTime, report.closed = $params.closed
SET resource.disabled = review.disable
SET report.updatedAt = $dateTime, report.disable = review.disable, report.closed = $params.closed
SET resource.disabled = report.disable
RETURN review, report, resource, labels(resource)[0] AS type
WITH review, report, resource {.*, __typename: labels(resource)[0]} AS finalResource
RETURN review {.*, report: properties(report), resource: properties(finalResource)}
`
const reviewWriteTxResultPromise = session.writeTransaction(async txc => {
const reviewTransactionResponse = await txc.run(cypher, {
@ -36,16 +27,14 @@ export default {
moderatorId: moderator.id,
dateTime: new Date().toISOString(),
})
return reviewTransactionResponse.records.map(transformReturnType)
log(reviewTransactionResponse)
return reviewTransactionResponse.records.map(record => record.get('review'))
})
const txResult = await reviewWriteTxResultPromise
if (!txResult[0]) return null
createdRelationshipWithNestedAttributes = txResult[0]
const [reviewed] = await reviewWriteTxResultPromise
return reviewed || null
} finally {
session.close()
}
return createdRelationshipWithNestedAttributes
},
},
}

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
import bcrypt from 'bcryptjs'
import createPasswordReset from './helpers/createPasswordReset'
@ -22,6 +22,7 @@ export default {
WHERE duration.between(passwordReset.issuedAt, datetime()).days <= 0 AND passwordReset.usedAt IS NULL
SET passwordReset.usedAt = datetime()
SET user.encryptedPassword = $encryptedNewPassword
SET user.updatedAt = toString(datetime())
RETURN passwordReset
`,
{

View File

@ -1,4 +1,4 @@
import uuid from 'uuid/v4'
import { v4 as uuid } from 'uuid'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { isEmpty } from 'lodash'
import { UserInputError } from 'apollo-server'

View File

@ -1,23 +1,13 @@
import log from './helpers/databaseLogger'
const transformReturnType = record => {
return {
...record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
export default {
Mutation: {
fileReport: async (_parent, params, context, _resolveInfo) => {
const { resourceId, reasonCategory, reasonDescription } = params
const { driver, user } = context
const session = driver.session()
const reportWriteTxResultPromise = session.writeTransaction(async transaction => {
const reportTransactionResponse = await transaction.run(
const fileReportWriteTxResultPromise = session.writeTransaction(async transaction => {
const fileReportTransactionResponse = await transaction.run(
`
MATCH (submitter:User {id: $submitterId})
MATCH (resource {id: $resourceId})
@ -27,7 +17,8 @@ export default {
WITH submitter, resource, report
CREATE (report)<-[filed:FILED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter)
RETURN report, resource, labels(resource)[0] AS type
WITH filed, report, resource {.*, __typename: labels(resource)[0]} AS finalResource
RETURN filed {.*, reportId: report.id, resource: properties(finalResource)} AS filedReport
`,
{
resourceId,
@ -37,13 +28,12 @@ export default {
reasonDescription,
},
)
log(reportTransactionResponse)
return reportTransactionResponse.records.map(transformReturnType)
log(fileReportTransactionResponse)
return fileReportTransactionResponse.records.map(record => record.get('filedReport'))
})
try {
const [createdRelationshipWithNestedAttributes] = await reportWriteTxResultPromise
if (!createdRelationshipWithNestedAttributes) return null
return createdRelationshipWithNestedAttributes
const [filedReport] = await fileReportWriteTxResultPromise
return filedReport || null
} finally {
session.close()
}
@ -76,14 +66,24 @@ export default {
filterClause = ''
}
if (params.closed) filterClause = 'AND report.closed = true'
switch (params.closed) {
case true:
filterClause = 'AND report.closed = true'
break
case false:
filterClause = 'AND report.closed = false'
break
default:
break
}
const offset =
params.offset && typeof params.offset === 'number' ? `SKIP ${params.offset}` : ''
const limit = params.first && typeof params.first === 'number' ? `LIMIT ${params.first}` : ''
const reportReadTxPromise = session.readTransaction(async transaction => {
const allReportsTransactionResponse = await transaction.run(
const reportsReadTxPromise = session.readTransaction(async transaction => {
const reportsTransactionResponse = await transaction.run(
// !!! this Cypher query returns multiple reports on the same resource! i will create an issue for refactoring (bug fixing)
`
MATCH (report:Report)-[:BELONGS_TO]->(resource)
WHERE (resource:User OR resource:Post OR resource:Comment)
@ -101,11 +101,11 @@ export default {
${offset} ${limit}
`,
)
log(allReportsTransactionResponse)
return allReportsTransactionResponse.records.map(record => record.get('report'))
log(reportsTransactionResponse)
return reportsTransactionResponse.records.map(record => record.get('report'))
})
try {
const reports = await reportReadTxPromise
const reports = await reportsReadTxPromise
return reports
} finally {
session.close()

View File

@ -10,18 +10,17 @@ const driver = getDriver()
describe('file a report on a resource', () => {
let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser
const categoryIds = ['cat9']
const reportMutation = gql`
const fileReportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
createdAt
updatedAt
closed
rule
reasonCategory
reasonDescription
reportId
resource {
__typename
... on User {
@ -34,6 +33,35 @@ describe('file a report on a resource', () => {
content
}
}
}
}
`
const variables = {
resourceId: 'invalid',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
const reportsQuery = gql`
query($closed: Boolean) {
reports(orderBy: createdAt_desc, closed: $closed) {
id
createdAt
updatedAt
rule
disable
closed
resource {
__typename
... on User {
id
}
... on Post {
id
}
... on Comment {
id
}
}
filed {
submitter {
id
@ -45,11 +73,31 @@ describe('file a report on a resource', () => {
}
}
`
const variables = {
resourceId: 'whatever',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
const reviewMutation = gql`
mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
createdAt
resource {
__typename
... on User {
id
disabled
}
... on Post {
id
disabled
}
... on Comment {
id
disabled
}
}
report {
disable
}
}
}
`
beforeAll(async () => {
await cleanDatabase()
@ -74,7 +122,7 @@ describe('file a report on a resource', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: fileReportMutation, variables })).resolves.toMatchObject({
data: { fileReport: null },
errors: [{ message: 'Not Authorised!' }],
})
@ -94,6 +142,17 @@ describe('file a report on a resource', () => {
password: '1234',
},
)
moderator = await Factory.build(
'user',
{
id: 'moderator-id',
role: 'moderator',
},
{
email: 'moderator@example.org',
password: '1234',
},
)
otherReportingUser = await Factory.build(
'user',
{
@ -127,7 +186,7 @@ describe('file a report on a resource', () => {
describe('invalid resource id', () => {
it('returns null', async () => {
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: fileReportMutation, variables })).resolves.toMatchObject({
data: { fileReport: null },
errors: undefined,
})
@ -139,47 +198,112 @@ describe('file a report on a resource', () => {
it('which belongs to resource', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
id: expect.any(String),
reportId: expect.any(String),
resource: {
name: 'abusive-user',
},
},
},
errors: undefined,
})
})
it('creates only one report for multiple reports on the same resource', async () => {
it('only one report for multiple reports on the same resource', async () => {
const firstReport = await mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
authenticatedUser = await otherReportingUser.toJson()
const secondReport = await mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
expect(firstReport.data.fileReport.id).toEqual(secondReport.data.fileReport.id)
expect(firstReport.data.fileReport.reportId).toEqual(
secondReport.data.fileReport.reportId,
)
})
it('returns the rule for how the report was decided', async () => {
await expect(
mutate({
mutation: reportMutation,
describe('report properties are set correctly', () => {
const reportsCypherQuery =
'MATCH (resource:User {id: $resourceId})<-[:BELONGS_TO]-(report:Report {closed: false})<-[filed:FILED]-(user:User {id: $currentUserId}) RETURN report'
it('with the rule for how the report will be decided', async () => {
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
rule: 'latestReviewUpdatedAtRules',
},
},
errors: undefined,
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ rule: 'latestReviewUpdatedAtRules' })
})
describe('with overtaken disabled from resource in disable property', () => {
it('disable is false', async () => {
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ disable: false })
})
it('disable is true', async () => {
// first time filling a report to enable a moderator the disable the resource
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
authenticatedUser = await moderator.toJson()
await mutate({
mutation: reviewMutation,
variables: {
resourceId: 'abusive-user-id',
disable: true,
closed: true,
},
})
authenticatedUser = await currentUser.toJson()
// second time filling a report to see if the "disable is true" of the resource is overtaken
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ disable: true })
})
})
})
it.todo('creates multiple filed reports')
})
@ -187,7 +311,7 @@ describe('file a report on a resource', () => {
it('returns __typename "User"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -205,7 +329,7 @@ describe('file a report on a resource', () => {
it('returns user attribute info', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -221,32 +345,10 @@ describe('file a report on a resource', () => {
})
})
it('returns the submitter', async () => {
it('returns a createdAt', async () => {
await expect(
mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
submitter: {
id: 'current-user-id',
},
},
],
},
},
errors: undefined,
})
})
it('returns a date', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -262,7 +364,7 @@ describe('file a report on a resource', () => {
it('returns the reason category', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -272,11 +374,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonCategory: 'criminal_behavior_violation_german_law',
},
],
reasonCategory: 'criminal_behavior_violation_german_law',
},
},
errors: undefined,
@ -286,7 +384,7 @@ describe('file a report on a resource', () => {
it('gives an error if the reason category is not in enum "ReasonCategory"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -307,7 +405,7 @@ describe('file a report on a resource', () => {
it('returns the reason description', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -317,11 +415,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonDescription: 'My reason!',
},
],
reasonDescription: 'My reason!',
},
},
errors: undefined,
@ -331,7 +425,7 @@ describe('file a report on a resource', () => {
it('sanitizes the reason description', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -341,11 +435,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonDescription: 'My reason !',
},
],
reasonDescription: 'My reason !',
},
},
errors: undefined,
@ -371,7 +461,7 @@ describe('file a report on a resource', () => {
it('returns type "Post"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'post-to-report-id',
@ -392,7 +482,7 @@ describe('file a report on a resource', () => {
it('returns resource in post attribute', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'post-to-report-id',
@ -442,7 +532,7 @@ describe('file a report on a resource', () => {
it('returns type "Comment"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'comment-to-report-id',
@ -463,7 +553,7 @@ describe('file a report on a resource', () => {
it('returns resource in comment attribute', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'comment-to-report-id',
@ -493,7 +583,7 @@ describe('file a report on a resource', () => {
it('returns null', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'tag-to-report-id',
@ -510,37 +600,6 @@ describe('file a report on a resource', () => {
})
describe('query for reported resource', () => {
const reportsQuery = gql`
query {
reports(orderBy: createdAt_desc) {
id
createdAt
updatedAt
closed
resource {
__typename
... on User {
id
}
... on Post {
id
}
... on Comment {
id
}
}
filed {
submitter {
id
}
createdAt
reasonCategory
reasonDescription
}
}
}
`
beforeEach(async () => {
authenticatedUser = null
moderator = await Factory.build(
@ -632,7 +691,7 @@ describe('file a report on a resource', () => {
authenticatedUser = await currentUser.toJson()
await Promise.all([
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-post-1',
reasonCategory: 'other',
@ -640,7 +699,7 @@ describe('file a report on a resource', () => {
},
}),
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-comment-1',
reasonCategory: 'discrimination_etc',
@ -648,7 +707,7 @@ describe('file a report on a resource', () => {
},
}),
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-user-1',
reasonCategory: 'doxing',

View File

@ -251,12 +251,12 @@ export default {
boolean: {
followedByCurrentUser:
'MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isBlocked:
'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
blocked:
'MATCH (this)-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isMuted:
'MATCH (this)<-[:MUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isBlocked:
'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
},
count: {
contributionsCount:

View File

@ -16,3 +16,15 @@ enum ReasonCategory {
advert_products_services_commercial
criminal_behavior_violation_german_law
}
type FiledReport {
createdAt: String!
reasonCategory: ReasonCategory!
reasonDescription: String!
reportId: ID!
resource: ReportedResource!
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): FiledReport
}

View File

@ -4,7 +4,6 @@ type REVIEWED {
disable: Boolean!
closed: Boolean!
report: Report
# @cypher(statement: "MATCH (report:Report)<-[this:REVIEWED]-(:User) RETURN report")
moderator: User
resource: ReviewedResource
}

View File

@ -5,9 +5,9 @@ type Report {
rule: ReportRule!
disable: Boolean!
closed: Boolean!
filed: [FILED]
filed: [FILED]!
reviewed: [REVIEWED]!
resource: ReportedResource
resource: ReportedResource!
}
union ReportedResource = User | Post | Comment
@ -16,10 +16,6 @@ enum ReportRule {
latestReviewUpdatedAtRules
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): Report
}
type Query {
reports(orderBy: ReportOrdering, first: Int, offset: Int, reviewed: Boolean, closed: Boolean): [Report]
}

View File

@ -64,10 +64,11 @@ type User {
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(
statement: """
MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId})
MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1
"""
)
isBlocked: Boolean! @cypher(
statement: """
MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
{
"projectId": "qa7fe2",
"ignoreTestFiles": "*.js",
"baseUrl": "http://localhost:3000"
"baseUrl": "http://localhost:3000",
"env": {
"RETRIES": 2
}
}

View File

@ -1,2 +1,2 @@
// please change also version in file "webapp/constants/terms-and-conditions-version.js"
export const VERSION = '0.0.3'
export const VERSION = '0.0.4'

View File

@ -30,7 +30,7 @@ Then("my comment should be successfully created", () => {
});
Then("I should see my comment", () => {
cy.get("div.comment p")
cy.get("article.comment-card p")
.should("contain", "Human Connection rocks")
.get(".user-avatar img")
.should("have.attr", "src")
@ -40,12 +40,12 @@ Then("I should see my comment", () => {
});
Then("I should see the entirety of my comment", () => {
cy.get("div.comment")
cy.get("article.comment-card")
.should("not.contain", "show more")
});
Then("I should see an abreviated version of my comment", () => {
cy.get("div.comment")
cy.get("article.comment-card")
.should("contain", "show more")
});
@ -60,7 +60,7 @@ Then("it should create a mention in the CommentForm", () => {
})
When("I open the content menu of post {string}", (title)=> {
cy.contains('.post-card', title)
cy.contains('.post-teaser', title)
.find('.content-menu .base-button')
.click()
})
@ -77,9 +77,10 @@ Then("there is no button to pin a post", () => {
})
And("the post with title {string} has a ribbon for pinned posts", (title) => {
cy.get("article.post-card").contains(title)
cy.get(".post-teaser").contains(title)
.parent()
.find("div.ribbon.ribbon--pinned")
.parent()
.find(".ribbon.--pinned")
.should("contain", "Announcement")
})
@ -111,7 +112,7 @@ Then("I add all required fields", () => {
.get(".categories-select .base-button")
.first()
.click()
.get('.ds-flex-item > .ds-form-item .ds-select ')
.get('.base-card > .select-field input')
.click()
.get('.ds-select-option')
.eq(languages.findIndex(l => l.code === 'en'))
@ -119,7 +120,7 @@ Then("I add all required fields", () => {
})
Then("the post was saved successfully with the {string} teaser image", condition => {
cy.get(".ds-card-content > .ds-heading")
cy.get(".base-card > .title")
.should("contain", condition === 'updated' ? 'to be updated' : 'new post')
.get(".content")
.should("contain", condition === 'updated' ? 'successfully updated' : 'new post content')
@ -128,25 +129,22 @@ Then("the post was saved successfully with the {string} teaser image", condition
.and("contains", condition === 'updated' ? 'humanconnection' : 'onourjourney')
})
Then("the first image should be removed from the preview", () => {
cy.fixture("humanconnection.png").as('postTeaserImage').then(function() {
cy.get("#postdropzone")
.children()
.get('img.thumbnail-preview')
.should('have.length', 1)
.and('have.attr', 'src')
.and('contain', this.postTeaserImage)
})
Then("the first image should not be displayed anymore", () => {
cy.get(".hero-image")
.children()
.get('.hero-image > .image')
.should('have.length', 1)
.and('have.attr', 'src')
})
Then('the {string} post was saved successfully without a teaser image', condition => {
cy.get(".ds-card-content > .ds-heading")
cy.get(".base-card > .title")
.should("contain", condition === 'updated' ? 'to be updated' : 'new post')
.get(".content")
.should("contain", condition === 'updated' ? 'successfully updated' : 'new post content')
.get('.post-page')
.should('exist')
.get('.post-page img.ds-card-image')
.get('.hero-image > .image')
.should('not.exist')
})
@ -156,12 +154,12 @@ Then('I should be able to remove it', () => {
})
When('my post has a teaser image', () => {
cy.get('.contribution-image')
cy.get('.contribution-form .image')
.should('exist')
.and('have.attr', 'src')
})
Then('I should be able to remove the image', () => {
cy.get('.delete-image')
cy.get('.dz-message > .base-button')
.click()
})
})

View File

@ -29,7 +29,7 @@ When("I visit another user's profile page", () => {
});
Then("I cannot upload a picture", () => {
cy.get(".ds-card-content")
cy.get(".base-card")
.children()
.should("not.have.id", "customdropzone")
.should("have.class", "user-avatar");

View File

@ -12,7 +12,7 @@ let annoyingUserWhoMutedModeratorTitle = 'Fake news'
const savePostTitle = $post => {
return $post
.first()
.find('.ds-heading')
.find('.title')
.first()
.invoke('text')
.then(title => {
@ -51,7 +51,7 @@ Given('I am logged in with a {string} role', role => {
})
When('I click on "Report Post" from the content menu of the post', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
cy.contains('.base-card', davidIrvingPostTitle)
.find('.content-menu .base-button')
.click({force: true})
@ -61,7 +61,7 @@ When('I click on "Report Post" from the content menu of the post', () => {
})
When('I click on "Report User" from the content menu in the user info box', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
cy.contains('.base-card', davidIrvingPostTitle)
.get('.user-content-menu .base-button')
.click({ force: true })
@ -78,7 +78,7 @@ When('I click on the author', () => {
When('I report the author', () => {
cy.get('.page-name-profile-id-slug').then(() => {
invokeReportOnElement('.ds-card').then(() => {
invokeReportOnElement('.base-card').then(() => {
cy.get('button')
.contains('Send')
.click()
@ -139,7 +139,7 @@ Given('somebody reported the following posts:', table => {
.authenticateAs(submitter)
.mutate(gql`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) {
id
reportId
}
}`, {
resourceId,
@ -169,7 +169,7 @@ Then('each list item links to the post page', () => {
Then('I can visit the post page', () => {
cy.contains(annoyingUserWhoMutedModeratorTitle).click()
cy.location('pathname').should('contain', '/post')
.get('h3').should('contain', annoyingUserWhoMutedModeratorTitle)
.get('.base-card .title').should('contain', annoyingUserWhoMutedModeratorTitle)
})
When("they have a post someone has reported", () => {

View File

@ -1,6 +1,6 @@
import { When, Then } from "cypress-cucumber-preprocessor/steps";
When("I search for {string}", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value);
});
@ -25,7 +25,7 @@ Then("the search should contain the annoying user", () => {
expect($li).to.have.length(1);
})
cy.get(".ds-select-dropdown .user-teaser .slug").should("contain", '@spammy-spammer');
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type("{esc}");
})
@ -44,21 +44,21 @@ Then("I should see the following users in the select dropdown:", table => {
});
When("I type {string} and press Enter", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value)
.type("{enter}", { force: true });
});
When("I type {string} and press escape", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value)
.type("{esc}");
});
Then("the search field should clear", () => {
cy.get(".searchable-input .ds-select-search").should("have.text", "");
cy.get(".searchable-input .ds-select input").should("have.text", "");
});
When("I select a post entry", () => {

View File

@ -80,7 +80,7 @@ Then('I should be on the {string} page', page => {
.should(loc => {
expect(loc.pathname).to.eq(page)
})
.get('h3')
.get('h2')
.should('contain', 'Social media')
})
@ -112,7 +112,7 @@ Given('I have added a social media link', () => {
})
Then('they should be able to see my social media links', () => {
cy.get('.ds-card-content')
cy.get('.base-card')
.contains('Where else can I find Peter Pan?')
.get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1)

View File

@ -73,7 +73,7 @@ Given("the {string} user searches for {string}", (_, postTitle) => {
})
})
.then(user => cy.login(user))
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(postTitle);
});
@ -167,7 +167,8 @@ When("I fill in my email and password combination and click submit", () => {
});
When(/(?:when )?I refresh the page/, () => {
cy.reload();
cy.visit('/')
.reload();
});
When("I log out through the menu in the top right corner", () => {
@ -238,16 +239,16 @@ Given("we have the following comments in our database:", table => {
});
Given("we have the following posts in our database:", table => {
table.hashes().forEach((attributesOrOptions, i) => {
cy.factory().build("post", {
...attributesOrOptions,
deleted: Boolean(attributesOrOptions.deleted),
disabled: Boolean(attributesOrOptions.disabled),
pinned: Boolean(attributesOrOptions.pinned),
}, {
...attributesOrOptions,
});
})
table.hashes().forEach((attributesOrOptions, i) => {
cy.factory().build("post", {
...attributesOrOptions,
deleted: Boolean(attributesOrOptions.deleted),
disabled: Boolean(attributesOrOptions.disabled),
pinned: Boolean(attributesOrOptions.pinned),
}, {
...attributesOrOptions,
});
})
})
Then("I see a success message:", message => {
@ -295,14 +296,14 @@ Then("I select a category", () => {
});
When("I choose {string} as the language for the post", (languageCode) => {
cy.get('.ds-flex-item > .ds-form-item .ds-select ')
cy.get('.contribution-form .ds-select')
.click().get('.ds-select-option')
.eq(languages.findIndex(l => l.code === languageCode)).click()
})
Then("the post shows up on the landing page at position {int}", index => {
cy.openPage("landing");
const selector = `.post-card:nth-child(${index}) > .ds-card-content`;
const selector = `.post-teaser:nth-child(${index}) > .base-card`;
cy.get(selector).should("contain", lastPost.title);
cy.get(selector).should("contain", lastPost.content);
});
@ -312,16 +313,16 @@ Then("I get redirected to {string}", route => {
});
Then("the post was saved successfully", () => {
cy.get(".ds-card-content > .ds-heading").should("contain", lastPost.title);
cy.get(".base-card > .title").should("contain", lastPost.title);
cy.get(".content").should("contain", lastPost.content);
});
Then(/^I should see only ([0-9]+) posts? on the landing page/, postCount => {
cy.get(".post-card").should("have.length", postCount);
cy.get(".post-teaser").should("have.length", postCount);
});
Then("the first post on the landing page has the title:", title => {
cy.get(".post-card:first").should("contain", title);
cy.get(".post-teaser:first").should("contain", title);
});
Then(
@ -388,7 +389,7 @@ Then("I can login successfully with password {string}", password => {
When("open the notification menu and click on the first item", () => {
cy.get(".notifications-menu").invoke('show').click(); // "invoke('show')" because of the delay for show the menu
cy.get(".notification-mention-post")
cy.get(".notification .link")
.first()
.click({
force: true
@ -424,7 +425,7 @@ When("mention {string} in the text", mention => {
Then("the notification gets marked as read", () => {
cy.get(".notifications-menu-popover .notification")
.first()
.should("have.class", "read");
.should("have.class", "--read");
});
Then("there are no notifications in the top menu", () => {
@ -510,14 +511,14 @@ Given('{string} wrote a post {string}', (_, title) => {
});
Then("the list of posts of this user is empty", () => {
cy.get(".ds-card-content").not(".post-link");
cy.get(".base-card").not(".post-link");
cy.get(".main-container").find(".ds-space.hc-empty");
});
Then("I get removed from his follower collection", () => {
cy.get(".ds-card-content").not(".post-link");
cy.get(".base-card").not(".post-link");
cy.get(".main-container").contains(
".ds-card-content",
".base-card",
"is not followed by anyone"
);
});
@ -581,7 +582,7 @@ Then("I see only one post with the title {string}", title => {
});
Then("they should not see the comment form", () => {
cy.get(".ds-card-footer").children().should('not.have.class', 'comment-form')
cy.get(".base-card").children().should('not.have.class', 'comment-form')
})
Then("they should see a text explaining why commenting is not possible", () => {
@ -600,11 +601,11 @@ Then("I {string} see {string} from the content menu in the user info box", (cond
})
Then('I should not see {string} button', button => {
cy.get('.ds-card-content .action-buttons')
cy.get('.base-card .action-buttons')
.should('have.length', 1)
})
Then('I should see the {string} button', button => {
cy.get('.ds-card-content .action-buttons .base-button')
cy.get('.base-card .action-buttons .base-button')
.should('contain', button)
})

View File

@ -20,7 +20,6 @@ Feature: Notification for a mention
And I select a category
And I choose "en" as the language for the post
And I click on "Save"
When I log out
And I log in as "Matt Rider"
And see 1 unread notifications in the top menu
And open the notification menu and click on the first item

View File

@ -35,7 +35,7 @@ Feature: Upload Teaser Image
And confirm crop
And I should be able to "change" a teaser image
And confirm crop
And the first image should be removed from the preview
And the first image should not be displayed anymore
Scenario: Add image, then delete it
When I click on the big plus icon in the bottom right corner to create post
@ -44,4 +44,4 @@ Feature: Upload Teaser Image
And I add all required fields
And I click on "Save"
Then I get redirected to ".../new-post"
And the "new" post was saved successfully without a teaser image
And the "new" post was saved successfully without a teaser image

View File

@ -55,6 +55,6 @@ Feature: Block a User
Scenario: Blocked users should not see link or button to unblock, only blocking users
Given a user has blocked me
When I visit the profile page of the annoying user
And I "should not" see "Unblock user" from the content menu in the user info box
And I should see the "Follow" button
And I should not see "Unblock user" button
And I should not see "Unblock user" button
And I "should not" see "Unblock user" from the content menu in the user info box

View File

@ -58,14 +58,14 @@ Cypress.Commands.add("login", user => {
});
Cypress.Commands.add("manualLogin", ({ email, password }) => {
cy.visit(`/login`);
cy.get("input[name=email]")
cy.visit(`/login`)
.get("input[name=email]")
.trigger("focus")
.type(email);
cy.get("input[name=password]")
.type(email)
.get("input[name=password]")
.trigger("focus")
.type(password);
cy.get("button[name=submit]")
.type(password)
.get("button[name=submit]")
.as("submitButton")
.click();
});

View File

@ -1,5 +1,6 @@
import Factory, { cleanDatabase } from '../../backend/src/db/factories'
import { getDriver, getNeode } from '../../backend/src/db/neo4j'
import Factory from '../../backend/src/db/factories'
import { getNeode } from '../../backend/src/db/neo4j'
const neo4jConfigs = {
uri: Cypress.env('NEO4J_URI'),
@ -8,7 +9,7 @@ const neo4jConfigs = {
}
const neodeInstance = getNeode(neo4jConfigs)
beforeEach(() => cleanDatabase())
beforeEach(() => cy.then(() => neodeInstance.cypher('MATCH (everything) DETACH DELETE everything;')))
Cypress.Commands.add('neode', () => {
return neodeInstance

View File

@ -23,4 +23,11 @@ import 'cypress-plugin-retries'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { WebSocket } from 'mock-socket'
before(() => {
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, "WebSocket", url => new WebSocket(url))
}
})
})

View File

@ -1,6 +1,6 @@
{
"name": "human-connection",
"version": "0.3.1",
"version": "0.4.0",
"description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh",
"license": "MIT",
@ -18,32 +18,34 @@
"cypress:backend": "cd backend && yarn run dev",
"cypress:webapp": "cd webapp && yarn run dev",
"cypress:setup": "run-p cypress:backend cypress:webapp",
"cypress:run": "cross-env cypress run",
"cypress:open": "cross-env cypress open",
"cypress:run": "cross-env cypress run --browser firefox",
"cypress:open": "cross-env cypress open --browser firefox",
"cucumber:setup": "cd backend && yarn run dev",
"cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit",
"release": "standard-version",
"generate:changelog": "yarn version && auto-changelog"
},
"devDependencies": {
"@babel/core": "^7.8.4",
"@babel/core": "^7.8.6",
"@babel/preset-env": "^7.8.4",
"@babel/register": "^7.8.3",
"@babel/register": "^7.8.6",
"auto-changelog": "^1.16.2",
"bcryptjs": "^2.4.3",
"codecov": "^3.6.5",
"cross-env": "^6.0.3",
"cucumber": "^6.0.5",
"cypress": "^3.8.3",
"cypress": "^4.0.0",
"cypress-cucumber-preprocessor": "^2.0.1",
"cypress-file-upload": "^3.5.3",
"cypress-plugin-retries": "^1.5.2",
"date-fns": "^2.9.0",
"date-fns": "^2.10.0",
"dotenv": "^8.2.0",
"expect": "^25.1.0",
"faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2",
"import": "^0.0.6",
"jsonwebtoken": "^8.5.1",
"mock-socket": "^9.0.3",
"neo4j-driver": "^4.0.1",
"neode": "^0.3.7",
"npm-run-all": "^4.1.5",

View File

@ -9,3 +9,13 @@ button {
font-family: inherit;
font-size: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}

View File

@ -132,6 +132,7 @@ $border-size-x-large: 6px;
$border-radius-x-large: 5px;
$border-radius-large: 4px;
$border-radius-base: 4px;
$border-radius-small: 2px;
$border-radius-rounded: 2em;
$border-radius-circle: 50%;
@ -211,7 +212,8 @@ $letter-spacing-x-small: -0.015em;
* @presenter Opacity
*/
$opacity-soft: 0.65;
$opacity-base: 1;
$opacity-soft: 0.7;
$opacity-disabled: 0.5;
/**
@ -239,6 +241,7 @@ $size-height-large: 50px;
$size-height-xlarge: 60px;
$size-height-footer: 64px;
$size-tappable-square: 44px;
$size-ribbon: 6px;
/**
* @tokens Size Width
@ -264,12 +267,23 @@ $size-avatar-large: 114px;
$size-button-base: 36px;
$size-button-small: 26px;
/**
* @tokens Size Images
* @presenter Spacing
*/
$size-image-max-height: 2000px;
$size-image-cropper-max-height: 600px;
$size-image-cropper-min-height: 400px;
$size-image-uploader-min-height: 200px;
/**
* @tokens Size Icons
* @presenter Spacing
*/
$size-icon-base: 16px;
$size-icon-large: 60px;
/**
* @tokens Shadow
@ -285,6 +299,12 @@ $box-shadow-active: 0 0 6px 1px rgba(20, 100, 160, 0.5);
$box-shadow-inset: inset 0 0 20px 1px rgba(0,0,0,.15);
$box-shadow-small-inset: inset 0 0 0 1px rgba(0,0,0,.05);
/**
* @tokens Effects
*/
$blur-radius: 22px;
/**
* @tokens Animation Duration
*/
@ -316,7 +336,8 @@ $z-index-page-submenu: 2500;
$z-index-page-header: 2000;
$z-index-page-sidebar: 1500;
$z-index-sticky: 100;
$z-index-post-card-link: 5;
$z-index-post-teaser-link: 5;
$z-index-surface: 1;
/**
* @tokens Media Query

View File

@ -13,6 +13,8 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1);
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
@ -141,10 +143,9 @@ hr {
}
}
.ds-card .ds-section {
.base-card > .ds-section {
padding: 0;
margin-left: -$space-base;
margin-right: -$space-base;
margin: -$space-base;
.ds-container {
padding: $space-base;

View File

@ -1,30 +1,18 @@
<template>
<div class="categories-select">
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'xx-small' }">
<div v-for="category in categories" :key="category.id">
<ds-flex-item>
<base-button
:data-test="categoryButtonsId(category.id)"
@click="toggleCategory(category.id)"
:filled="isActive(category.id)"
:disabled="isDisabled(category.id)"
:icon="category.icon"
size="small"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</ds-flex-item>
</div>
</ds-flex>
<p class="small-info">
{{
$t('contribution.categories.infoSelectedNoOfMaxCategories', {
chosen: selectedCount,
max: selectedMax,
})
}}
</p>
</div>
<section class="categories-select">
<base-button
v-for="category in categories"
:key="category.id"
:data-test="categoryButtonsId(category.id)"
@click="toggleCategory(category.id)"
:filled="isActive(category.id)"
:disabled="isDisabled(category.id)"
:icon="category.icon"
size="small"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</section>
</template>
<script>
@ -85,3 +73,15 @@ export default {
},
}
</script>
<style lang="scss">
.categories-select {
display: flex;
flex-wrap: wrap;
> .base-button {
margin-right: $space-xx-small;
margin-bottom: $space-xx-small;
}
}
</style>

View File

@ -1,230 +0,0 @@
<template>
<div v-if="(comment.deleted || comment.disabled) && !isModerator" :class="{ comment: true }">
<ds-card>
<ds-space margin-bottom="base" />
<ds-text style="padding-left: 40px; font-weight: bold;" color="soft">
<base-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
<ds-space margin-bottom="base" />
</ds-card>
</div>
<div v-else :class="{ comment: true, 'disabled-content': comment.deleted || comment.disabled }">
<ds-card :id="anchor" :class="{ 'comment--target': isTarget }">
<ds-space margin-bottom="small" margin-top="small">
<user-teaser :user="author" :date-time="comment.createdAt">
<template v-slot:dateTime>
<ds-text v-if="comment.createdAt !== comment.updatedAt">
({{ $t('comment.edited') }})
</ds-text>
</template>
</user-teaser>
<client-only>
<content-menu
v-show="!openEditCommentMenu"
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
class="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</client-only>
</ds-space>
<div v-if="openEditCommentMenu">
<comment-form
:update="true"
:post="post"
:comment="comment"
@showEditCommentMenu="editCommentMenu"
@updateComment="updateComment"
@collapse="isCollapsed = true"
/>
</div>
<div v-else>
<content-viewer :content="commentContent" class="comment-content" />
<button
v-if="isLongComment"
type="button"
class="collapse-button"
@click="isCollapsed = !isCollapsed"
>
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</button>
</div>
<ds-space margin-bottom="small" />
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click.prevent="reply"
></base-button>
</ds-card>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
import BaseButton from '~/components/_new/generic/BaseButton/BaseButton'
export default {
mixins: [scrollToAnchor],
data() {
const anchor = `commentId-${this.comment.id}`
const isTarget = this.routeHash === `#${anchor}`
return {
anchor,
isTarget,
isCollapsed: !isTarget,
openEditCommentMenu: false,
}
},
components: {
UserTeaser,
ContentMenu,
ContentViewer,
CommentForm,
BaseButton,
},
props: {
routeHash: { type: String, default: () => '' },
post: { type: Object, default: () => ({}) },
comment: { type: Object, default: () => ({}) },
dateTime: { type: [Date, String], default: null },
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
}),
isLongComment() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
},
commentContent() {
if (this.isLongComment && this.isCollapsed) {
return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH)
}
return this.comment.content
},
displaysComment() {
return !this.unavailable || this.isModerator
},
author() {
if (this.deleted) return {}
return this.comment.author || {}
},
menuModalsData() {
return {
delete: {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: this.deleteCommentCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: () => {},
},
},
},
}
},
},
methods: {
reply() {
const message = { slug: this.comment.author.slug, id: this.comment.author.id }
this.$emit('reply', message)
},
checkAnchor(anchor) {
return `#${this.anchor}` === anchor
},
isAuthor(id) {
return this.user.id === id
},
editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu
this.$emit('toggleNewCommentForm', !showMenu)
},
updateComment(comment) {
this.$emit('updateComment', comment)
},
async deleteCommentCallback() {
try {
const {
data: { DeleteComment },
} = await this.$apollo.mutate({
mutation: CommentMutations(this.$i18n).DeleteComment,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment', DeleteComment)
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss" scoped>
.collapse-button {
// TODO: move this to css resets
font-family: inherit;
font-size: inherit;
border: none;
background-color: transparent;
float: right;
padding: 0 16px 16px 16px;
color: $color-primary;
cursor: pointer;
}
.comment-content {
padding-left: 40px;
}
.float-right {
float: right;
}
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight {
0% {
border: 1px solid $color-primary;
}
100% {
border: 1px solid transparent;
}
}
.comment--target {
animation: highlight 4s ease;
}
</style>

View File

@ -1,5 +1,5 @@
import { config, mount } from '@vue/test-utils'
import Comment from './Comment.vue'
import CommentCard from './CommentCard.vue'
import Vuex from 'vuex'
const localVue = global.localVue
@ -8,11 +8,17 @@ localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('Comment.vue', () => {
describe('CommentCard.vue', () => {
let propsData, mocks, stubs, getters, wrapper, Wrapper
beforeEach(() => {
propsData = {}
propsData = {
comment: {
id: 'comment007',
author: { id: 'some-user' },
},
postId: 'post42',
}
mocks = {
$t: jest.fn(),
$toast: {
@ -26,6 +32,7 @@ describe('Comment.vue', () => {
truncate: a => a,
removeHtml: a => a,
},
$route: { hash: '' },
$scrollTo: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue({
@ -55,7 +62,7 @@ describe('Comment.vue', () => {
const store = new Vuex.Store({
getters,
})
return mount(Comment, {
return mount(CommentCard, {
store,
propsData,
mocks,

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import Comment from './Comment'
import CommentCard from './CommentCard'
import helpers from '~/storybook/helpers'
helpers.init()
@ -41,14 +41,14 @@ const comment = {
__typename: 'Comment',
}
storiesOf('Comment', module)
storiesOf('CommentCard', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('Basic comment', () => ({
components: { Comment },
components: { CommentCard },
store: helpers.store,
data: () => ({
comment,
}),
template: `<comment :key="comment.id" :comment="comment" />`,
template: `<comment-card :key="comment.id" :comment="comment" />`,
}))

View File

@ -0,0 +1,216 @@
<template>
<base-card v-if="isUnavailable" class="comment-card">
<p>
<base-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</p>
</base-card>
<base-card v-else :class="commentClass" :id="anchor">
<header class="header">
<user-teaser :user="comment.author" :date-time="comment.createdAt">
<template v-if="wasEdited" #dateTime>
<span>({{ $t('comment.edited') }})</span>
</template>
</user-teaser>
<client-only>
<content-menu
v-show="!editingComment"
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
:is-owner="user.id === comment.author.id"
@editComment="editComment(true)"
/>
</client-only>
</header>
<comment-form
v-if="editingComment"
:update="true"
:postId="postId"
:comment="comment"
@finishEditing="editComment(false)"
@updateComment="updateComment"
@collapse="isCollapsed = true"
/>
<template v-else>
<content-viewer :content="commentContent" class="content" />
<base-button v-if="hasLongContent" size="small" ghost @click="isCollapsed = !isCollapsed">
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</base-button>
</template>
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click="reply"
/>
</base-card>
</template>
<script>
import { mapGetters } from 'vuex'
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
export default {
components: {
UserTeaser,
ContentMenu,
ContentViewer,
CommentForm,
},
mixins: [scrollToAnchor],
data() {
const anchor = `commentId-${this.comment.id}`
const isTarget = this.$route.hash === `#${anchor}`
return {
anchor,
isTarget,
isCollapsed: !isTarget,
editingComment: false,
}
},
props: {
comment: {
type: Object,
required: true,
},
postId: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
}),
hasLongContent() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
},
isUnavailable() {
return (this.comment.deleted || this.comment.disabled) && !this.isModerator
},
wasEdited() {
return this.comment.createdAt !== this.comment.updatedAt
},
commentClass() {
let commentClass = 'comment-card'
if (this.comment.deleted || this.comment.disabled) commentClass += ' disabled-content'
if (this.isTarget) commentClass += ' --target'
return commentClass
},
commentContent() {
if (this.hasLongContent && this.isCollapsed) {
return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH)
}
return this.comment.content
},
menuModalsData() {
return {
delete: {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: this.deleteCommentCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: () => {},
},
},
},
}
},
},
methods: {
checkAnchor(anchor) {
return `#${this.anchor}` === anchor
},
reply() {
const message = { slug: this.comment.author.slug, id: this.comment.author.id }
this.$emit('reply', message)
},
editComment(editing) {
this.editingComment = editing
this.$emit('toggleNewCommentForm', !editing)
},
updateComment(comment) {
this.$emit('updateComment', comment)
},
async deleteCommentCallback() {
try {
const {
data: { DeleteComment },
} = await this.$apollo.mutate({
mutation: CommentMutations(this.$i18n).DeleteComment,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment', DeleteComment)
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss">
.comment-card {
display: flex;
flex-direction: column;
margin-bottom: $space-small;
&.--target {
animation: highlight 4s ease;
}
> .header {
display: flex;
justify-content: space-between;
margin-bottom: $space-small;
}
> .base-button {
align-self: flex-end;
}
}
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight {
0% {
border: $border-size-base solid $color-primary;
}
100% {
border: $border-size-base solid transparent;
}
}
</style>

View File

@ -153,10 +153,10 @@ describe('CommentForm.vue', () => {
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
it('emits `finishEditing` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
expect(wrapper.emitted('finishEditing')).toBeTruthy()
})
})
@ -167,10 +167,10 @@ describe('CommentForm.vue', () => {
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
it('emits `finishEditing` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('[data-test="cancel-button"]').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
expect(wrapper.emitted('finishEditing')).toBeTruthy()
})
})

View File

@ -1,8 +1,7 @@
<template>
<ds-form v-model="form" @submit="handleSubmit" class="comment-form">
<template slot-scope="{ errors }">
<ds-card>
<!-- with client-only the content is not shown -->
<base-card>
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<div class="buttons">
<base-button
@ -17,7 +16,7 @@
{{ $t('post.comment.submit') }}
</base-button>
</div>
</ds-card>
</base-card>
</template>
</ds-form>
</template>
@ -72,7 +71,7 @@ export default {
this.$refs.editor.clear()
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
this.$emit('finishEditing')
},
handleCancel() {
if (!this.update) {
@ -146,10 +145,13 @@ export default {
<style lang="scss">
.comment-form {
.editor {
margin-bottom: $space-small;
}
.buttons {
display: flex;
justify-content: flex-end;
margin: $space-small 0;
> .base-button {
margin-left: $space-x-small;

View File

@ -1,6 +1,6 @@
import { config, mount } from '@vue/test-utils'
import CommentList from './CommentList'
import Comment from '~/components/Comment/Comment'
import CommentCard from '~/components/CommentCard/CommentCard'
import Vuex from 'vuex'
import Vue from 'vue'
@ -20,9 +20,14 @@ describe('CommentList.vue', () => {
beforeEach(() => {
propsData = {
post: {
id: 1,
id: 'post42',
comments: [
{ id: 'comment134', contentExcerpt: 'this is a comment', content: 'this is a comment' },
{
id: 'comment134',
contentExcerpt: 'this is a comment',
content: 'this is a comment',
author: { id: 'some-user' },
},
],
},
}
@ -41,6 +46,7 @@ describe('CommentList.vue', () => {
removeHtml: a => a,
},
$scrollTo: jest.fn(),
$route: { hash: '' },
$apollo: {
queries: {
Post: {
@ -73,12 +79,6 @@ describe('CommentList.vue', () => {
beforeEach(jest.useFakeTimers)
describe('$route.hash !== `#comments`', () => {
beforeEach(() => {
mocks.$route = {
hash: '',
}
})
it('skips $scrollTo', () => {
wrapper = Wrapper()
jest.runAllTimers()
@ -107,7 +107,7 @@ describe('CommentList.vue', () => {
})
it('Comment emitted reply()', () => {
wrapper.find(Comment).vm.$emit('reply', {
wrapper.find(CommentCard).vm.$emit('reply', {
id: 'commentAuthorId',
slug: 'ogerly',
})

View File

@ -1,18 +1,15 @@
<template>
<div id="comments" class="comment-list">
<h3 class="title">
<counter-icon icon="comments" :count="post.comments.length" />
<counter-icon icon="comments" :count="postComments.length" />
{{ $t('common.comment', null, 0) }}
</h3>
<ds-space margin-bottom="large" />
<div v-if="post.comments && post.comments.length" id="comments" class="comments">
<comment
v-for="comment in post.comments"
<div v-if="postComments" id="comments" class="comments">
<comment-card
v-for="comment in postComments"
:key="comment.id"
:comment="comment"
:post="post"
:routeHash="routeHash"
class="comment-tag"
:postId="post.id"
@deleteComment="updateCommentList"
@updateComment="updateCommentList"
@toggleNewCommentForm="toggleNewCommentForm"
@ -23,18 +20,25 @@
</template>
<script>
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Comment from '~/components/Comment/Comment'
import CommentCard from '~/components/CommentCard/CommentCard'
import scrollToAnchor from '~/mixins/scrollToAnchor'
export default {
mixins: [scrollToAnchor],
components: {
CounterIcon,
Comment,
CommentCard,
},
props: {
routeHash: { type: String, default: () => '' },
post: { type: Object, default: () => {} },
post: {
type: Object,
required: true,
},
},
computed: {
postComments() {
return (this.post && this.post.comments) || []
},
},
methods: {
reply(message) {
@ -44,7 +48,7 @@ export default {
return anchor === '#comments'
},
updateCommentList(updatedComment) {
this.post.comments = this.post.comments.map(comment => {
this.postComments = this.postComments.map(comment => {
return comment.id === updatedComment.id ? updatedComment : comment
})
},

View File

@ -154,7 +154,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'comment.menu.edit')
.at(0)
.trigger('click')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[true]])
expect(wrapper.emitted('editComment')).toBeTruthy()
})
it('delete the comment', () => {
wrapper

View File

@ -8,7 +8,7 @@
size="small"
circle
ghost
@click="toggleMenu"
@click.prevent="toggleMenu()"
/>
</slot>
</template>
@ -104,7 +104,7 @@ export default {
routes.push({
label: this.$t(`comment.menu.edit`),
callback: () => {
this.$emit('showEditCommentMenu', true)
this.$emit('editComment')
},
icon: 'edit',
})

View File

@ -6,7 +6,7 @@ import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import TeaserImage from '~/components/TeaserImage/TeaserImage'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
@ -182,7 +182,7 @@ describe('ContributionForm.vue', () => {
})
it('has no more than three categories', async () => {
wrapper.vm.form.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
await Vue.nextTick()
wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
@ -233,10 +233,13 @@ describe('ContributionForm.vue', () => {
})
it('supports adding a teaser image', async () => {
const spy = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {})
expectedParams.variables.imageUpload = imageUpload
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
wrapper.find(ImageUploader).vm.$emit('addHeroImage', imageUpload)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
expect(spy).toHaveBeenCalledWith(imageUpload)
spy.mockReset()
})
it('content is valid with just a link', async () => {
@ -320,20 +323,12 @@ describe('ContributionForm.vue', () => {
wrapper = Wrapper()
})
it('sets id equal to contribution id', () => {
expect(wrapper.vm.id).toEqual(propsData.contribution.id)
})
it('sets slug equal to contribution slug', () => {
expect(wrapper.vm.slug).toEqual(propsData.contribution.slug)
})
it('sets title equal to contribution title', () => {
expect(wrapper.vm.form.title).toEqual(propsData.contribution.title)
expect(wrapper.vm.formData.title).toEqual(propsData.contribution.title)
})
it('sets content equal to contribution content', () => {
expect(wrapper.vm.form.content).toEqual(propsData.contribution.content)
expect(wrapper.vm.formData.content).toEqual(propsData.contribution.content)
})
describe('valid update', () => {
@ -362,6 +357,7 @@ describe('ContributionForm.vue', () => {
image,
imageUpload: null,
imageAspectRatio: 1,
imageBlurred: false,
},
}
})
@ -387,9 +383,10 @@ describe('ContributionForm.vue', () => {
it('supports deleting a teaser image', async () => {
expectedParams.variables.image = null
expectedParams.variables.imageAspectRatio = null
propsData.contribution.image = '/uploads/someimage.png'
wrapper = Wrapper()
wrapper.find('.contribution-form .delete-image').trigger('click')
wrapper.find('[data-test="delete-button"]').trigger('click')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})

View File

@ -2,115 +2,75 @@
<ds-form
class="contribution-form"
ref="contributionForm"
v-model="form"
v-model="formData"
:schema="formSchema"
@submit="submit"
>
<template slot-scope="{ errors }">
<base-button
v-if="showDeleteImageButton"
class="delete-image"
icon="close"
size="small"
circle
danger
filled
@click.prevent="deleteImage"
/>
<hc-teaser-image
:contribution="contribution"
:class="{ '--blur-image': form.blurImage }"
@addTeaserImage="addTeaserImage"
@addImageAspectRatio="addImageAspectRatio"
@cropInProgress="cropInProgress"
>
<img
v-if="contribution"
class="contribution-image"
:src="contribution.image | proxyApiUrl"
/>
</hc-teaser-image>
<ds-card>
<div class="blur-toggle">
<base-card>
<template #heroImage>
<img
v-if="formData.image"
:src="formData.image | proxyApiUrl"
:class="['image', formData.imageBlurred && '--blur-image']"
/>
<image-uploader
:hasImage="!!formData.image"
:class="[formData.imageBlurred && '--blur-image']"
@addHeroImage="addHeroImage"
@addImageAspectRatio="addImageAspectRatio"
/>
</template>
<div v-if="formData.image" class="blur-toggle">
<label for="blur-img">{{ $t('contribution.inappropriatePicture') }}</label>
<input type="checkbox" id="blur-img" v-model="form.blurImage" />
<p>
<a
href="https://support.human-connection.org/kb/faq.php?id=113"
target="_blank"
class="link"
>
{{ $t('contribution.inappropriatePictureText') }}
<ds-icon name="question-circle" />
</a>
</p>
<input type="checkbox" id="blur-img" v-model="formData.imageBlurred" />
<a
href="https://support.human-connection.org/kb/faq.php?id=113"
target="_blank"
class="link"
>
{{ $t('contribution.inappropriatePictureText') }}
<base-icon name="question-circle" />
</a>
</div>
<ds-space />
<client-only>
<user-teaser :user="currentUser" />
</client-only>
<ds-space />
<ds-input
model="title"
class="post-title"
:placeholder="$t('contribution.title')"
name="title"
autofocus
size="large"
/>
<ds-text align="right">
<ds-chip v-if="errors && errors.title" color="danger" size="base">
{{ form.title.length }}/{{ formSchema.title.max }}
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">{{ form.title.length }}/{{ formSchema.title.max }}</ds-chip>
</ds-text>
<ds-chip size="base" :color="errors && errors.title && 'danger'">
{{ formData.title.length }}/{{ formSchema.title.max }}
<base-icon v-if="errors && errors.title" name="warning" />
</ds-chip>
<hc-editor
:users="users"
:value="form.content"
:value="formData.content"
:hashtags="hashtags"
@input="updateEditorContent"
/>
<ds-text align="right">
<ds-chip v-if="errors && errors.content" color="danger" size="base">
{{ contentLength }}
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">
{{ contentLength }}
</ds-chip>
</ds-text>
<ds-space margin-bottom="small" />
<hc-categories-select model="categoryIds" :existingCategoryIds="form.categoryIds" />
<ds-text align="right">
<ds-chip v-if="errors && errors.categoryIds" color="danger" size="base">
{{ form.categoryIds.length }} / 3
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">{{ form.categoryIds.length }} / 3</ds-chip>
</ds-text>
<ds-flex class="contribution-form-footer">
<ds-flex-item :width="{ lg: '50%', md: '50%', sm: '100%' }" />
<ds-flex-item>
<ds-space margin-bottom="small" />
<ds-select
model="language"
:options="languageOptions"
icon="globe"
:placeholder="$t('contribution.languageSelectText')"
:label="$t('contribution.languageSelectLabel')"
/>
</ds-flex-item>
</ds-flex>
<ds-text align="right">
<ds-chip v-if="errors && errors.language" size="base" color="danger">
<ds-icon name="warning"></ds-icon>
</ds-chip>
</ds-text>
<ds-space />
<div slot="footer" style="text-align: right">
<ds-chip size="base" :color="errors && errors.content && 'danger'">
{{ contentLength }}
<base-icon v-if="errors && errors.content" name="warning" />
</ds-chip>
<categories-select model="categoryIds" :existingCategoryIds="formData.categoryIds" />
<ds-chip size="base" :color="errors && errors.categoryIds && 'danger'">
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<ds-select
model="language"
icon="globe"
class="select-field"
:options="languageOptions"
:placeholder="$t('contribution.languageSelectText')"
:label="$t('contribution.languageSelectLabel')"
/>
<ds-chip v-if="errors && errors.language" size="base" color="danger">
<base-icon name="warning" />
</ds-chip>
<div class="buttons">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
{{ $t('actions.cancel') }}
</base-button>
@ -118,8 +78,7 @@
{{ $t('actions.save') }}
</base-button>
</div>
<ds-space margin-bottom="large" />
</ds-card>
</base-card>
</template>
</ds-form>
</template>
@ -131,127 +90,95 @@ 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 UserTeaser from '~/components/UserTeaser/UserTeaser'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
export default {
components: {
HcEditor,
HcCategoriesSelect,
HcTeaserImage,
UserTeaser,
CategoriesSelect,
ImageUploader,
},
props: {
contribution: { type: Object, default: () => {} },
contribution: {
type: Object,
default: () => ({}),
},
},
data() {
const {
title,
content,
image,
imageAspectRatio,
imageBlurred,
language,
categories,
} = this.contribution
const languageOptions = orderBy(locales, 'name').map(locale => {
return { label: locale.name, value: locale.code }
})
const formDefaults = {
title: '',
content: '',
teaserImage: null,
imageAspectRatio: null,
image: null,
language: null,
categoryIds: [],
blurImage: false,
}
let id = null
let slug = null
const form = { ...formDefaults }
if (this.contribution && this.contribution.id) {
id = this.contribution.id
slug = this.contribution.slug
form.title = this.contribution.title
form.content = this.contribution.content
form.image = this.contribution.image
form.language =
this.contribution && this.contribution.language
? languageOptions.find(o => this.contribution.language === o.value)
: null
form.categoryIds = this.categoryIds(this.contribution.categories)
form.imageAspectRatio = this.contribution.imageAspectRatio
form.blurImage = this.contribution.imageBlurred
}
return {
form,
formData: {
title: title || '',
content: content || '',
image: image || null,
imageAspectRatio: imageAspectRatio || null,
imageBlurred: imageBlurred || false,
language: languageOptions.find(option => option.value === language) || null,
categoryIds: categories ? categories.map(category => category.id) : [],
},
formSchema: {
title: { required: true, min: 3, max: 100 },
content: { required: true },
categoryIds: {
type: 'array',
required: true,
validator: (rule, value) => {
const errors = []
if (!(value && value.length >= 1 && value.length <= 3)) {
errors.push(new Error(this.$t('common.validations.categories')))
validator: (_, value = []) => {
if (value.length === 0 || value.length > 3) {
return [new Error(this.$t('common.validations.categories'))]
}
return errors
return []
},
},
language: { required: true },
blurImage: { required: false },
imageBlurred: { required: false },
},
languageOptions,
id,
slug,
loading: false,
users: [],
contentMin: 3,
hashtags: [],
elem: null,
isCropInProgress: null,
imageUpload: null,
}
},
computed: {
contentLength() {
return this.$filters.removeHtml(this.form.content).length
},
...mapGetters({
currentUser: 'auth/user',
}),
showDeleteImageButton() {
return this.contribution && this.contribution.image && !this.isCropInProgress
contentLength() {
return this.$filters.removeHtml(this.formData.content).length
},
},
methods: {
submit() {
const {
language: { value: language },
title,
content,
image,
teaserImage,
imageAspectRatio,
categoryIds,
blurImage,
} = this.form
this.loading = true
this.$apollo
.mutate({
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: {
id: this.id,
title,
content,
categoryIds,
language,
image,
imageUpload: teaserImage,
imageBlurred: blurImage,
imageAspectRatio,
...this.formData,
id: this.contribution.id || null,
language: this.formData.language.value,
image: this.imageUpload ? null : this.formData.image,
imageUpload: this.imageUpload,
},
})
.then(({ data }) => {
this.loading = false
this.$toast.success(this.$t('contribution.success'))
const result = data[this.id ? 'UpdatePost' : 'CreatePost']
const result = data[this.contribution.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
name: 'post-id-slug',
@ -266,22 +193,19 @@ export default {
updateEditorContent(value) {
this.$refs.contributionForm.update('content', value)
},
addTeaserImage(file) {
this.form.teaserImage = file
addHeroImage(file) {
this.formData.image = null
if (file) {
const reader = new FileReader()
reader.onload = ({ target }) => {
this.formData.image = target.result
}
this.imageUpload = file
reader.readAsDataURL(file)
}
},
addImageAspectRatio(aspectRatio) {
this.form.imageAspectRatio = aspectRatio
},
categoryIds(categories) {
return categories.map(c => c.id)
},
deleteImage() {
this.contribution.image = null
this.form.image = null
this.form.teaserImage = null
},
cropInProgress(boolean) {
this.isCropInProgress = boolean
this.formData.imageAspectRatio = aspectRatio
},
},
apollo: {
@ -319,41 +243,48 @@ export default {
</script>
<style lang="scss">
.contribution-form {
.ds-card-image.--blur-image img {
filter: blur(32px);
.contribution-form > .base-card {
display: flex;
flex-direction: column;
> .hero-image {
position: relative;
> .image {
max-height: $size-image-max-height;
}
}
.image.--blur-image {
filter: blur($blur-radius);
}
> .ds-form-item {
margin: 0;
}
> .ds-chip {
align-self: flex-end;
margin: $space-xx-small 0 $space-base;
cursor: default;
}
> .select-field {
align-self: flex-end;
}
> .buttons {
align-self: flex-end;
margin-top: $space-base;
}
.blur-toggle {
text-align: right;
margin-bottom: $space-base;
> .link {
display: block;
}
}
.ds-chip {
cursor: default;
}
.post-title {
margin-top: $space-x-small;
margin-bottom: $space-xx-small;
input {
border: 0;
font-size: $font-size-x-large;
font-weight: bold;
padding-left: 0;
padding-right: 0;
}
}
}
.delete-image {
right: 10px;
position: relative;
z-index: 1;
float: right;
top: $space-large;
}
</style>

View File

@ -88,7 +88,7 @@ describe('DeleteData.vue', () => {
describe('calls the delete user mutation', () => {
beforeEach(() => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput = wrapper.find('.ds-input')
enableDeletionInput.setValue(deleteAccountName)
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')
})
@ -107,7 +107,7 @@ describe('DeleteData.vue', () => {
it("deletes a user's posts if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(0)
enableContributionDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -122,7 +122,7 @@ describe('DeleteData.vue', () => {
it("deletes a user's comments if requested", () => {
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -137,10 +137,10 @@ describe('DeleteData.vue', () => {
it("deletes a user's posts and comments if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(0)
enableContributionDeletionCheckbox.trigger('click')
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -166,7 +166,7 @@ describe('DeleteData.vue', () => {
describe('error handling', () => {
it('shows an error toaster when the mutation rejects', async () => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput = wrapper.find('.ds-input')
enableDeletionInput.setValue(deleteAccountName)
await Vue.nextTick()
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')

View File

@ -1,80 +1,46 @@
<template>
<div>
<ds-card hover>
<ds-space />
<ds-container>
<ds-flex>
<ds-flex-item :width="{ base: '22%', sm: '12%', md: '12%', lg: '8%' }">
<base-icon name="warning" class="delete-warning-icon" />
</ds-flex-item>
<ds-flex-item :width="{ base: '78%', sm: '88%', md: '88%', lg: '92%' }">
<ds-heading>{{ $t('settings.deleteUserAccount.name') }}</ds-heading>
</ds-flex-item>
<ds-space />
<ds-heading tag="h4">
{{ $t('settings.deleteUserAccount.accountDescription') }}
</ds-heading>
</ds-flex>
</ds-container>
<ds-space />
<ds-container>
<transition name="slide-up">
<div v-if="deleteEnabled">
<label v-if="currentUser.contributionsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteContributions" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<label v-if="currentUser.commentedCount" class="checkbox-container">
<input type="checkbox" v-model="deleteComments" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.commentedCount', {
count: currentUser.commentedCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<ds-section id="delete-user-account-warning">
<div v-html="$t('settings.deleteUserAccount.accountWarning')"></div>
</ds-section>
</div>
</transition>
</ds-container>
<template slot="footer" class="delete-data-footer">
<ds-container>
<div
class="delete-input-label"
v-html="$t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name })"
></div>
<ds-space margin-bottom="xx-small" />
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'large' }">
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1.75 }">
<ds-input v-model="enableDeletionValue" class="enable-deletion-input" />
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1 }">
<base-button
icon="trash"
danger
filled
:disabled="!deleteEnabled"
data-test="delete-button"
@click="handleSubmit"
>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</ds-flex-item>
</ds-flex>
</ds-container>
</template>
</ds-card>
</div>
<base-card class="delete-data">
<h2 class="title">
<base-icon name="warning" />
{{ $t('settings.deleteUserAccount.name') }}
</h2>
<label>
{{ $t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name }) }}
</label>
<ds-input v-model="enableDeletionValue" />
<p class="notice">{{ $t('settings.deleteUserAccount.accountDescription') }}</p>
<label v-if="currentUser.contributionsCount" class="checkbox">
<input type="checkbox" v-model="deleteContributions" />
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<label v-if="currentUser.commentedCount" class="checkbox">
<input type="checkbox" v-model="deleteComments" />
{{
$t('settings.deleteUserAccount.commentedCount', {
count: currentUser.commentedCount,
})
}}
</label>
<section v-if="deleteEnabled" class="warning">
<p>{{ $t('settings.deleteUserAccount.accountWarning') }}</p>
</section>
<base-button
icon="trash"
danger
filled
:disabled="!deleteEnabled"
data-test="delete-button"
@click="handleSubmit"
>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</base-card>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import gql from 'graphql-tag'
@ -131,96 +97,47 @@ export default {
},
}
</script>
<style lang="scss">
.delete-warning-icon {
color: $color-danger;
font-size: $font-size-xxx-large;
}
.delete-data {
display: flex;
flex-direction: column;
.checkbox-container {
display: block;
position: relative;
padding-left: 35px;
cursor: pointer;
font-size: $font-size-large;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
> .title > .base-icon {
color: $color-danger;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
> .ds-form-item {
align-self: flex-start;
margin-top: $space-xxx-small;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
border: 2px solid $background-color-inverse-softer;
background-color: $background-color-base;
border-radius: $border-radius-x-large;
}
> .notice {
font-weight: $font-weight-bold;
margin-bottom: $space-small;
}
.checkbox-container:hover input ~ .checkmark {
background-color: $background-color-softest;
}
> .checkbox {
margin-left: $space-base;
margin-bottom: $space-x-small;
.checkbox-container input:checked ~ .checkmark {
background-color: $background-color-danger-active;
}
&:last-of-type {
margin-bottom: $space-small;
}
}
.checkmark:after {
content: '';
position: absolute;
display: none;
}
> .warning {
padding: $space-large;
margin-bottom: $space-small;
border-radius: $border-radius-base;
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
color: $color-danger;
background-color: $color-danger-inverse;
border-left: 4px solid $color-danger;
}
.checkbox-container .checkmark:after {
left: 6px;
top: 3px;
width: 5px;
height: 10px;
border: solid $background-color-base;
border-width: 0 $border-size-large $border-size-large 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.enable-deletion-input input:focus {
border-color: $border-color-danger;
}
.delete-input-label {
font-size: $font-size-base;
}
b.is-danger {
color: $text-color-danger;
}
.delete-data-footer {
border-top: $border-size-base solid $border-color-softest;
background-color: $background-color-danger-inverse;
}
#delete-user-account-warning {
background-color: $background-color-danger-inverse;
border-left: $border-size-x-large solid $background-color-danger-active;
color: $text-color-danger;
margin-left: 0px;
margin-right: 0px;
border-radius: $border-radius-x-large;
> .base-button {
align-self: flex-start;
}
}
</style>

View File

@ -40,9 +40,9 @@ storiesOf('Editor', module)
return {
components: { ctx },
template: `
<ds-card style="width: 50%; min-width: 500px; margin: 0 auto;">
<base-card style="width: 50%; min-width: 500px; margin: 0 auto;">
<ctx />
</ds-card>
</base-card>
`,
}
})

View File

@ -89,7 +89,7 @@ export default {
}
&.hint {
opacity: 0.7;
opacity: $opacity-soft;
pointer-events: none;
}
}

View File

@ -12,10 +12,10 @@ describe('FilterMenu.vue', () => {
mocks = { $t: () => {} }
})
describe('given a user', () => {
describe('given a hashtag', () => {
beforeEach(() => {
propsData = {
hashtag: null,
hashtag: 'Frieden',
}
})
@ -27,19 +27,14 @@ describe('FilterMenu.vue', () => {
wrapper = Wrapper()
})
it('does not render a card if there are no hashtags', () => {
expect(wrapper.is('.ds-card')).toBe(true)
})
it('renders a card if there are hashtags', () => {
propsData.hashtag = 'Frieden'
it('renders a card', () => {
wrapper = Wrapper()
expect(wrapper.is('.ds-card')).toBe(true)
expect(wrapper.is('.base-card')).toBe(true)
})
describe('click "clear-search-button" button', () => {
describe('click clear search button', () => {
it('emits clearSearch', () => {
wrapper.find('[name="clear-search-button"]').trigger('click')
wrapper.find('.base-button').trigger('click')
expect(wrapper.emitted().clearSearch).toHaveLength(1)
})
})

View File

@ -1,32 +1,22 @@
<template>
<ds-card class="filter-menu-card">
<ds-flex class="filter-menu-content">
<ds-flex-item>
<ds-heading size="h3">{{ $t('filter-menu.hashtag-search', { hashtag }) }}</ds-heading>
</ds-flex-item>
<ds-flex-item>
<div class="filter-menu-buttons">
<base-button
name="clear-search-button"
icon="close"
circle
@click="clearSearch"
v-tooltip="{
content: this.$t('filter-menu.clearSearch'),
placement: 'left',
delay: { show: 500 },
}"
/>
</div>
</ds-flex-item>
</ds-flex>
</ds-card>
<base-card class="filter-menu">
<h2>{{ $t('filter-menu.hashtag-search', { hashtag }) }}</h2>
<base-button
icon="close"
circle
:title="this.$t('filter-menu.clearSearch')"
@click="clearSearch"
/>
</base-card>
</template>
<script>
export default {
props: {
hashtag: { type: String, default: null },
hashtag: {
type: String,
required: true,
},
},
methods: {
clearSearch() {
@ -37,21 +27,10 @@ export default {
</script>
<style lang="scss">
.filter-menu-card {
background-color: $background-color-soft;
}
.filter-menu-content {
height: 100%;
align-items: center;
}
.filter-menu-title {
.filter-menu.base-card {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-menu-buttons {
float: right;
padding: $space-x-small $space-base;
}
</style>

View File

@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import TeaserImage from './TeaserImage.vue'
import ImageUploader from './ImageUploader.vue'
const localVue = global.localVue
describe('TeaserImage.vue', () => {
describe('ImageUploader.vue', () => {
let wrapper
let mocks
@ -17,7 +17,7 @@ describe('TeaserImage.vue', () => {
})
describe('mount', () => {
const Wrapper = () => {
return mount(TeaserImage, { mocks, localVue })
return mount(ImageUploader, { mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
@ -28,21 +28,10 @@ describe('TeaserImage.vue', () => {
const message = 'File upload failed'
const fileError = { status: 'error' }
it('defaults to error false', () => {
expect(wrapper.vm.error).toEqual(false)
})
it('shows an error toaster when verror is called', () => {
wrapper.vm.verror(fileError, message)
wrapper.vm.onDropzoneError(fileError, message)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
})
it('changes error status from false to true to false', () => {
wrapper.vm.verror(fileError, message)
expect(wrapper.vm.error).toEqual(true)
jest.runAllTimers()
expect(wrapper.vm.error).toEqual(false)
})
})
})
})

View File

@ -0,0 +1,200 @@
<template>
<div class="image-uploader">
<vue-dropzone
v-show="!showCropper"
id="postdropzone"
:options="dropzoneOptions"
:use-custom-slot="true"
@vdropzone-error="onDropzoneError"
@vdropzone-file-added="initCropper"
>
<loading-spinner v-if="isLoadingImage" />
<base-icon v-else name="image" />
<base-button
v-if="hasImage"
icon="trash"
circle
danger
filled
data-test="delete-button"
:title="$t('actions.delete')"
@click.stop="deleteImage"
/>
</vue-dropzone>
<div v-show="showCropper" class="crop-overlay">
<img id="cropping-image" />
<base-button class="crop-confirm" filled @click="cropImage">
{{ $t('contribution.teaserImage.cropperConfirm') }}
</base-button>
<base-button
class="crop-cancel"
icon="close"
size="small"
circle
danger
filled
@click="closeCropper"
/>
</div>
</div>
</template>
<script>
import VueDropzone from 'nuxt-dropzone'
import Cropper from 'cropperjs'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
import 'cropperjs/dist/cropper.css'
export default {
components: {
LoadingSpinner,
VueDropzone,
},
props: {
hasImage: {
type: Boolean,
default: false,
},
},
data() {
return {
dropzoneOptions: {
url: () => '',
maxFilesize: 5.0,
previewTemplate: '<span class="no-preview" />',
},
cropper: null,
file: null,
showCropper: false,
isLoadingImage: false,
}
},
methods: {
onDropzoneError(file, message) {
this.$toast.error(file.status, message)
},
initCropper(file) {
this.showCropper = true
this.file = file
const imageElement = document.querySelector('#cropping-image')
imageElement.src = URL.createObjectURL(file)
this.cropper = new Cropper(imageElement, { zoomable: false, autoCropArea: 0.9 })
},
cropImage() {
this.isLoadingImage = true
const onCropComplete = (aspectRatio, imageFile) => {
this.$emit('addImageAspectRatio', aspectRatio)
this.$emit('addHeroImage', imageFile)
this.$nextTick((this.isLoadingImage = false))
this.closeCropper()
}
if (this.file.type === 'image/jpeg') {
const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob(blob => {
const imageAspectRatio = canvas.width / canvas.height
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
onCropComplete(imageAspectRatio, croppedImageFile)
}, 'image/jpeg')
} else {
// TODO: use cropped file instead of original file
const imageAspectRatio = this.file.width / this.file.height || 1.0
onCropComplete(imageAspectRatio, this.file)
}
},
closeCropper() {
this.showCropper = false
this.cropper.destroy()
},
deleteImage() {
this.$emit('addHeroImage', null)
this.$emit('addImageAspectRatio', null)
},
},
}
</script>
<style lang="scss">
.image-uploader {
position: relative;
min-height: $size-image-uploader-min-height;
cursor: pointer;
.image + & {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&:only-child {
background-color: $color-neutral-85;
}
&:disabled {
pointer-events: none;
}
> .crop-overlay {
width: 100%;
height: 100%;
min-height: $size-image-cropper-min-height;
max-height: $size-image-cropper-max-height;
font-size: $font-size-base;
> .img {
display: block;
max-width: 100%;
}
> .crop-confirm {
position: absolute;
left: $space-x-small;
top: $space-x-small;
z-index: $z-index-surface;
}
> .crop-cancel {
position: absolute;
right: $space-x-small;
top: $space-x-small;
z-index: $z-index-surface;
}
}
.dz-message {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
z-index: $z-index-surface;
&:hover {
> .base-icon {
opacity: $opacity-base;
}
}
> .base-icon {
position: absolute;
padding: $space-small;
border-radius: 100%;
border: $border-size-base dashed $color-neutral-20;
background-color: $color-neutral-95;
font-size: $size-icon-large;
opacity: $opacity-soft;
}
> .base-button {
position: absolute;
top: $space-small;
right: $space-small;
z-index: $z-index-surface;
}
}
}
</style>

View File

@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils'
import { config, mount } from '@vue/test-utils'
import LocaleSwitch from './LocaleSwitch.vue'
import Vuex from 'vuex'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
describe('LocaleSwitch.vue', () => {
let wrapper, mocks, computed, deutschLanguageItem, getters

View File

@ -1,35 +1,37 @@
<template>
<dropdown ref="menu" :placement="placement" :offset="offset">
<a
slot="default"
slot-scope="{ toggleMenu }"
class="locale-menu"
href="#"
@click.prevent="toggleMenu()"
>
<base-icon name="globe" />
<span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="locale-menu-popover"
:matcher="matcher"
:routes="routes"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="locale-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
<client-only>
<dropdown ref="menu" :placement="placement" :offset="offset">
<a
slot="default"
slot-scope="{ toggleMenu }"
class="locale-menu"
href="#"
@click.prevent="toggleMenu()"
>
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</dropdown>
<base-icon name="globe" />
<span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="locale-menu-popover"
:matcher="matcher"
:routes="routes"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="locale-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
>
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</dropdown>
</client-only>
</template>
<script>

View File

@ -1,71 +1,54 @@
<template>
<ds-container width="medium">
<ds-space margin="small">
<blockquote>
<p>{{ $t('quotes.african.quote') }}</p>
<b>- {{ $t('quotes.african.author') }}</b>
</blockquote>
</ds-space>
<ds-card class="login-card">
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<client-only>
<locale-switch class="login-locale-switch" offset="5" />
</client-only>
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
<img
class="login-image"
alt="Human Connection"
src="/img/sign-up/humanconnection.svg"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<ds-space margin="small">
<a :href="$t('login.moreInfoURL')" :title="$t('login.moreInfoHint')" target="_blank">
{{ $t('login.moreInfo') }}
</a>
</ds-space>
<ds-space margin="small">
<ds-text size="small">{{ $t('login.copy') }}</ds-text>
</ds-space>
<form :disabled="pending" @submit.prevent="onSubmit">
<ds-input
v-model="form.email"
:disabled="pending"
:placeholder="$t('login.email')"
type="email"
name="email"
icon="envelope"
/>
<ds-input
v-model="form.password"
:disabled="pending"
:placeholder="$t('login.password')"
icon="lock"
icon-right="question-circle"
name="password"
type="password"
/>
<ds-space margin-bottom="large">
<nuxt-link to="/password-reset/request">{{ $t('login.forgotPassword') }}</nuxt-link>
</ds-space>
<base-button :loading="pending" filled name="submit" type="submit" icon="sign-in">
{{ $t('login.login') }}
</base-button>
<ds-space margin-top="large" margin-bottom="x-small">
{{ $t('login.no-account') }}
<nuxt-link to="/registration/signup">{{ $t('login.register') }}</nuxt-link>
</ds-space>
</form>
</ds-flex-item>
</ds-flex>
</ds-card>
</ds-container>
<section class="login-form">
<blockquote>
<p>{{ $t('quotes.african.quote') }}</p>
<b>- {{ $t('quotes.african.author') }}</b>
</blockquote>
<base-card>
<template #imageColumn>
<a :href="$t('login.moreInfoURL')" :title="$t('login.moreInfo')" target="_blank">
<img class="image" alt="Human Connection" src="/img/sign-up/humanconnection.svg" />
</a>
</template>
<h2 class="title">{{ $t('login.login') }}</h2>
<form :disabled="pending" @submit.prevent="onSubmit">
<ds-input
v-model="form.email"
:disabled="pending"
:placeholder="$t('login.email')"
type="email"
name="email"
icon="envelope"
/>
<ds-input
v-model="form.password"
:disabled="pending"
:placeholder="$t('login.password')"
icon="lock"
icon-right="question-circle"
name="password"
type="password"
/>
<nuxt-link to="/password-reset/request">
{{ $t('login.forgotPassword') }}
</nuxt-link>
<base-button :loading="pending" filled name="submit" type="submit" icon="sign-in">
{{ $t('login.login') }}
</base-button>
<p>
{{ $t('login.no-account') }}
<nuxt-link to="/registration/signup">{{ $t('login.register') }}</nuxt-link>
</p>
</form>
<template #topMenu>
<locale-switch offset="5" />
</template>
</base-card>
</section>
</template>
<script>
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch.vue'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
export default {
components: {
@ -100,21 +83,16 @@ export default {
</script>
<style lang="scss">
.login-image {
width: 90%;
max-width: 200px;
}
.login-card {
position: relative;
.login-form {
width: 80vw;
max-width: 620px;
margin: auto;
.base-button {
display: block;
width: 100%;
margin-top: $space-large;
margin-bottom: $space-small;
}
}
.login-locale-switch {
position: absolute;
top: 1em;
left: 1em;
}
</style>

View File

@ -63,7 +63,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.commented_on_post',
)
})
@ -79,9 +79,9 @@ describe('Notification', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -90,8 +90,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
@ -113,7 +113,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_post',
)
})
@ -125,9 +125,9 @@ describe('Notification', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -136,8 +136,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
@ -163,7 +163,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_comment',
)
})
@ -182,9 +182,9 @@ describe('Notification', () => {
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -193,8 +193,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})

View File

@ -1,37 +1,23 @@
<template>
<ds-space :class="{ read: notification.read, notification: true }" margin-bottom="x-small">
<article :class="{ '--read': notification.read, notification: true }">
<client-only>
<ds-space margin-bottom="x-small">
<user-teaser :user="from.author" :date-time="from.createdAt" />
</ds-space>
<ds-text class="reason-text-for-test" color="soft">
{{ $t(`notifications.reason.${notification.reason}`) }}
</ds-text>
<user-teaser :user="from.author" :date-time="from.createdAt" />
</client-only>
<ds-space margin-bottom="x-small" />
<p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p>
<nuxt-link
class="notification-mention-post"
class="link"
:to="{ name: 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')"
>
<ds-space margin-bottom="x-small">
<ds-card
:header="from.title || from.post.title"
hover
space="x-small"
class="notifications-card"
>
<ds-space margin-bottom="x-small" />
<div>
<span v-if="isComment" class="comment-notification-header">
{{ $t(`notifications.comment`) }}:
</span>
{{ from.contentExcerpt | removeHtml }}
</div>
</ds-card>
</ds-space>
<base-card wideContent>
<h2 class="title">{{ from.title || from.post.title }}</h2>
<p>
<strong v-if="isComment" class="comment">{{ $t(`notifications.comment`) }}:</strong>
{{ from.contentExcerpt | removeHtml }}
</p>
</base-card>
</nuxt-link>
</ds-space>
</article>
</template>
<script>
@ -70,14 +56,36 @@ export default {
</script>
<style lang="scss">
.notification.read {
opacity: $opacity-soft;
}
.notifications-card {
min-width: 500px;
}
.comment-notification-header {
font-weight: 700;
margin-right: 0.1rem;
.notification {
margin-bottom: $space-base;
&:first-of-type {
margin-top: $space-x-small;
}
&.--read {
opacity: $opacity-disabled;
}
> .description {
margin-bottom: $space-x-small;
}
> .link {
display: block;
color: $text-color-base;
&:hover {
color: $color-primary;
}
}
.user-teaser {
margin-bottom: $space-x-small;
}
.comment {
font-weight: $font-weight-bold;
}
}
</style>

View File

@ -73,7 +73,7 @@ describe('NotificationList.vue', () => {
describe('click on a notification', () => {
beforeEach(() => {
wrapper.find('.notification-mention-post').trigger('click')
wrapper.find('.notification > .link').trigger('click')
})
it("emits 'markAsRead' with the id of the notification source", () => {

View File

@ -3,7 +3,7 @@ import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import helpers from '~/storybook/helpers'
import { post } from '~/components/PostCard/PostCard.story.js'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
helpers.init()

View File

@ -1,219 +0,0 @@
<template>
<ds-card
:lang="post.language"
:image="post.image | proxyApiUrl"
:class="{
'post-card': true,
'disabled-content': post.disabled,
'--pinned': isPinned,
'--blur-image': post.imageBlurred,
}"
>
<!-- Post Link Target -->
<nuxt-link
class="post-link"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
{{ post.title }}
</nuxt-link>
<ds-space margin-bottom="small" />
<!-- Username, Image & Date of Post -->
<div class="user-wrapper">
<client-only>
<user-teaser :user="post.author" :date-time="post.createdAt" />
</client-only>
<hc-ribbon v-if="isPinned" class="ribbon--pinned" :text="$t('post.pinned')" />
<hc-ribbon v-else :text="$t('post.name')" />
</div>
<ds-space margin-bottom="small" />
<!-- Post Title -->
<ds-heading tag="h3" class="hyphenate-text post-title">{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Post Content Excerpt -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div class="hc-editor-content hyphenate-text" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<!-- Footer o the Post -->
<template slot="footer">
<div style="display: inline-block; opacity: .5;">
<!-- Categories -->
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
placement: 'bottom-start',
delay: { show: 500 },
}"
:icon="category.icon"
/>
</div>
<client-only>
<div style="display: inline-block; float: right">
<!-- Shouts Count -->
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
<base-icon name="bullhorn" />
<small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<!-- Comments Count -->
<span :style="{ opacity: post.commentsCount ? 1 : 0.5 }">
<base-icon name="comments" />
<small>{{ post.commentsCount }}</small>
</span>
<!-- Menu -->
<content-menu
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</div>
</client-only>
</template>
</ds-card>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
// import { randomBytes } from 'crypto'
import { mapGetters } from 'vuex'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'HcPostCard',
components: {
UserTeaser,
HcCategory,
HcRibbon,
ContentMenu,
},
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
mounted() {
const width = this.$el.offsetWidth
const height = Math.min(width / this.post.imageAspectRatio, 2000)
const imageElement = this.$el.querySelector('.ds-card-image')
if (imageElement) {
imageElement.style.height = `${height}px`
}
},
computed: {
...mapGetters({
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
isAuthor() {
const { author } = this.post
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
isPinned() {
return this.post && this.post.pinned
},
},
methods: {
async deletePostCallback() {
try {
const {
data: { DeletePost },
} = await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList', DeletePost)
} catch (err) {
this.$toast.error(err.message)
}
},
pinPost(post) {
this.$emit('pinPost', post)
},
unpinPost(post) {
this.$emit('unpinPost', post)
},
},
}
</script>
<style lang="scss">
.post-card {
justify-content: space-between;
position: relative;
z-index: 1;
cursor: pointer;
&.--pinned {
border: 1px solid $color-warning;
}
&.--blur-image > .ds-card-image img {
filter: blur(22px);
}
> .ds-card-image img {
width: 100%;
max-height: 2000px;
object-fit: contain;
}
> .ds-card-content {
flex-grow: 0;
}
/* workaround to avoid jumping layout when footer is rendered */
> .ds-card-footer {
height: 75px;
}
.post-title {
margin-top: $space-large;
}
/* workaround to avoid jumping layout when user-teaser is rendered */
.user-wrapper {
height: 36px;
position: relative;
z-index: $z-index-post-card-link;
}
.content-menu {
position: relative;
z-index: $z-index-post-card-link;
display: inline-block;
margin-left: $space-xx-small;
margin-right: -$space-x-small;
}
.post-link {
margin: 15px;
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-indent: -999999px;
}
}
</style>

View File

@ -2,14 +2,14 @@ import { config, shallowMount, mount, RouterLinkStub } from '@vue/test-utils'
import Vuex from 'vuex'
import PostCard from './PostCard.vue'
import PostTeaser from './PostTeaser.vue'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {
describe('PostTeaser', () => {
let store
let stubs
let mocks
@ -22,11 +22,13 @@ describe('PostCard', () => {
propsData = {
post: {
id: 'p23',
disabled: false,
shoutedCount: 0,
commentsCount: 0,
name: 'It is a post',
author: {
id: 'u1',
},
disabled: false,
},
}
stubs = {
@ -55,7 +57,7 @@ describe('PostCard', () => {
describe('shallowMount', () => {
Wrapper = () => {
store = new Vuex.Store({ getters })
return shallowMount(PostCard, {
return shallowMount(PostTeaser, {
store,
propsData,
mocks,
@ -63,6 +65,13 @@ describe('PostCard', () => {
})
}
it('has no validation errors', () => {
const spy = jest.spyOn(global.console, 'error')
Wrapper()
expect(spy).not.toBeCalled()
spy.mockReset()
})
beforeEach(jest.useFakeTimers)
describe('test Post callbacks', () => {
@ -99,7 +108,7 @@ describe('PostCard', () => {
const store = new Vuex.Store({
getters,
})
return mount(PostCard, {
return mount(PostTeaser, {
stubs,
mocks,
propsData,
@ -111,6 +120,7 @@ describe('PostCard', () => {
describe('given a post', () => {
beforeEach(() => {
propsData.post = {
...propsData.post,
title: "It's a title",
}
})

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcPostCard from './PostCard.vue'
import PostTeaser from './PostTeaser.vue'
import helpers from '~/storybook/helpers'
helpers.init()
@ -44,24 +44,24 @@ export const post = {
__typename: 'Post',
}
storiesOf('Post Card', module)
storiesOf('PostTeaser', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('without image', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post,
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))
.add('with image', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post: {
@ -70,27 +70,23 @@ storiesOf('Post Card', module)
},
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))
.add('pinned by admin', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post: {
...post,
pinnedBy: {
id: '4711',
name: 'Ad Min',
role: 'admin',
},
pinned: true,
},
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>

View File

@ -0,0 +1,207 @@
<template>
<nuxt-link
class="post-teaser"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
<base-card
:lang="post.language"
:class="{
'disabled-content': post.disabled,
'--blur-image': post.imageBlurred,
}"
:highlight="isPinned"
>
<template v-if="post.image" #heroImage>
<img :src="post.image | proxyApiUrl" class="image" />
</template>
<client-only>
<user-teaser :user="post.author" :date-time="post.createdAt" />
</client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable vue/no-v-html -->
<div class="content hyphenate-text" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<footer class="footer">
<div class="categories">
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
placement: 'bottom-start',
delay: { show: 500 },
}"
:icon="category.icon"
/>
</div>
<counter-icon
icon="bullhorn"
:count="post.shoutedCount"
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
/>
<counter-icon
icon="comments"
:count="post.commentsCount"
:title="$t('contribution.amount-comments', { amount: post.commentsCount })"
/>
<client-only>
<content-menu
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</client-only>
</footer>
</base-card>
<hc-ribbon
:class="{ '--pinned': isPinned }"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</nuxt-link>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { mapGetters } from 'vuex'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'PostTeaser',
components: {
UserTeaser,
HcCategory,
HcRibbon,
ContentMenu,
CounterIcon,
},
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
mounted() {
const width = this.$el.offsetWidth
const height = Math.min(width / this.post.imageAspectRatio, 2000)
const imageElement = this.$el.querySelector('.hero-image')
if (imageElement) {
imageElement.style.height = `${height}px`
}
},
computed: {
...mapGetters({
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
isAuthor() {
const { author } = this.post
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
isPinned() {
return this.post && this.post.pinned
},
},
methods: {
async deletePostCallback() {
try {
const {
data: { DeletePost },
} = await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList', DeletePost)
} catch (err) {
this.$toast.error(err.message)
}
},
pinPost(post) {
this.$emit('pinPost', post)
},
unpinPost(post) {
this.$emit('unpinPost', post)
},
},
}
</script>
<style lang="scss">
.post-teaser,
.post-teaser:hover,
.post-teaser:active {
position: relative;
display: block;
height: 100%;
color: $text-color-base;
> .ribbon {
position: absolute;
top: 50%;
right: -7px;
}
}
.post-teaser > .base-card {
display: flex;
flex-direction: column;
height: 100%;
&.--blur-image > .hero-image > .image {
filter: blur($blur-radius);
}
> .content {
flex-grow: 1;
margin-bottom: $space-small;
}
> .footer {
display: flex;
justify-content: space-between;
align-items: center;
> .categories {
flex-grow: 1;
}
> .counter-icon {
display: block;
margin-right: $space-small;
opacity: $opacity-disabled;
}
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
.ds-tag {
margin: 0;
margin-right: $space-xx-small;
}
}
.user-teaser {
margin-bottom: $space-small;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="ribbon">
<small>{{ text }}</small>
</div>
<aside class="ribbon">
<p>{{ text }}</p>
</aside>
</template>
<script>
@ -18,40 +18,29 @@ export default {
<style scoped lang="scss">
.ribbon {
$card-shadow: 0px 12px 26px -4px rgba(0, 0, 0, 0.1);
//position: absolute;
position: relative;
right: -31px;
top: -31px;
font-size: 0.9em;
font-weight: bold;
padding: 6px 6px;
color: #fff;
padding: $size-ribbon $size-ribbon;
border-radius: $border-radius-small 0 0 $border-radius-small;
color: $color-neutral-100;
background-color: $background-color-secondary-active;
float: right;
border-radius: 2px 0 0 2px;
box-shadow: $card-shadow;
z-index: 11;
// border: 1px solid #ccc;
&:before {
font-size: $font-size-x-small;
font-weight: $font-weight-bold;
&::before {
content: ' ';
position: absolute;
width: 0;
height: 0;
right: 0;
bottom: -6px;
border-width: 3px 4px 3px 3px;
bottom: -$size-ribbon;
border-width: $border-size-large 4px $border-size-large $border-size-large;
border-style: solid;
border-color: $background-color-secondary transparent transparent $background-color-secondary;
}
}
.ribbon--pinned {
background-color: $color-warning-active;
&.--pinned {
background-color: $color-warning;
&::before {
border-color: $color-warning transparent transparent $color-warning;
&::before {
border-color: $color-warning transparent transparent $color-warning;
}
}
}
</style>

View File

@ -1,257 +0,0 @@
<template>
<vue-dropzone
:options="dropzoneOptions"
ref="el"
id="postdropzone"
class="ds-card-image"
:use-custom-slot="true"
@vdropzone-error="verror"
@vdropzone-thumbnail="transformImage"
>
<div class="crop-overlay" ref="cropperOverlay" v-show="showCropper">
<base-button @click="cropImage" class="crop-confirm" filled>
{{ $t('contribution.teaserImage.cropperConfirm') }}
</base-button>
<base-button
class="crop-cancel"
icon="close"
size="small"
circle
danger
filled
@click="cancelCrop"
/>
</div>
<div
:class="{
'hc-attachments-upload-area-post': true,
'hc-attachments-upload-area-update-post': contribution,
}"
>
<slot></slot>
<div
:class="{
'hc-drag-marker-post': true,
'hc-drag-marker-update-post': contribution,
}"
>
<base-icon name="image" />
</div>
</div>
</vue-dropzone>
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
export default {
components: {
vueDropzone,
},
props: {
contribution: { type: Object, default: () => {} },
},
data() {
return {
dropzoneOptions: {
url: () => '',
maxFilesize: 5.0,
previewTemplate: this.template(),
},
image: null,
file: null,
editor: null,
cropper: null,
thumbnailElement: null,
oldImage: null,
error: false,
showCropper: false,
imageAspectRatio: null,
}
},
methods: {
template() {
return `<div class="dz-preview dz-file-preview">
<div class="dz-image">
<div data-dz-thumbnail-bg></div>
</div>
</div>
`
},
verror(file, message) {
this.error = true
this.$toast.error(file.status, message)
setTimeout(() => {
this.error = false
}, 2000)
},
transformImage(file) {
this.file = file
this.$emit('cropInProgress', true)
this.showCropper = true
this.initEditor()
this.initCropper()
},
initEditor() {
this.editor = this.$refs.cropperOverlay
this.clearImages()
this.thumbnailElement.appendChild(this.editor)
},
clearImages() {
this.thumbnailElement = document.querySelectorAll('#postdropzone')[0]
const thumbnailPreview = document.querySelectorAll('.thumbnail-preview')[0]
if (thumbnailPreview) thumbnailPreview.remove()
const contributionImage = document.querySelectorAll('.contribution-image')[0]
this.oldImage = contributionImage
if (contributionImage) contributionImage.remove()
},
deleteImage() {
this.clearImages()
},
initCropper() {
this.image = new Image()
this.image.src = URL.createObjectURL(this.file)
this.editor.appendChild(this.image)
this.cropper = new Cropper(this.image, { zoomable: false, autoCropArea: 0.9 })
},
cropImage() {
this.showCropper = false
if (this.file.type === 'image/jpeg') {
this.uploadJpeg()
} else {
this.uploadOtherImageType()
}
},
uploadOtherImageType() {
this.imageAspectRatio = this.file.width / this.file.height || 1.0
this.image = new Image()
this.image.src = this.file.dataURL
this.setupPreview()
this.emitImageData(this.file)
},
uploadJpeg() {
const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob(blob => {
this.imageAspectRatio = canvas.width / canvas.height
this.image = new Image()
this.image.src = canvas.toDataURL()
this.setupPreview()
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
this.emitImageData(croppedImageFile)
}, 'image/jpeg')
},
setupPreview() {
this.image.classList.add('thumbnail-preview')
this.thumbnailElement.appendChild(this.image)
},
cancelCrop() {
if (this.oldImage) this.thumbnailElement.appendChild(this.oldImage)
this.showCropper = false
this.$emit('cropInProgress', false)
},
emitImageData(imageFile) {
this.$emit('addTeaserImage', imageFile)
this.$emit('addImageAspectRatio', this.imageAspectRatio)
this.$emit('cropInProgress', false)
},
},
}
</script>
<style lang="scss">
#postdropzone {
width: 100%;
min-height: 400px;
background-color: $background-color-softest;
}
.hc-attachments-upload-area-post {
position: relative;
display: flex;
justify-content: center;
cursor: pointer;
}
.hc-attachments-upload-area-update-post img {
object-fit: cover;
object-position: center;
display: block;
width: 100%;
}
.hc-attachments-upload-area-update-post:hover {
opacity: 0.7;
}
.hc-drag-marker-post {
position: absolute;
width: 122px;
height: 122px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 180px 5px;
color: hsl(0, 0%, 25%);
transition: all 0.2s ease-out;
font-size: 60px;
background-color: $background-color-softest;
opacity: 0.65;
&:before {
position: absolute;
content: '';
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 100%;
border: 20px solid $text-color-base;
visibility: hidden;
}
&:after {
position: absolute;
content: '';
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
border-radius: 100%;
border: $border-size-base dashed $text-color-base;
}
.hc-attachments-upload-area-post:hover & {
opacity: 1;
}
}
.hc-drag-marker-update-post {
opacity: 0.1;
}
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
.crop-overlay {
max-height: 2000px;
position: relative;
width: 100%;
background-color: #000;
}
.crop-confirm {
position: absolute;
left: 10px;
top: 10px;
z-index: 1;
}
.crop-cancel {
position: absolute;
right: 10px;
top: 10px;
z-index: 1;
}
</style>

View File

@ -80,7 +80,7 @@ storiesOf('UserTeaser', module)
}),
template: `
<user-teaser :user="user" :date-time="new Date()">
<template v-slot:dateTime>
<template #dateTime>
- HEY! I'm edited
</template>
</user>

View File

@ -3,107 +3,36 @@
<user-avatar v-if="showAvatar" size="small" />
<span class="info anonymous">{{ $t('profile.userAnonym') }}</span>
</div>
<dropdown
v-else
:class="[{ 'disabled-content': user.disabled }]"
placement="top-start"
offset="0"
>
<template #default="{ openMenu, closeMenu, isOpen }">
<nuxt-link
:to="userLink"
:class="['user-teaser', isOpen && 'active']"
@mouseover.native="showPopover ? openMenu(true) : () => {}"
@mouseleave.native="closeMenu(true)"
>
<user-avatar v-if="showAvatar" :user="user" size="small" />
<div class="info">
<span class="text">
<span class="slug">{{ userSlug }}</span>
<span v-if="dateTime">{{ userName }}</span>
</span>
<span v-if="dateTime" class="text">
<base-icon name="clock" />
<hc-relative-date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</span>
<span v-else class="text">{{ userName }}</span>
</div>
</nuxt-link>
</template>
<template #popover v-if="showPopover">
<div style="min-width: 250px">
<hc-badges v-if="user.badges && user.badges.length" :badges="user.badges" />
<ds-text
v-if="user.location"
align="center"
color="soft"
size="small"
style="margin-top: 5px"
bold
>
<base-icon name="map-marker" />
{{ user.location.name }}
</ds-text>
<ds-flex style="margin-top: -10px">
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="user.followedByCount"
:label="$t('profile.followers')"
size="x-large"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item ds-tab-nav-item-active">
<ds-space margin="small">
<ds-number
:count="user.contributionsCount"
:label="$t('common.post', null, user.contributionsCount)"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item class="ds-tab-nav-item">
<ds-space margin="small">
<ds-number
:count="user.commentedCount"
:label="$t('common.comment', null, user.commentedCount)"
/>
</ds-space>
</ds-flex-item>
</ds-flex>
<ds-flex v-if="!itsMe" gutter="x-small" style="margin-bottom: 0;">
<ds-flex-item>
<hc-follow-button
:follow-id="user.id"
:is-followed="user.followedByCurrentUser"
@optimistic="optimisticFollow"
@update="updateFollow"
/>
</ds-flex-item>
</ds-flex>
<div v-else :class="[{ 'disabled-content': user.disabled }]" placement="top-start">
<nuxt-link :to="userLink" :class="['user-teaser']">
<user-avatar v-if="showAvatar" :user="user" size="small" />
<div class="info">
<span class="text">
<span class="slug">{{ userSlug }}</span>
<span v-if="dateTime">{{ userName }}</span>
</span>
<span v-if="dateTime" class="text">
<base-icon name="clock" />
<hc-relative-date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</span>
<span v-else class="text">{{ userName }}</span>
</div>
</template>
</dropdown>
</nuxt-link>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import HcRelativeDateTime from '~/components/RelativeDateTime'
import HcFollowButton from '~/components/FollowButton'
import HcBadges from '~/components/Badges'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import Dropdown from '~/components/Dropdown'
export default {
name: 'UserTeaser',
components: {
HcRelativeDateTime,
HcFollowButton,
UserAvatar,
HcBadges,
Dropdown,
},
props: {
user: { type: Object, default: null },

View File

@ -0,0 +1,78 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import BaseCard from './BaseCard.vue'
storiesOf('Generic/BaseCard', module)
.addDecorator(helpers.layout)
.add('default', () => ({
components: { BaseCard },
template: `
<base-card>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: hero image', () => ({
components: { BaseCard },
template: `
<base-card style="width: 400px;">
<template #heroImage>
<img class="image" src="https://unsplash.com/photos/R4y_E5ZQDPg/download" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: image column', () => ({
components: { BaseCard },
template: `
<base-card style="width: 600px;">
<template #imageColumn>
<img class="image" src="/img/sign-up/humanconnection.svg" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: topMenu', () => ({
components: { BaseCard },
template: `
<base-card style="width: 600px;">
<template #imageColumn>
<img class="image" src="/img/sign-up/humanconnection.svg" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
<template #topMenu>
<base-button size="small">Menu</base-button>
</template>
</base-card>
`,
}))
.add('with highlight prop', () => ({
components: { BaseCard },
template: `
<base-card highlight style="width: 400px;">
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with wideContent prop', () => ({
components: { BaseCard },
template: `
<base-card wideContent style="width: 400px;">
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))

View File

@ -0,0 +1,132 @@
<template>
<article :class="classNames">
<template v-if="$slots.imageColumn">
<aside class="image-column">
<slot name="imageColumn" />
</aside>
<section class="content-column">
<slot />
</section>
</template>
<template v-else-if="$slots.heroImage">
<section class="hero-image">
<slot name="heroImage" />
</section>
<slot />
</template>
<slot v-else />
<aside v-if="$slots.topMenu" class="top-menu">
<slot name="topMenu" />
</aside>
</article>
</template>
<script>
export default {
props: {
highlight: {
type: Boolean,
default: false,
},
wideContent: {
type: Boolean,
default: false,
},
},
computed: {
classNames() {
let classNames = 'base-card'
if (this.$slots.imageColumn) classNames += ' --columns'
if (this.highlight) classNames += ' --highlight'
if (this.wideContent) classNames += ' --wide-content'
return classNames
},
},
}
</script>
<style lang="scss">
.base-card {
position: relative;
padding: $space-base;
border-radius: $border-radius-x-large;
overflow: hidden;
background-color: $color-neutral-100;
box-shadow: $box-shadow-base;
&.--columns {
display: flex;
}
&.--highlight {
border: $border-size-base solid $color-warning;
}
&.--wide-content {
padding: $space-small;
> .hero-image {
width: calc(100% + (2 * #{$space-small}));
margin: -$space-small;
margin-bottom: $space-small;
}
}
> .title,
> .content-column > .title {
font-size: $font-size-large;
margin-bottom: $space-x-small;
}
> .hero-image {
width: calc(100% + (2 * #{$space-base}));
max-height: $size-image-max-height;
margin: -$space-base;
margin-bottom: $space-base;
overflow: hidden;
> .image {
width: 100%;
object-fit: contain;
}
}
> .image-column {
flex-basis: 50%;
display: flex;
justify-content: center;
align-items: center;
padding-right: $space-base;
.image {
width: 100%;
max-width: 200px;
}
}
> .content-column {
flex-basis: 50%;
}
> .top-menu {
position: absolute;
top: $space-small;
left: $space-small;
}
}
@media (max-width: 565px) {
.base-card.--columns {
flex-direction: column;
> .image-column {
padding-right: 0;
margin-bottom: $space-base;
}
}
}
</style>

View File

@ -1,7 +1,7 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import { post } from '~/components/PostCard/PostCard.story.js'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
import helpers from '~/storybook/helpers'
import ReportList from './ReportList'
@ -183,11 +183,11 @@ storiesOf('ReportList', module)
openModal: action('openModal'),
filter: action('filter'),
},
template: `<ds-card>
template: `<base-card>
<div class="reports-header">
<h3 class="title">Reports</h3>
<dropdown-filter @filter="filter" :filterOptions="filterOptions" :selected="selected" />
</div>
<reports-table :reports="reports" @confirm="openModal" />
</ds-card>`,
</base-card>`,
}))

View File

@ -1,5 +1,5 @@
<template>
<ds-card>
<base-card>
<div class="reports-header">
<h3 class="title">{{ $t('moderation.reports.name') }}</h3>
<client-only>
@ -8,8 +8,9 @@
</div>
<reports-table :reports="reports" @confirm="openModal" />
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
</ds-card>
</base-card>
</template>
<script>
import { mapMutations } from 'vuex'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
@ -43,13 +44,22 @@ export default {
computed: {
filterOptions() {
return [
{ label: this.$t('moderation.reports.filterLabel.all'), value: { reviewed: null } },
{
label: this.$t('moderation.reports.filterLabel.all'),
value: { reviewed: null, closed: null },
},
{
label: this.$t('moderation.reports.filterLabel.unreviewed'),
value: { reviewed: false },
value: { reviewed: false, closed: false },
},
{
label: this.$t('moderation.reports.filterLabel.reviewed'),
value: { reviewed: true, closed: false },
},
{
label: this.$t('moderation.reports.filterLabel.closed'),
value: { reviewed: null, closed: true },
},
{ label: this.$t('moderation.reports.filterLabel.reviewed'), value: { reviewed: true } },
{ label: this.$t('moderation.reports.filterLabel.closed'), value: { closed: true } },
]
},
modalData() {
@ -107,13 +117,8 @@ export default {
filter(option) {
this.selected = option.label
this.offset = 0
if (option.value.closed) {
this.closed = option.value.closed
this.reviewed = null
return
}
this.closed = null
this.reviewed = option.value.reviewed
this.closed = option.value.closed
},
async confirmCallback(resource) {
const { disabled: disable, id: resourceId } = resource
@ -166,7 +171,7 @@ export default {
.reports-header {
display: flex;
justify-content: space-between;
margin: $space-small 0;
margin-bottom: $space-small;
> .title {
margin: 0;

View File

@ -24,11 +24,8 @@
</tr>
</thead>
<template v-for="report in reports">
<report-row
:key="report.resource.id"
:report="report"
@confirm-report="$emit('confirm', report)"
/>
<!-- should be ':key="report.resource.id"' for having one element for every resource, but this crashes at the moment, because the 'reports' query returns multiple reports on the same resource! I will create an issue -->
<report-row :key="report.id" :report="report" @confirm-report="$emit('confirm', report)" />
</template>
</table>
<hc-empty v-else icon="alert" :message="$t('moderation.reports.empty')" />

View File

@ -1,2 +1,2 @@
// please change also version in file "cypress/constants/terms-and-conditions-version.js"
export const VERSION = '0.0.3'
export const VERSION = '0.0.4'

View File

@ -100,7 +100,7 @@ export const reportMutation = () => {
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
reportId
}
}
`

View File

@ -10,9 +10,7 @@
</a>
</ds-flex-item>
<ds-flex-item width="20%" style="flex-grow:0;">
<client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="16" />
</client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="16" />
</ds-flex-item>
</ds-flex>
</ds-container>

View File

@ -50,9 +50,7 @@
}"
style="flex-basis: auto;"
>
<client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
<template v-if="isLoggedIn">
<client-only>
<notification-menu placement="top" />

View File

@ -40,7 +40,7 @@
"tagCountUnique": "Benutzer"
},
"invites": {
"description": "Einladungen sind ein wunderbarer Weg, deine Freund in deinem Netzwerk zu haben …",
"description": "Einladungen sind eine wunderbare Möglichkeit, Deine Freunde in Deinem Netzwerk zu haben …",
"name": "Benutzer einladen",
"title": "Leute einladen"
},
@ -66,7 +66,7 @@
"table": {
"columns": {
"createdAt": "Erstellt am",
"email": "E-mail",
"email": "E-Mail",
"name": "Name",
"number": "Nr.",
"role": "Rolle",
@ -81,9 +81,9 @@
"list": {
"0": "Aufforderung zum sofortigen Abstellen des inakzeptablen Verhaltens",
"1": "Sperren oder Löschen von Kommentaren",
"2": "Temporärer Ausschluss aus dem jeweiligen Beitrag",
"2": "Vorübergehender Ausschluss aus dem jeweiligen Beitrag",
"3": "Sperren bzw. Löschen von Inhalten",
"4": "Temporärer Entzug von Schreibrechten",
"4": "Vorübergehender Entzug von Schreibrechten",
"5": "Vorübergehender Ausschluss aus dem Netzwerk",
"6": "Endgültiger Ausschluss aus dem Netzwerk",
"7": "Verstöße gegen deutsches Recht können zur Anzeige gebracht werden."
@ -93,10 +93,10 @@
"expected-behaviour": {
"description": "Die folgenden Verhaltensweisen werden von allen Community-Mitgliedern erwartet und gefordert:",
"list": {
"0": "Sei rücksichtsvoll und respektvoll bei dem was Du schreibst und tust.",
"0": "Sei rücksichtsvoll und respektvoll, bei dem, was Du schreibst und tust.",
"1": "Versuche auf andere zuzugehen, bevor ein Konflikt entsteht.",
"2": "Vermeide erniedrigende, diskriminierende oder belästigende Verhaltensweisen und Ausdrücke.",
"3": "Gehe achtsam mit Deiner Umgebung um. Informiere den Support bei gefährlichen Situationen, wenn eine Person in Not ist oder bei Verstößen gegen diesen Verhaltenskodex, auch wenn sie unbedeutend erscheinen."
"3": "Achte Dein Umfeld und Deine Mitmenschen. Warne die Verantwortlichen der Community, falls Du eine gefährliche Situation, jemanden in Not oder Verstöße gegen diesen Verhaltenskodex bemerkst, auch wenn diese unbedeutend erscheinen."
},
"title": "Erwartetes Verhalten"
},
@ -114,9 +114,9 @@
"description": "Die folgenden Verhaltensweisen sind in unserer Community inakzeptabel:",
"list": {
"0": "Diskriminierende Beiträge, Kommentare, Äußerungen oder Beleidigungen, insbesondere solche, die sich auf Geschlecht, sexuelle Orientierung, Rasse, Religion, politische oder weltanschauliche Ausrichtung oder Behinderung beziehen.",
"1": "Das Posten oder Verlinken eindeutig pornografischen Materials.",
"1": "Das Senden oder Verlinken eindeutig pornografischen Materials.",
"2": "Verherrlichung oder Verharmlosung grausamer oder unmenschlicher Gewalttätigkeiten.",
"3": "Das Veröffentlichen von personenbezogenen Daten anderer ohne deren Einverständnis oder das Androhen dessen (\"Doxing\").",
"3": "Das Veröffentlichen von personenbezogenen Daten anderer ohne deren Einverständnis oder das Androhen dessen („Doxing“).",
"4": "Absichtliche Einschüchterung, Stalking oder Verfolgung.",
"5": "Bewerben von Produkten und Dienstleistungen mit kommerzieller Absicht.",
"6": "Strafbares Verhalten bzw. Verstoß gegen deutsches Recht.",
@ -157,7 +157,7 @@
"user": "Benutzer ::: Benutzer",
"validations": {
"categories": "es müssen eine bis drei Kategorien ausgewählt werden",
"email": "muss eine gültige E-Mail Adresse sein",
"email": "muss eine gültige E-Mail-Adresse sein",
"url": "muss eine gültige URL sein"
},
"versus": "Versus"
@ -165,7 +165,7 @@
"components": {
"enter-nonce": {
"form": {
"description": "Öffne dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.",
"description": "Öffne Dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.",
"next": "Weiter",
"nonce": "Code eingeben",
"validations": {
@ -181,9 +181,9 @@
},
"request": {
"form": {
"description": "Eine Mail zum Zurücksetzen des Passworts wird an die angegebene E-Mail Adresse geschickt.",
"description": "Eine E-Mail zum Zurücksetzen des Passworts wird an die angegebene Adresse geschickt.",
"submit": "E-Mail anfordern",
"submitted": "Eine E-Mail mit weiteren Instruktionen wurde verschickt an <b>{email}</b>"
"submitted": "Eine E-Mail mit weiteren Hinweisen wurde verschickt an <b>{email}</b>"
},
"title": "Passwort zurücksetzen"
}
@ -191,25 +191,25 @@
"registration": {
"create-user-account": {
"error": "Es konnte kein Benutzerkonto erstellt werden!",
"help": "Vielleicht war der Bestätigungscode falsch oder abgelaufen? Wenn das Problem weiterhin besteht, schick uns gerne eine E-Mail an:",
"help": "Vielleicht war der Bestätigungscode falsch oder abgelaufen? Wenn das Problem weiterhin besteht, schicke uns gerne eine E-Mail an:",
"success": "Dein Benutzerkonto wurde erstellt!",
"title": "Benutzerkonto anlegen"
},
"signup": {
"form": {
"data-privacy": "Ich habe die <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\"><ds-text bold color=\"primary\" >Datenschutzerklärung</ds-text></a> gelesen und verstanden",
"description": "Um loszulegen, kannst du dich hier kostenfrei registrieren:",
"data-privacy": "Ich habe die <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\"><ds-text bold color=\"primary\" >Datenschutzerklärung</ds-text></a> gelesen und verstanden",
"description": "Um loszulegen, kannst Du Dich hier kostenfrei registrieren:",
"errors": {
"email-exists": "Es gibt schon ein Benutzerkonto mit dieser E-Mail Adresse!",
"email-exists": "Es gibt schon ein Benutzerkonto mit dieser E-Mail-Adresse!",
"invalid-invitation-token": "Es sieht so aus, als ob der Einladungscode schon eingelöst wurde. Jeder Code kann nur einmalig benutzt werden."
},
"invitation-code": "Dein Einladungscode lautet: <b>{code}</b>",
"minimum-age": "Ich bin 18 Jahre oder älter.",
"no-commercial": "Ich habe keine kommerziellen Absichten und ich repräsentiere kein kommerzielles Unternehmen oder Organisation.",
"no-political": "Ich bin nicht im Auftrag einer Partei oder politischen Organisation im Netzwerk. ",
"no-political": "Ich bin nicht im Auftrag einer Partei oder politischen Organisation im Netzwerk.",
"submit": "Konto erstellen",
"success": "Eine Mail mit einem Bestätigungslink für die Registrierung wurde an <b>{email}</b> geschickt",
"terms-and-condition": "Ich stimme den <a href=\"/terms-and-conditions\"><ds-text bold color=\"primary\" > Nutzungsbedingungen</ds-text></a>zu."
"success": "Eine E-Mail mit einem Link zum Abschließen Deiner Registrierung wurde an <b>{email}</b> geschickt",
"terms-and-condition": "Ich stimme den <a href=\"/terms-and-conditions\"><ds-text bold color=\"primary\" >Nutzungsbedingungen</ds-text></a> zu."
},
"title": "Mach mit bei Human Connection!",
"unavailable": "Leider ist die öffentliche Registrierung von Benutzerkonten auf diesem Server derzeit nicht möglich."
@ -217,6 +217,8 @@
}
},
"contribution": {
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} von {max} Kategorien ausgewählt"
},
@ -248,10 +250,10 @@
"surprised": "Erstaunt"
},
"filterALL": "Alle Beiträge anzeigen",
"filterFollow": "Beiträge filtern von Usern denen ich folge",
"filterFollow": "Beiträge von Benutzern filtern, denen ich folge",
"inappropriatePicture": "Dieses Bild kann für einige Menschen unangemessen sein.",
"inappropriatePictureText": "Wann soll ein Foto versteckt werden",
"languageSelectLabel": "Sprache deines Beitrags",
"inappropriatePictureText": "Wann sollte mein Beitragsbild verschwommen sein?",
"languageSelectLabel": "Sprache Deines Beitrags",
"languageSelectText": "Sprache wählen",
"newPost": "Erstelle einen neuen Beitrag",
"success": "Gespeichert!",
@ -263,16 +265,16 @@
"delete": {
"cancel": "Abbrechen",
"comment": {
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" löschen möchtest?",
"message": "Bist Du sicher, dass Du den Kommentar „<b>{name}</b>“ löschen möchtest?",
"success": "Kommentar erfolgreich gelöscht!",
"title": "Lösche Kommentar",
"type": "Comment"
"type": "Kommentar"
},
"contribution": {
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" löschen möchtest?",
"message": "Bist Du sicher, dass Du den Beitrag „<b>{name}</b>“ löschen möchtest?",
"success": "Beitrag erfolgreich gelöscht!",
"title": "Lösche Beitrag",
"type": "Contribution"
"type": "Beitrag"
},
"submit": "Löschen"
},
@ -297,15 +299,15 @@
}
},
"donations": {
"amount-of-total": "{amount} von {total} € erreicht",
"amount-of-total": "{amount} von {total} € erreicht",
"donate-now": "Jetzt spenden",
"donations-for": "Spenden für"
},
"editor": {
"embed": {
"always_allow": "Inhalte von Drittanbietern immer anzeigen (diese Einstellung kannst du jederzeit ändern)",
"data_privacy_info": "Deine Daten wurden noch nicht an Drittanbieter weitergegeben. Wenn du dieses Video jetzt abspielst, registriert der folgende Anbieter wahrscheinlich deine Nutzerdaten:",
"data_privacy_warning": "Achte auf deine Daten!",
"always_allow": "Einzubettende Inhalte von Drittanbietern immer erlauben (diese Einstellung ist jederzeit änderbar)",
"data_privacy_info": "Deine Daten wurden noch nicht an Drittanbieter weitergegeben. Wenn Du dieses Video jetzt abspielst, registriert der folgende Anbieter wahrscheinlich Deine Nutzerdaten:",
"data_privacy_warning": "Achte auf Deine Daten!",
"play_now": "Jetzt ansehen"
},
"hashtag": {
@ -355,7 +357,7 @@
"following": "Folge Ich"
},
"index": {
"change-filter-settings": "Verändere die Filter-Einstellungen um mehr Ergebnisse zu erhalten.",
"change-filter-settings": "Verändere die Filter-Einstellungen, um mehr Ergebnisse zu erhalten.",
"no-results": "Keine Beiträge gefunden."
},
"login": {
@ -375,8 +377,8 @@
"success": "Du bist eingeloggt!"
},
"maintenance": {
"explanation": "Zurzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuch es später erneut.",
"questions": "Bei Fragen oder Problemen erreichst du uns per E-Mail an",
"explanation": "Derzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuche es später erneut.",
"questions": "Bei Fragen oder Problemen erreichst Du uns per E-Mail an",
"title": "Human Connection befindet sich in der Wartung"
},
"moderation": {
@ -390,32 +392,32 @@
"cancel": "Abbruch",
"Comment": {
"disable": {
"message": "Möchtest du den Kommentar \"<b>{name}</b>\" wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest Du den Kommentar „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Kommentar abschließend"
},
"enable": {
"message": "Möchtest du den Kommentar \"<b>{name}</b>\" wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest Du den Kommentar „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"title": "Entsperre den Kommentar abschließend"
}
},
"Post": {
"disable": {
"message": "Möchtest du den Beitrag \"<b>{name}</b>\" wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest Du den Beitrag „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Beitrag abschließend"
},
"enable": {
"message": "Möchtest du den Beitrag \"<b>{name}</b>\" wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest Du den Beitrag „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"title": "Entsperre den Beitrag abschließend"
}
},
"submit": "Bestätige Entscheidung",
"User": {
"disable": {
"message": "Möchtest du den Benutzer \"<b>{name}</b>\" wirklich <b>gesperrt</b> lassen?",
"message": "Möchtest Du den Benutzer „<b>{name}</b>“ wirklich <b>gesperrt</b> lassen?",
"title": "Sperre den Benutzer abschließend"
},
"enable": {
"message": "Möchtest du den Benutzer \"<b>{name}</b>\" wirklich <b>entsperrt</b> lassen?",
"message": "Möchtest Du den Benutzer „<b>{name}</b>“ wirklich <b>entsperrt</b> lassen?",
"title": "Entsperre den Benutzer abschließend"
}
}
@ -459,9 +461,9 @@
"pageLink": "Alle Benachrichtigungen",
"post": "Beitrag",
"reason": {
"commented_on_post": "Hat deinen Beitrag kommentiert …",
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …",
"mentioned_in_post": "Hat dich in einem Beitrag erwähnt …"
"commented_on_post": "Hat Deinen Beitrag kommentiert …",
"mentioned_in_comment": "Hat Dich in einem Kommentar erwähnt …",
"mentioned_in_post": "Hat Dich in einem Beitrag erwähnt …"
},
"title": "Benachrichtigungen",
"user": "Benutzer"
@ -470,20 +472,20 @@
"comment": {
"reply": "Antworten",
"submit": "Kommentiere",
"submitted": "Kommentar gesendet!",
"submitted": "Kommentar gesendet",
"updated": "Änderungen gespeichert"
},
"edited": "bearbeitet",
"menu": {
"delete": "Beitrag löschen",
"edit": "Beitrag bearbeiten",
"pin": "Post festpinnen",
"pinnedSuccessfully": "Post erfolgreich festgepinnt!",
"unpin": "Post nicht mehr festpinnen",
"unpinnedSuccessfully": "Post erfolgreich nicht mehr festgepinnt!"
"pin": "Beitrag anheften",
"pinnedSuccessfully": "Beitrag erfolgreich angeheftet!",
"unpin": "Beitrag loslösen",
"unpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!"
},
"moreInfo": {
"description": "Hier findest du weitere Infos zum Thema.",
"description": "Hier findest Du weitere Infos zum Thema.",
"name": "Mehr Info",
"title": "Mehr Informationen",
"titleOfCategoriesSection": "Kategorien",
@ -529,22 +531,22 @@
"release": {
"cancel": "Abbrechen",
"comment": {
"error": "Den Kommentar hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" freigeben möchtest?",
"error": "Den Kommentar hast Du schon gemeldet!",
"message": "Bist Du sicher, dass Du den Kommentar „<b>{name}</b>“ freigeben möchtest?",
"title": "Kommentar freigeben",
"type": "Kommentar"
},
"contribution": {
"error": "Den Beitrag hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" freigeben möchtest?",
"error": "Den Beitrag hast Du schon gemeldet!",
"message": "Bist Du sicher, dass Du den Beitrag „<b>{name}</b>“ freigeben möchtest?",
"title": "Beitrag freigeben",
"type": "Beitrag"
},
"submit": "freigeben",
"success": "Erfolgreich freigegeben!",
"user": {
"error": "Den User hast du schon gemeldet!",
"message": "Bist du sicher, dass du den Nutzer \"<b>{name}</b>\" freigeben möchtest?",
"error": "Den Benutzer hast Du schon gemeldet!",
"message": "Bist Du sicher, dass Du den Nutzer „<b>{name}</b>“ freigeben möchtest?",
"title": "Nutzer freigeben",
"type": "Nutzer"
}
@ -571,16 +573,16 @@
"advert_products_services_commercial": "Bewerben von Produkten und Dienstleistungen mit kommerzieller Absicht.",
"criminal_behavior_violation_german_law": "Strafbares Verhalten bzw. Verstoß gegen deutsches Recht.",
"discrimination_etc": "Diskriminierende Beiträge, Kommentare, Äußerungen oder Beleidigungen.",
"doxing": "Das Veröffentlichen von personenbezogenen Daten anderer ohne deren Einverständnis oder das Androhen dessen (\"Doxing\").",
"doxing": "Das Veröffentlichen von personenbezogenen Daten anderer ohne deren Einverständnis oder das Androhen dessen („Doxing“).",
"glorific_trivia_of_cruel_inhuman_acts": "Verherrlichung oder Verharmlosung grausamer oder unmenschlicher Gewalttätigkeiten.",
"intentional_intimidation_stalking_persecution": "Absichtliche Einschüchterung, Stalking oder Verfolgung.",
"other": "Andere …",
"pornographic_content_links": "Das Posten oder Verlinken eindeutig pornografischen Materials."
"pornographic_content_links": "Das Senden oder Verlinken eindeutig pornografischen Materials."
},
"placeholder": "Kategorie …"
},
"description": {
"label": "Bitte erkläre: Warum möchtest du dies melden?",
"label": "Bitte erkläre: Warum möchtest Du dies melden?",
"placeholder": "Zusätzliche Information …"
}
},
@ -610,19 +612,19 @@
"slug": "Alias",
"unblock": "Entsperren"
},
"empty": "Bislang hast du niemanden blockiert.",
"empty": "Bislang hast Du niemanden blockiert.",
"explanation": {
"closing": "Das sollte fürs Erste genügen, damit blockierte Benutzer dich nicht mehr länger belästigen können.",
"closing": "Das sollte fürs Erste genügen, damit blockierte Benutzer Dich nicht mehr länger belästigen können.",
"commenting-disabled": "Du kannst den Beitrag derzeit nicht kommentieren.",
"commenting-explanation": "Dafür kann es mehrere Gründe geben, bitte schau in unsere ",
"intro": "Wenn ein anderer Benutzer von dir blockiert wurde, dann passiert folgendes:",
"notifications": "Von dir blockierte Personen erhalten keine Benachrichtigungen mehr, wenn sie in deinen Beiträgen erwähnt werden.",
"their-perspective": "Die blockierte Person kann deine Beiträge nicht mehr kommentieren",
"your-perspective": "Du kannst keine Beiträge der blockierten Person mehr kommentieren."
"intro": "Wenn ein anderer Benutzer durch Dich blockiert wurde, dann passiert Folgendes:",
"notifications": "Von Dir blockierte Benutzer werden keine Benachrichtigungen mehr erhalten, falls sie in Deinen Beiträgen erwähnt werden.",
"their-perspective": "Umgekehrt das gleiche: Die blockierte Person bekommt auch in ihren Benachrichtigungen Deine Beiträge nicht mehr zu sehen.",
"your-perspective": "In Deinen Benachrichtigungen tauchen keine Beiträge der blockierten Person mehr auf."
},
"how-to": "Du kannst andere Benutzer auf deren Profilseite über das Inhaltsmenü blockieren.",
"name": "Blocked users",
"unblock": "Nutzer entsperren",
"name": "Blockierte Benutzer",
"unblock": "Blockierten Nutzer freigeben",
"unblocked": "{name} ist wieder entsperrt"
},
"data": {
@ -636,47 +638,47 @@
},
"deleteUserAccount": {
"accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.",
"accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen <b>WEDER VERWALTEN NOCH WIEDERHERSTELLEN!</b>",
"accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen WEDER VERWALTEN NOCH WIEDERHERSTELLEN!",
"commentedCount": "Meine {count} Kommentare löschen",
"contributionsCount": "Meine {count} Beiträge löschen",
"name": "Benutzerkonto löschen",
"pleaseConfirm": "<b class='is-danger'>Zerstörerische Aktion!</b> Gib <b>{confirm}</b> ein, um zu bestätigen.",
"pleaseConfirm": "Zerstörerische Aktion! Gib „{confirm}“ ein, um zu bestätigen.",
"success": "Konto erfolgreich gelöscht!"
},
"download": {
"name": "Daten herunterladen"
},
"email": {
"change-successful": "Deine E-Mail Adresse wurde erfolgreich geändert.",
"labelEmail": "E-Mail Adresse ändern",
"labelNewEmail": "Neue E-Mail Adresse",
"change-successful": "Deine E-Mail-Adresse wurde erfolgreich geändert.",
"labelEmail": "E-Mail-Adresse ändern",
"labelNewEmail": "Neue E-Mail-Adresse",
"labelNonce": "Bestätigungscode eingeben",
"name": "Deine E-Mail",
"submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an <b>{email}</b> gesendet.",
"success": "Eine neue E-Mail Addresse wurde registriert.",
"submitted": "Eine E-Mail zur Bestätigung Deiner Adresse wurde an <b>{email}</b> gesendet.",
"success": "Eine neue E-Mail-Adresse wurde registriert.",
"validation": {
"same-email": "Das ist deine aktuelle E-Mail Addresse"
"same-email": "Das ist Deine aktuelle E-Mail-Adresse"
},
"verification-error": {
"explanation": "Das kann verschiedene Ursachen haben:",
"message": "Deine E-Mail Adresse konnte nicht verifiziert werden.",
"message": "Deine E-Mail-Adresse konnte nicht verifiziert werden.",
"reason": {
"invalid-nonce": "Ist der Bestätigungscode falsch?",
"no-email-request": "Bist du dir sicher, dass du eine Änderung deiner E-Mail Adresse angefragt hattest?"
"no-email-request": "Bist Du Dir sicher, dass Du eine Änderung Deiner E-Mail-Adresse angefragt hattest?"
},
"support": "Wenn das Problem weiterhin besteht, kontaktiere uns gerne per E-Mail an"
}
},
"embeds": {
"info-description": "Hier ist die Liste an Drittanbietern, deren Inhalte als Fremdcode z.B. in Form von eingebetteten Videos angezeigt werden kann:",
"info-description": "Hier ist die Liste der Drittanbieter, deren Inhalte, z.B. in Form eingebetteter Videos, mittels Drittanbieter-Programmcode angezeigt werden kann:",
"name": "Drittanbieter",
"status": {
"change": {
"allow": "Na klar",
"deny": "Lieber nicht",
"question": "Soll eingebetter Fremdcode von Dritten für dich immer angezeigt werden?"
"question": "Soll einzubettender Programmcode der Drittanbieter Dir immer zur Anzeige gebracht werden?"
},
"description": "Als Grundeinstellung für dich wird eingebetter Fremdcode von Drittanbietern",
"description": "Als Grundeinstellung wird Dir der einzubettende Code der Drittanbieter",
"disabled": {
"off": "zunächst nicht angezeigt",
"on": "sofort angezeigt"
@ -722,10 +724,10 @@
"label-new-password": "Dein neues Passwort",
"label-new-password-confirm": "Bestätige Dein neues Passwort",
"label-old-password": "Dein altes Passwort",
"message-new-password-confirm-required": "Bestätige dein neues Passwort",
"message-new-password-missmatch": "Gebe das gleiche Passwort nochmals ein",
"message-new-password-required": "Gebe ein neues Passwort ein",
"message-old-password-required": "Gebe dein altes Passwort ein",
"message-new-password-confirm-required": "Bestätige Dein neues Passwort",
"message-new-password-missmatch": "Gib dasselbe Passwort nochmals ein",
"message-new-password-required": "Gib ein neues Passwort ein",
"message-old-password-required": "Gib Dein altes Passwort ein",
"passwordSecurity": "Passwortsicherheit",
"passwordStrength0": "Sehr unsicheres Passwort",
"passwordStrength1": "Unsicheres Passwort",
@ -737,12 +739,12 @@
"name": "Sicherheit"
},
"social-media": {
"name": "Soziale Medien",
"placeholder": "Deine Social-Media URL",
"name": "Soziale Netzwerke",
"placeholder": "Deine Webadresse des Sozialen Netzwerkes",
"requireUnique": "Dieser Link existiert bereits",
"submit": "Link hinzufügen",
"successAdd": "Social-Media hinzugefügt. Profil aktualisiert!",
"successDelete": "Social-Media gelöscht. Profil aktualisiert!"
"successAdd": "Soziales Netzwerk hinzugefügt. Profil wurde aktualisiert!",
"successDelete": "Soziales Netzwerk entfernt. Profil wurde aktualisiert!"
},
"validation": {
"slug": {
@ -767,7 +769,7 @@
"imprint": "Impressum",
"made": "Mit &#10084; gemacht",
"register": "Registernummer",
"responsible": "Verantwortlicher gemäß § 55 Abs. 2 RStV ",
"responsible": "Verantwortlich für Inhalte dieser Seite (§ 55 Abs. 2 RStV)",
"taxident": "Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz (Deutschland)",
"termsAndConditions": "Nutzungsbedingungen",
"thanks": "Danke!",
@ -788,11 +790,11 @@
"termsAndConditions": {
"addition": {
"description": "<a href=\"https://human-connection.org/veranstaltungen/\" target=\"_blank\" > https://human-connection.org/veranstaltungen/ </a>",
"title": "Zusätzlich machen wir regelmäßig Veranstaltungen, wo Du auch Eindrücke wiedergeben und Fragen stellen kannst. Du findest eine aktuelle Übersicht hier:"
"title": "Zusätzlich veranstalten wir regelmäßig Ereignisse, wo Du auch Eindrücke teilen und Fragen stellen kannst. Du findest eine aktuelle Übersicht hier:"
},
"agree": "Ich stimme zu!",
"code-of-conduct": {
"description": "Unser Verhaltenskodex dient als Leitfaden für das persönliche Auftreten und den Umgang miteinander. Wer als Nutzer im Human Connection-Netzwerk aktiv ist, Beiträge verfasst, kommentiert oder mit anderen Nutzern, auch außerhalb des Netzwerkes, Kontakt aufnimmt, erkennt diese Verhaltensregeln als verbindlich an. <a href=\"https://alpha.human-connection.org/code-of-conduct\" target=\"_blank\"> https://alpha.human-connection.org/code-of-conduct</a>",
"description": "Unser Verhaltenskodex dient als Leitfaden für das persönliche Auftreten und den Umgang miteinander. Wer als Nutzer im Human Connection-Netzwerk aktiv ist, Beiträge verfasst, kommentiert oder mit anderen Nutzern, auch außerhalb des Netzwerkes, Kontakt aufnimmt, erkennt diese Verhaltensregeln als verbindlich an. <a href=\"https://alpha.human-connection.org/code-of-conduct\" target=\"_blank\">https://alpha.human-connection.org/code-of-conduct</a>",
"title": "Verhaltenscodex"
},
"errors-and-feedback": {
@ -800,7 +802,7 @@
"title": "Fehler und Rückmeldungen"
},
"help-and-questions": {
"description": "Für Hilfe und Fragen haben wir Dir eine umfassende Sammlung an häufig gestellten Fragen und Antworten (FAQ) zusammengestellt. Du findest diese hier: <a href=\"https://support.human-connection.org/kb/\" target=\"_blank\" > https://support.human-connection.org/kb/ </a>",
"description": "Für Hilfe und Fragen haben wir Dir eine umfassende Sammlung an häufig gestellten Fragen und Antworten (FAQ) zusammengestellt; Du findest diese auf <a href=\"https://support.human-connection.org/kb/\" target=\"_blank\" > support.human-connection.org/kb/ </a>",
"title": "Hilfe und Fragen"
},
"moderation": {
@ -809,11 +811,15 @@
},
"newTermsAndConditions": "Neue Nutzungsbedingungen",
"no-commercial-use": {
"description": "Die Nutzung des Human Connection Netzwerkes ist nicht gestattet für kommerzielle Nutzung. Darunter fällt unter anderem das Bewerben von Produkten mit kommerzieller Absicht, das Einstellen von Affiliate-Links, direkter Aufruf zu Spenden oder finanzieller Unterstützung für Zwecke, die steuerlich nicht als gemeinnützig anerkannt sind.",
"description": "Die Nutzung des Human Connection Netzwerkes ist nicht gestattet für kommerzielle Nutzung. Darunter fällt unter anderem das Bewerben von Produkten mit kommerzieller Absicht, das Einstellen von Affiliate-Links (Geschäftspartner-Links), direkter Aufruf zu Spenden oder finanzieller Unterstützung für Zwecke, die steuerlich nicht als gemeinnützig anerkannt sind.",
"title": "Keine kommerzielle Nutzung"
},
"no-parties": {
"description": "Nutzerkonten von politischen Parteien oder offizielle Nutzerkonten eines politischen Vertreters sind unzulässig.",
"title": "Keine politische Nutzung"
},
"privacy-statement": {
"description": "Unser Netzwerk ist ein soziales Wissens- und Aktionsnetzwerk. Daher ist es uns besonders wichtig, dass möglichst viele Inhalte öffentlich zugänglich sind. Im Laufe der Entwicklung unseres Netzwerkes wird es mehr und mehr die Möglichkeit geben, über die Sichtbarkeit der selbst angegebenen bzw. persönlichen Daten zu entscheiden. Über diese neuen Funktionen werden wir Euch informieren. Ansonsten gilt, dass Du immer darüber nachdenken solltest, welche persönlichen Daten Du über Dich (oder andere) preisgibst. Dies gilt insbesondere für Inhalte von Beiträgen und Kommentaren, da diese einen weitgehend öffentlichen Charakter haben. Später wird es Möglichkeiten geben, die Sichtbarkeit Deines Profils einzuschränken. Teil der Nutzungsbedingungen ist unsere Datenschutzerklärung, die Dich über die einzelnen Datenverarbeitungen in unserem Netzwerk informiert: <a href=\"https://human-connection.org/datenschutz/#netzwerk\" target=\"_blank\"> https://human-connection.org/datenschutz/#netzwerk</a> bzw. <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\">https://human-connection.org/datenschutz/</a> Unsere Datenschutzerklärung ist an die Gesetzeslage und die Charakteristika unseres Netzwerks angepasst und gilt immer in der aktuellsten Version.",
"description": "Unser Netzwerk ist ein soziales Wissens- und Aktionsnetzwerk. Daher ist es uns besonders wichtig, dass möglichst viele Inhalte öffentlich zugänglich sind. Im Laufe der Entwicklung unseres Netzwerkes wird es mehr und mehr die Möglichkeit geben, über die Sichtbarkeit der selbst angegebenen bzw. persönlichen Daten zu entscheiden. Über diese neuen Funktionen werden wir Euch informieren. Ansonsten gilt, dass Du immer darüber nachdenken solltest, welche persönlichen Daten Du über Dich (oder andere) preisgibst. Dies gilt insbesondere für Inhalte von Beiträgen und Kommentaren, da diese einen weitgehend öffentlichen Charakter haben. Später wird es Möglichkeiten geben, die Sichtbarkeit Deines Profils einzuschränken. Teil der Nutzungsbedingungen ist unsere Datenschutzerklärung, die Dich über die einzelnen Datenverarbeitungen in unserem Netzwerk informiert: <a href=\"https://human-connection.org/datenschutz/#netzwerk\" target=\"_blank\">https://human-connection.org/datenschutz/#netzwerk</a> bzw. <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\">https://human-connection.org/datenschutz/</a>. Unsere Datenschutzerklärung ist an die Gesetzeslage und die Charakteristika unseres Netzwerks angepasst und gilt immer in der aktuellsten Version.",
"title": "Datenschutz"
},
"terms-of-service": {
@ -822,7 +828,7 @@
},
"termsAndConditionsConfirmed": "Ich habe die <a href=\"/terms-and-conditions\" target=\"_blank\">Nutzungsbedingungen</a> durchgelesen und stimme ihnen zu.",
"termsAndConditionsNewConfirm": "Ich habe die neuen Nutzungsbedingungen durchgelesen und stimme zu.",
"termsAndConditionsNewConfirmText": "Bitte lies Dir die neue Nutzungsbedingungen jetzt durch!",
"termsAndConditionsNewConfirmText": "Bitte lies Dir die neuen Nutzungsbedingungen jetzt durch!",
"use-and-license": {
"description": "Sind Inhalte, die Du bei uns einstellst, durch Rechte am geistigen Eigentum geschützt, erteilst Du uns eine nicht-exklusive, übertragbare, unterlizenzierbare und weltweite Lizenz für die Nutzung dieser Inhalte für die Bereitstellung in unserem Netzwerk. Diese Lizenz endet, sobald Du Deine Inhalte oder Deinen ganzen Account löscht. Bedenke, dass andere Deine Inhalte weiter teilen können und wir diese nicht löschen können.",
"title": "Nutzung und Lizenz"
@ -830,7 +836,7 @@
},
"user": {
"avatar": {
"submitted": "Upload erfolgreich"
"submitted": "Erfolgreich hochgeladen!"
}
}
}

View File

@ -217,6 +217,8 @@
}
},
"contribution": {
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected"
},
@ -636,11 +638,11 @@
},
"deleteUserAccount": {
"accountDescription": "Be aware that your Posts and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "You <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!",
"accountWarning": "You CAN'T MANAGE and CAN'T RECOVER your Account, Posts, or Comments after deleting your account!",
"commentedCount": "Delete my {count} comments",
"contributionsCount": "Delete my {count} posts",
"name": "Delete user account",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm",
"pleaseConfirm": "Destructive action! Type “{confirm}” to confirm.",
"success": "Account successfully deleted!"
},
"download": {
@ -812,6 +814,10 @@
"description": "The use of the Human Connection Network is not permitted for commercial purposes. This includes, but is not limited to, advertising products with commercial intent, posting affiliate links, directly soliciting donations, or providing financial support for purposes that are not recognized as charitable for tax purposes.",
"title": "No Commercial Use"
},
"no-parties": {
"description": "User accounts of political parties or official user accounts of a political representative are not permitted.",
"title": "No Political Use"
},
"privacy-statement": {
"description": "Our network is a social knowledge and action network. It is therefore particularly important to us that as much content as possible is publicly accessible. In the course of the development of our network there will be more and more the possibility to decide about the visibility of the personal data. We will inform you about these new features. Otherwise, you should always think about which personal data you disclose about yourself (or others). This applies in particular to the content of posts and comments, as these have a largely public character. Later there will be possibilities to limit the visibility of your profile. Part of the terms of service is our privacy statement, which informs you about the individual data processing operations in our network: <a href=\"https://human-connection.org/datenschutz/#netzwerk\" target=\"_blank\"> https://human-connection.org/datenschutz/#netzwerk</a> bzw. <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\">https://human-connection.org/datenschutz/</a> Our privacy statement is adapted to the legal situation and characteristics of our network and is always valid in the most current version.",
"title": "Privacy Statement"

View File

@ -636,11 +636,11 @@
},
"deleteUserAccount": {
"accountDescription": "Tenga en cuenta que su contribución y sus comentarios son importantes para nuestra comunidad. Si aún decide borrarlos, debe marcarlos a continuación.",
"accountWarning": <b> NO PUEDE GESTIONAR </b> y <b> NO PUEDE RECUPERAR </b> su cuenta, contribuciones o comentarios después de eliminar su cuenta!",
"accountWarning": NO PUEDE GESTIONAR y NO PUEDE RECUPERAR su cuenta, contribuciones o comentarios después de eliminar su cuenta!",
"commentedCount": "Eliminar mis {count} comentarios",
"contributionsCount": "Eliminar mis {count} contribuciones",
"name": "Borrar datos",
"pleaseConfirm": "<b class='is-danger'> ¡Acción destructiva! </b> Escriba <b> {confirm} </b> para confirmar",
"name": "Eliminar cuenta de usuario",
"pleaseConfirm": "¡Acción destructiva! Escriba “{confirm}” para confirmar.",
"success": "¡Cuenta eliminada con éxito!"
},
"download": {
@ -812,6 +812,10 @@
"description": "El uso de la red Human Connection no está permitido para fines comerciales. Esto incluye, pero no se limita a, publicitar productos con intención comercial, publicar enlaces de afiliados, solicitar donaciones directamente o brindar apoyo financiero para fines que no se reconocen como caritativos para fines fiscales.",
"title": "Sin uso comercial"
},
"no-parties": {
"description": "No se permiten las cuentas de usuario de los partidos políticos ni las cuentas de usuario oficiales de un representante político.",
"title": "No tiene uso político"
},
"privacy-statement": {
"description": "Nuestra red es una red de conocimiento y acción social. Por lo tanto, es especialmente importante para nosotros que el mayor número posible de contenidos sea accesible al público. En el curso del desarrollo de nuestra red habrá cada vez más la posibilidad de decidir sobre la visibilidad de los datos personales. Le informaremos sobre estas nuevas características. De lo contrario, siempre debe pensar en los datos personales que revela sobre usted (u otros). Esto se aplica en particular al contenido de los mensajes y comentarios, ya que éstos tienen un carácter ampliamente público. Más tarde habrá posibilidades de limitar la visibilidad de su perfil. Parte de los términos del servicio es nuestra declaración de privacidad, que le informa sobre las operaciones individuales de procesamiento de datos en nuestra red: <a href=\"https://human-connection.org/datenschutz/#netzwerk\" target=\"_blank\">https://human-connection.org/datenschutz/#netzwerk</a> o <a href=\"https://human-connection.org/datenschutz/\" target=\"_blank\">https://human-connection.org/datenschutz/</a> Nuestra declaración de privacidad está adaptada a la situación legal y a las características de nuestra red y es siempre válida en la versión más actualizada.",
"title": "Protección de datos"

View File

@ -152,7 +152,7 @@
"project": "Projet ::: Projets",
"reportContent": "Signaler",
"shout": "Partage ::: Partages",
"tag": "Tag ::: Tags",
"tag": "Tag ::: Tags",
"takeAction": "Passer à l'action",
"user": "Utilisateur ::: Utilisateurs",
"validations": {
@ -622,11 +622,11 @@
},
"deleteUserAccount": {
"accountDescription": "Sachez que vos postes et commentaires sont importants pour notre communauté. Si vous voulez quand même les supprimer, vous devez les marquer ci-dessous.",
"accountWarning": "Vous <b>NE POUVEZ PAS GÉRER</b> et <b>NE POUVEZ PAS RECOUVRIR</b> votre compte, vos messages ou vos commentaires après avoir supprimé votre compte !",
"accountWarning": "Vous NE POUVEZ PAS GÉRER et NE POUVEZ PAS RECOUVRIR votre compte, vos messages ou vos commentaires après avoir supprimé votre compte!",
"commentedCount": "Supprimer mes {count} commentaires",
"contributionsCount": "Supprimer mes {count} postes",
"name": "Effacer les données",
"pleaseConfirm": "<b class='is-danger'> Action destructive! </b> Saisissez <b> {confirm} </b> pour confirmer",
"name": "Supprimer un compte utilisateur",
"pleaseConfirm": "Action destructive! Saisissez “{confirm}” pour confirmer.",
"success": "Compte supprimer avec succès!"
},
"download": {

View File

@ -551,11 +551,11 @@
},
"deleteUserAccount": {
"accountDescription": "Essere consapevoli che i tuoi post e commenti sono importanti per la nostra comunità. Se cancelli il tuo account utente, tutto scomparirà per sempre - e sarebbe un vero peccato!",
"accountWarning": "Attenzione!Tu <b>Non puoi gestire</b> e <b>Non puoi recuperare il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!",
"accountWarning": "Attenzione! Tu NON PUOI GESTIRE e NON PUOI RECUPERARE il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!",
"commentedCount": "Cancella i miei {count} commenti",
"contributionsCount": "Cancellare i miei {count} messaggi",
"name": "Cancellare l'account utente",
"pleaseConfirm": "<b class='is-danger'>Azione distruttiva! </b> Digita <b>{conferma}</b> per confermare",
"pleaseConfirm": "Azione distruttiva! Digita “{confirm}” per confermare.",
"success": "Account eliminato con successo!"
},
"download": {

View File

@ -295,11 +295,11 @@
},
"deleteUserAccount": {
"accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "Po usunięcie Twojego konta, nie możesz <b>ZARZĄDZAĆ</b> ani <b>ODZYSKAĆ</b> danych, wpisów oraz komentarzy!",
"accountWarning": "Po usunięcie Twojego konta, nie możesz ZARZĄDZAĆ ani ODZYSKAĆ danych, wpisów oraz komentarzy!",
"commentedCount": "Usuń {count} moich komentarzy",
"contributionsCount": "Usuń {count} moich postów",
"name": "Usuń dane",
"pleaseConfirm": "<b class='is-danger'>Uwaga, niebezpieczeństwo!</b> Wpisz <b>{confirm}</b>, aby potwierdzić",
"pleaseConfirm": "Uwaga, niebezpieczeństwo! Wpisz „{confirm}”, aby potwierdzić.",
"success": "Konto zostało usunięte"
},
"download": {

View File

@ -557,11 +557,11 @@
},
"deleteUserAccount": {
"accountDescription": "Esteja ciente de que o suas Publicações e Comentários são importantes para a nossa comunidade. Se você ainda optar por excluí-los, você tem que marcá-los abaixo.",
"accountWarning": "Você <b>NÃO PODE GERENCIAR</b> e <b>NÃO PODE RECUPERAR</b> sua conta, Publicações, ou Comentários após excluir sua conta!",
"accountWarning": "Você NÃO PODE GERENCIAR e NÃO PODE RECUPERAR sua conta, Publicações, ou Comentários após excluir sua conta!",
"commentedCount": "Deletar meus {count} comentários",
"contributionsCount": "Deletar minhas {count} publicações",
"name": "Deletar dados",
"pleaseConfirm": "<b class='is-danger'>Ação destrutiva!</b> Digitar <b>{confirm}</b> para confirmar",
"pleaseConfirm": "Ação destrutiva! Digitar “{confirm}” para confirmar.",
"success": "Conta eliminada com sucesso!"
},
"download": {

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