Merging webapp to master

This commit is contained in:
Robert Schäfer 2019-03-20 21:06:17 +01:00
commit c91a61af89
208 changed files with 23860 additions and 0 deletions

24
webapp/.babelrc Normal file
View File

@ -0,0 +1,24 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
],
"env": {
"test": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
]
]
}
}
}

18
webapp/.dockerignore Normal file
View File

@ -0,0 +1,18 @@
.vscode/
styleguide/
node_modules/
npm-debug.log
Dockerfile
docker-compose*.yml
scripts/
.env
cypress/
README.md
screenshot*.png
lokalise.png
.editorconfig

13
webapp/.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

1
webapp/.env.template Normal file
View File

@ -0,0 +1 @@
MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"

5
webapp/.eslintignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
build
.nuxt
styleguide/
**/*.min.js

25
webapp/.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'plugin:vue/recommended',
'plugin:prettier/recommended'
],
// required to lint *.vue files
plugins: [
'vue',
'prettier'
],
// add your custom rules here
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case']
}
}

View File

@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to 'http...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

86
webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,86 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
styleguide/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
#ignore internal github files
/.github
# Serverless directories
.serverless
# IDE
.idea
.vscode
# TEMORIRY
static/uploads
cypress/videos
cypress/screenshots/
cypress.env.json
# Apple macOS folder attribute file
.DS_Store

6
webapp/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"bracketSpacing": true
}

79
webapp/.travis.yml Normal file
View File

@ -0,0 +1,79 @@
language: node_js
node_js:
- "10"
cache:
yarn: true
directories:
- node_modules
services:
- docker
addons:
chrome: stable
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
env:
- DOCKER_COMPOSE_VERSION=1.23.2 BACKEND_BRANCH=${TRAVIS_PULL_REQUEST_BRANCH:-${TRAVIS_BRANCH:-master}}
before_install:
- echo $BACKEND_BRANCH
- sudo rm /usr/local/bin/docker-compose
- curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose
- chmod +x docker-compose
- sudo mv docker-compose /usr/local/bin
- cp cypress.env.template.json cypress.env.json
install:
- docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-web .
- docker-compose -f docker-compose.yml -f docker-compose.travis.yml up -d
- git clone https://github.com/Human-Connection/Nitro-Backend.git ../Nitro-Backend
- git -C "../Nitro-Backend" checkout $BACKEND_BRANCH || git -C "../Nitro-Backend" checkout master
- cd ../Nitro-Backend && yarn install && cd -
- docker-compose -f ../Nitro-Backend/docker-compose.yml -f ../Nitro-Backend/docker-compose.cypress.yml up -d
- yarn global add cypress wait-on
- yarn add cypress-cucumber-preprocessor
script:
- docker-compose exec -e NODE_ENV=test webapp yarn run lint
- docker-compose exec -e NODE_ENV=test webapp yarn run test
- wait-on http://localhost:7474 && docker-compose -f ../Nitro-Backend/docker-compose.yml exec neo4j migrate
- wait-on http://localhost:3000 && cypress run --browser chrome --record --key $CYPRESS_TOKEN
after_success:
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- chmod +x send.sh
- ./send.sh success $WEBHOOK_URL
- if [ $TRAVIS_BRANCH == "master" ] && [ $TRAVIS_EVENT_TYPE == "push" ]; then
wget https://raw.githubusercontent.com/Human-Connection/Discord-Bot/develop/tester.sh &&
chmod +x tester.sh &&
./tester.sh staging $WEBHOOK_URL;
fi
after_failure:
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- chmod +x send.sh
- ./send.sh failure $WEBHOOK_URL
deploy:
- provider: script
script: scripts/docker_push.sh
on:
branch: master
- provider: script
script: scripts/deploy.sh nitro.human-connection.org
on:
branch: master
tags: true
- provider: script
script: scripts/deploy.sh nitro-staging.human-connection.org
on:
branch: master
- provider: script
script: scripts/deploy.sh "nitro-$(git rev-parse --short HEAD).human-connection.org"
on:
tags: true
all_branches: true

46
webapp/CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at developer@human-connection.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

27
webapp/Dockerfile Normal file
View File

@ -0,0 +1,27 @@
FROM node:10-alpine as base
LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)"
EXPOSE 3000
CMD ["yarn", "run", "start"]
# Expose the app port
ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT
ARG WORKDIR=/nitro-web
RUN mkdir -p $WORKDIR
WORKDIR $WORKDIR
# See: https://github.com/nodejs/docker-node/pull/367#issuecomment-430807898
RUN apk --no-cache add git
COPY . .
FROM base as build-and-test
RUN cp .env.template .env
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build
FROM base as production
ENV NODE_ENV=production
COPY --from=build-and-test ./nitro-web/node_modules ./node_modules
COPY --from=build-and-test ./nitro-web/.nuxt ./.nuxt

21
webapp/LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Human-Connection gGmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
webapp/README.md Normal file
View File

@ -0,0 +1,68 @@
<p align="center">
<a href="https://human-connection.org"><img align="center" src="static/img/sign-up/humanconnection.png" height="200" alt="Human Connection" /></a>
</p>
# NITRO Web
[![Build Status](https://img.shields.io/travis/com/Human-Connection/Nitro-Web/master.svg)](https://travis-ci.com/Human-Connection/Nitro-Web)
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Human-Connection/Nitro-Web/blob/master/LICENSE.md)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web?ref=badge_shield)
[![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discord.gg/6ub73U3)
![UI Screenshot](screenshot.png)
## Build Setup
### Install
``` bash
# install all dependencies
$ yarn install
```
Copy:
```
cp .env.template .env
cp cypress.env.template.json cypress.env.json
```
Configure the files according to your needs and your local setup.
### Development
``` bash
# serve with hot reload at localhost:3000
$ yarn dev
```
### Build for production
``` bash
# build for production and launch server
$ yarn build
$ yarn start
```
## Styleguide
All reusable Components (for example avatar) should be done inside the [Nitro-Styleguide](https://github.com/Human-Connection/Nitro-Styleguide) repository.
![Styleguide Screenshot](screenshot-styleguide.png)
More information can be found here: https://github.com/Human-Connection/Nitro-Styleguide
If you need to change something in the styleguide and want to see the effects on the frontend immediately, then we have you covered.
You need to clone the styleguide to the parent directory `../Nitro-Styleguide` and run `yarn && yarn run dev`. After that you run `yarn run dev:styleguide` instead of `yarn run dev` and you will see your changes reflected inside the fronten!
## Internationalization (i18n)
You can help translating the interface by joining us on [lokalise.co](https://lokalise.co/public/556252725c18dd752dd546.13222042/).
Thanks lokalise.co that we can use your premium account!
<a href="(https://lokalise.co/public/556252725c18dd752dd546.13222042/)."><img src="lokalise.png" alt="localise.co" height="32px" /></a>
## Attributions
<div>Locale Icons made by <a href="http://www.freepik.com/" title="Freepik">Freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a> is licensed by <a href="http://creativecommons.org/licenses/by/3.0/" title="Creative Commons BY 3.0" target="_blank">CC 3.0 BY</a></div>
## License
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web?ref=badge_large)

7
webapp/assets/README.md Normal file
View File

@ -0,0 +1,7 @@
# ASSETS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your un-compiled assets such as LESS, SASS, or JavaScript.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).

View File

@ -0,0 +1,32 @@
.iziToast-target, .iziToast {
&,
&:after,
&.iziToast-color-dark:after {
box-shadow: none !important;
}
}
.iziToast .iziToast-message {
font-weight: 400 !important;
}
.iziToast.iziToast-color-red {
background: $color-danger !important;
border-color: $color-danger !important;
}
.iziToast.iziToast-color-orange {
background: $color-warning !important;
border-color: $color-warning !important;
}
.iziToast.iziToast-color-yellow {
background: $color-yellow !important;
border-color: $color-yellow !important;
}
.iziToast.iziToast-color-blue {
background: $color-secondary !important;
border-color: $color-secondary !important;
}
.iziToast.iziToast-color-green {
background: $color-success !important;
border-color: $color-success !important;
}

View File

@ -0,0 +1,127 @@
@mixin arrow($size, $type, $color) {
--#{$type}-arrow-size: $size;
.#{$type}-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: $size;
border-color: $color;
z-index: 1;
}
&[x-placement^="top"] {
margin-bottom: $size;
.#{$type}-arrow {
border-width: $size $size 0 $size;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -$size;
left: calc(50% - var(--#{$type}-arrow-size));
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: $size;
.#{$type}-arrow {
border-width: 0 $size $size $size;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -$size;
left: calc(50% - var(--#{$type}-arrow-size));
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
margin-left: $size;
.#{$type}-arrow {
border-width: $size $size $size 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -$size;
top: calc(50% - var(--#{$type}-arrow-size));
margin-left: 0;
margin-right: 0;
}
}
&[x-placement^="left"] {
margin-right: $size;
.#{$type}-arrow {
border-width: $size 0 $size $size;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -$size;
top: calc(50% - var(--#{$type}-arrow-size));
margin-left: 0;
margin-right: 0;
}
}
}
.tooltip {
display: block !important;
z-index: $z-index-modal - 2;
.tooltip-inner {
background: $background-color-inverse-soft;
color: $text-color-inverse;
border-radius: $border-radius-base;
padding: $space-x-small $space-small;
box-shadow: $box-shadow-large;
}
@include arrow(5px, "tooltip", $background-color-inverse-soft);
&.popover {
.popover-inner {
background: $background-color-soft;
color: $text-color-base;
border-radius: $border-radius-base;
padding: $space-x-small $space-small;
box-shadow: $box-shadow-x-large;
nav {
margin-left: -$space-small;
margin-right: -$space-small;
a {
padding-left: 12px;
}
}
}
.popover-arrow {
border-color: $background-color-soft;
}
@include arrow(7px, "popover", $background-color-soft);
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
transition: opacity 60ms;
}
&[aria-hidden='false'] {
visibility: visible;
opacity: 1;
transition: opacity 60ms;
}
}

View File

@ -0,0 +1,164 @@
@import './imports/_tooltip.scss';
@import './imports/_toast.scss';
// Transition Easing
$easeOut: cubic-bezier(0.19, 1, 0.22, 1);
.disabled-content {
position: relative;
&::before {
@include border-radius($border-radius-x-large);
box-shadow: inset 0 0 0 5px $color-danger;
content: "";
display: block;
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
}
.layout-enter-active {
transition: opacity 80ms ease-out;
transition-delay: 80ms;
}
.layout-leave-active {
transition: opacity 80ms ease-in;
}
.layout-enter,
.layout-leave-active {
opacity: 0;
}
// slide up ease
.slide-up-enter-active {
transition: all 500ms $easeOut;
transition-delay: 20ms;
opacity: 1;
transform: translate3d(0, 0, 0);
}
.slide-up-enter,
.slide-up-leave-active {
opacity: 0;
box-shadow: none;
transform: translate3d(0, 15px, 0);
}
.main-navigation {
background: #fff;
}
blockquote {
display: block;
padding: 15px 20px 15px 45px;
margin: 0 0 20px;
position: relative;
/*Font*/
font-size: $font-size-base;
line-height: 1.2;
color: $color-neutral-40;
font-family: $font-family-serif;
font-style: italic;
border-left: 3px dotted $color-neutral-70;
&::before {
content: '\201C'; /*Unicode for Left Double Quote*/
/*Font*/
font-size: $font-size-xxxx-large;
font-weight: bold;
color: $color-neutral-50;
/*Positioning*/
position: absolute;
left: 10px;
top: 5px;
}
p {
margin-top: 0;
}
}
.main-navigation {
box-shadow: $box-shadow-base;
position: fixed;
width: 100%;
z-index: 10;
a {
outline: none;
}
}
hr {
border: 0;
width: 100%;
color: $color-neutral-80;
background-color: $color-neutral-80;
height: 1px !important;
}
[class$=menu-trigger] {
user-select: none;
}
[class$=menu-popover] {
display: inline-block;
nav {
margin-left: -17px;
margin-right: -15px;
}
}
#overlay {
display: block;
opacity: 0;
width: 100%;
height: 100%;
top: 0;
left: 0;
position: fixed;
background: rgba(0, 0, 0, 0.15);
z-index: 99;
pointer-events: none;
transition: opacity 150ms ease-out;
transition-delay: 50ms;
.dropdown-open & {
opacity: 1;
transition-delay: 0;
transition: opacity 80ms ease-out;
}
}
.ds-card .ds-section {
padding: 0;
margin-left: -$space-base;
margin-right: -$space-base;
.ds-container {
padding: $space-base;
}
}
[class$="menu-popover"] {
min-width: 130px;
a, button {
display: flex;
align-content: center;
align-items: center;
.ds-icon {
padding-right: $space-xx-small;
}
}
}
.v-popover.open .trigger a {
color: $text-color-link-active;
}

View File

@ -0,0 +1,21 @@
import { shallowMount } from '@vue/test-utils'
import Badges from './Badges.vue'
describe('Badges.vue', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(Badges, {})
})
it('renders', () => {
expect(wrapper.is('div')).toBe(true)
})
it('has class "hc-badges"', () => {
expect(wrapper.contains('.hc-badges')).toBe(true)
})
// TODO: add similar software tests for other components
// TODO: add more test cases in this file
})

View File

@ -0,0 +1,81 @@
<template>
<div
:class="[
(badges.length === 2) && 'hc-badges-dual'
]"
class="hc-badges"
>
<div
v-for="badge in badges"
:key="badge.key"
class="hc-badge-container"
>
<img
:title="badge.key"
:src="badge.icon"
class="hc-badge"
>
</div>
</div>
</template>
<script>
export default {
props: {
badges: {
type: Array,
default: () => []
}
}
}
</script>
<style lang="scss">
.hc-badges {
text-align: center;
position: relative;
.hc-badge-container {
display: inline-block;
position: unset;
overflow: hidden;
vertical-align: middle;
}
.hc-badge {
display: block;
width: 100%;
}
$size: 30px;
$offset: $size * -0.2;
&.hc-badges-dual {
padding-top: $size / 2 - 2;
}
.hc-badge-container {
width: $size;
height: 26px;
margin-left: -1px;
&:nth-child(3n - 1) {
margin-top: -$size - $offset - 3;
margin-left: -$size * 0.33 - $offset - 2;
}
&:nth-child(3n + 0) {
margin-top: $size + $offset + 3;
margin-left: -$size;
}
&:nth-child(3n + 1) {
margin-left: -6px;
}
&:first-child {
margin-left: -$size / 3;
}
&:last-child {
margin-right: -$size / 3;
}
}
}
</style>

View File

@ -0,0 +1,95 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import Comment from './Comment.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
config.stubs['no-ssr'] = '<span><slot /></span>'
describe('Comment.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
let getters
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn()
}
getters = {
'auth/user': () => {
return {}
},
'auth/isModerator': () => false
}
})
describe('shallowMount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters
})
return shallowMount(Comment, { store, propsData, mocks, localVue })
}
describe('given a comment', () => {
beforeEach(() => {
propsData.comment = {
id: '2',
contentExcerpt: 'Hello I am a comment content'
}
})
it('renders content', () => {
const wrapper = Wrapper()
expect(wrapper.text()).toMatch('Hello I am a comment content')
})
describe('which is disabled', () => {
beforeEach(() => {
propsData.comment.disabled = true
})
it('renders no comment data', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('comment content')
})
it('has no "disabled-content" css class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('disabled-content')
})
it('translates a placeholder', () => {
const wrapper = Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['comment.content.unavailable-placeholder']]
expect(calls).toEqual(expect.arrayContaining(expected))
})
describe('for a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders comment data', () => {
const wrapper = Wrapper()
expect(wrapper.text()).toMatch('comment content')
})
it('has a "disabled-content" css class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
})
})
})
})
})

View File

@ -0,0 +1,76 @@
<template>
<div v-if="(comment.deleted || comment.disabled) && !isModerator">
<ds-text
style="padding-left: 40px; font-weight: bold;"
color="soft"
>
<ds-icon name="ban" /> {{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
</div>
<div
v-else
:class="{'comment': true, 'disabled-content': (comment.deleted || comment.disabled)}"
>
<ds-space
margin-bottom="x-small"
>
<hc-user :user="author" />
</ds-space>
<no-ssr>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<ds-space margin-bottom="small" />
<div
style="padding-left: 40px;"
v-html="comment.contentExcerpt"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import HcUser from '~/components/User.vue'
import ContentMenu from '~/components/ContentMenu'
export default {
components: {
HcUser,
ContentMenu
},
props: {
comment: {
type: Object,
default() {
return {}
}
}
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator'
}),
displaysComment() {
return !this.unavailable || this.isModerator
},
author() {
if (this.deleted) return {}
return this.comment.author || {}
}
},
methods: {
isAuthor(id) {
return this.user.id === id
}
}
}
</script>

View File

@ -0,0 +1,156 @@
<template>
<dropdown
class="content-menu"
:placement="placement"
offset="5"
>
<template
slot="default"
slot-scope="{toggleMenu}"
>
<slot
name="button"
:toggleMenu="toggleMenu"
>
<ds-button
class="content-menu-trigger"
size="small"
ghost
@click.prevent="toggleMenu"
>
<ds-icon name="ellipsis-v" />
</ds-button>
</slot>
</template>
<div
slot="popover"
slot-scope="{toggleMenu}"
class="content-menu-popover"
>
<ds-menu :routes="routes">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</div>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
export default {
components: {
Dropdown
},
props: {
placement: { type: String, default: 'top-end' },
resource: { type: Object, required: true },
isOwner: { type: Boolean, default: false },
resourceType: {
type: String,
required: true,
validator: value => {
return value.match(/(contribution|comment|organization|user)/)
}
}
},
computed: {
routes() {
let routes = []
if (this.isOwner && this.resourceType === 'contribution') {
routes.push({
name: this.$t(`contribution.edit`),
path: this.$router.resolve({
name: 'post-edit-id',
params: {
id: this.resource.id
}
}).href,
icon: 'edit'
})
}
if (this.isOwner && this.resourceType === 'comment') {
routes.push({
name: this.$t(`comment.edit`),
callback: () => {
console.log('EDIT COMMENT')
},
icon: 'edit'
})
}
if (!this.isOwner) {
routes.push({
name: this.$t(`report.${this.resourceType}.title`),
callback: () => {
this.openModal('report')
},
icon: 'flag'
})
}
if (!this.isOwner && this.isModerator) {
routes.push({
name: this.$t(`disable.${this.resourceType}.title`),
callback: () => {
this.openModal('disable')
},
icon: 'eye-slash'
})
}
if (this.isOwner && this.resourceType === 'user') {
routes.push({
name: this.$t(`settings.data.name`),
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
callback: () => this.$router.push('/settings'),
icon: 'edit'
})
}
return routes
},
isModerator() {
return this.$store.getters['auth/isModerator']
}
},
methods: {
openItem(route, toggleMenu) {
if (route.callback) {
route.callback()
} else {
this.$router.push(route.path)
}
toggleMenu()
},
openModal(dialog) {
this.$store.commit('modal/SET_OPEN', {
name: dialog,
data: {
type: this.resourceType,
resource: this.resource
}
})
}
}
}
</script>
<style lang="scss">
.content-menu-popover {
nav {
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
margin-left: -$space-x-small;
margin-right: -$space-x-small;
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<ds-form
ref="contributionForm"
v-model="form"
:schema="formSchema"
@submit="submit"
>
<template slot-scope="{ errors }">
<ds-card>
<ds-input
model="title"
class="post-title"
placeholder="Title"
name="title"
autofocus
/>
<no-ssr>
<hc-editor
:value="form.content"
@input="updateEditorContent"
/>
</no-ssr>
<div
slot="footer"
style="text-align: right"
>
<ds-button
:disabled="loading || disabled"
ghost
@click.prevent="$router.back()"
>
{{ $t('actions.cancel') }}
</ds-button>
<ds-button
icon="check"
type="submit"
:loading="loading"
:disabled="disabled || errors"
primary
>
{{ $t('actions.save') }}
</ds-button>
</div>
</ds-card>
</template>
</ds-form>
</template>
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor.vue'
export default {
components: {
HcEditor
},
props: {
contribution: { type: Object, default: () => {} }
},
data() {
return {
form: {
title: '',
content: ''
},
formSchema: {
title: { required: true, min: 3, max: 64 },
content: { required: true, min: 3 }
},
id: null,
loading: false,
disabled: false,
slug: null
}
},
watch: {
contribution: {
immediate: true,
handler: function(contribution) {
if (!contribution || !contribution.id) {
return
}
this.id = contribution.id
this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title
}
}
},
methods: {
submit() {
const postMutations = require('~/graphql/PostMutations.js').default(this)
this.loading = true
this.$apollo
.mutate({
mutation: this.id
? postMutations.UpdatePost
: postMutations.CreatePost,
variables: {
id: this.id,
title: this.form.title,
content: this.form.content
}
})
.then(res => {
this.loading = false
this.$toast.success('Saved!')
this.disabled = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
name: 'post-slug',
params: { slug: result.slug }
})
})
.catch(err => {
this.$toast.error(err.message)
this.loading = false
this.disabled = false
})
},
updateEditorContent(value) {
// this.form.content = value
this.$refs.contributionForm.update('content', value)
}
}
}
</script>
<style lang="scss">
.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;
}
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<span>
<no-ssr
placeholder="0"
tag="span"
>
<count-to
:start-val="lastEndVal || startVal"
:end-val="endVal"
:duration="duration"
:autoplay="autoplay"
:separator="separator"
/>
</no-ssr>
</span>
</template>
<script>
import CountTo from 'vue-count-to'
export default {
components: {
CountTo
},
props: {
startVal: { type: Number, default: 0 },
endVal: { type: Number, required: true },
duration: { type: Number, default: 3000 },
autoplay: { type: Boolean, default: true },
separator: { type: String, default: '.' }
},
data() {
return {
lastEndVal: null,
isReady: false
}
},
watch: {
endVal(endVal) {
if (this.isReady && this.startVal === 0 && !this.lastEndVal) {
this.lastEndVal = this.endVal
}
}
},
mounted() {
setTimeout(() => {
this.isReady = true
}, 500)
}
}
</script>

View File

@ -0,0 +1,134 @@
<template>
<v-popover
:open.sync="isPopoverOpen"
:open-group="Math.random().toString()"
:placement="placement"
:disabled="disabled"
trigger="manual"
:offset="offset"
>
<slot
:toggleMenu="toggleMenu"
:openMenu="openMenu"
:closeMenu="closeMenu"
:isOpen="isOpen"
/>
<div
slot="popover"
@mouseover="popoverMouseEnter"
@mouseleave="popoveMouseLeave"
>
<slot
name="popover"
:toggleMenu="toggleMenu"
:openMenu="openMenu"
:closeMenu="closeMenu"
:isOpen="isOpen"
/>
</div>
</v-popover>
</template>
<script>
import { mapGetters } from 'vuex'
let mouseEnterTimer = null
let mouseLeaveTimer = null
export default {
props: {
placement: { type: String, default: 'bottom-end' },
disabled: { type: Boolean, default: false },
offset: { type: [String, Number], default: '16' }
},
data() {
return {
isPopoverOpen: false
}
},
computed: {
isOpen() {
return this.isPopoverOpen
}
},
watch: {
isPopoverOpen: {
immediate: true,
handler(isOpen) {
try {
if (isOpen) {
this.$nextTick(() => {
setTimeout(() => {
document
.getElementsByTagName('body')[0]
.classList.add('dropdown-open')
}, 20)
})
} else {
document
.getElementsByTagName('body')[0]
.classList.remove('dropdown-open')
}
} catch (err) {}
}
}
},
beforeDestroy() {
clearTimeout(mouseEnterTimer)
clearTimeout(mouseLeaveTimer)
},
methods: {
toggleMenu() {
this.isPopoverOpen ? this.closeMenu(false) : this.openMenu(false)
},
openMenu(useTimeout) {
if (this.disabled) {
return
}
this.clearTimeouts()
if (useTimeout === true) {
this.popoverMouseEnter()
} else {
this.isPopoverOpen = true
}
},
closeMenu(useTimeout) {
if (this.disabled) {
return
}
this.clearTimeouts()
if (useTimeout === true) {
this.popoveMouseLeave()
} else {
this.isPopoverOpen = false
}
},
popoverMouseEnter() {
if (this.disabled) {
return
}
this.clearTimeouts()
if (!this.isPopoverOpen) {
mouseEnterTimer = setTimeout(() => {
this.isPopoverOpen = true
}, 500)
}
},
popoveMouseLeave() {
if (this.disabled) {
return
}
this.clearTimeouts()
if (this.isPopoverOpen) {
mouseLeaveTimer = setTimeout(() => {
this.isPopoverOpen = false
}, 300)
}
},
clearTimeouts() {
clearTimeout(mouseEnterTimer)
clearTimeout(mouseLeaveTimer)
}
}
}
</script>

View File

@ -0,0 +1,366 @@
<template>
<div class="editor">
<editor-menu-bubble :editor="editor">
<div
ref="menu"
slot-scope="{ commands, getMarkAttrs, isActive, menu }"
class="menububble tooltip"
x-placement="top"
:class="{ 'is-active': menu.isActive || linkMenuIsActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<div class="tooltip-wrapper">
<template v-if="linkMenuIsActive">
<ds-input
ref="linkInput"
v-model="linkUrl"
class="editor-menu-link-input"
placeholder="http://"
@blur.native.capture="hideMenu(menu.isActive)"
@keydown.native.esc.prevent="hideMenu(menu.isActive)"
@keydown.native.enter.prevent="setLinkUrl(commands.link, linkUrl)"
/>
</template>
<template v-else>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.bold()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.bold"
>
<ds-icon name="bold" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.italic()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.italic"
>
<ds-icon name="italic" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.link()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="showLinkMenu(getMarkAttrs('link'))"
>
<ds-icon name="link" />
</ds-button>
</template>
</div>
<div class="tooltip-arrow" />
</div>
</editor-menu-bubble>
<editor-floating-menu :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="editor__floating-menu"
:class="{ 'is-active': menu.isActive }"
:style="`top: ${menu.top}px`"
>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.paragraph()"
@click.prevent="commands.paragraph()"
>
<ds-icon name="paragraph" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 3 })"
@click.prevent="commands.heading({ level: 3 })"
>
H3
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 4 })"
@click.prevent="commands.heading({ level: 4 })"
>
H4
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.bullet_list()"
@click.prevent="commands.bullet_list()"
>
<ds-icon name="list-ul" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.ordered_list()"
@click.prevent="commands.ordered_list()"
>
<ds-icon name="list-ol" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.blockquote()"
@click.prevent="commands.blockquote"
>
<ds-icon name="quote-right" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.horizontal_rule()"
@click.prevent="commands.horizontal_rule"
>
<ds-icon name="minus" />
</ds-button>
</div>
</editor-floating-menu>
<editor-content :editor="editor" />
</div>
</template>
<script>
import linkify from 'linkify-it'
import stringHash from 'string-hash'
import {
Editor,
EditorContent,
EditorFloatingMenu,
EditorMenuBubble
} from 'tiptap'
import EventHandler from './plugins/eventHandler.js'
import {
Heading,
HardBreak,
Blockquote,
ListItem,
BulletList,
OrderedList,
HorizontalRule,
Placeholder,
Bold,
Italic,
Strike,
Underline,
Link,
History
} from 'tiptap-extensions'
let throttleInputEvent
export default {
components: {
EditorContent,
EditorFloatingMenu,
EditorMenuBubble
},
props: {
value: { type: String, default: '' },
doc: { type: Object, default: () => {} }
},
data() {
return {
lastValueHash: null,
editor: new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
new EventHandler(),
new Heading(),
new HardBreak(),
new Blockquote(),
new BulletList(),
new OrderedList(),
new HorizontalRule(),
new Bold(),
new Italic(),
new Strike(),
new Underline(),
new Link(),
new Heading({ levels: [3, 4] }),
new ListItem(),
new Placeholder({
emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…'
}),
new History()
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
}
}),
linkUrl: null,
linkMenuIsActive: false
}
},
watch: {
value: {
immediate: true,
handler: function(content, old) {
const contentHash = stringHash(content)
if (!content || contentHash === this.lastValueHash) {
return
}
this.lastValueHash = contentHash
this.editor.setContent(content)
}
}
},
beforeDestroy() {
this.editor.destroy()
},
methods: {
onUpdate(e) {
const content = e.getHTML()
const contentHash = stringHash(content)
if (contentHash !== this.lastValueHash) {
this.lastValueHash = contentHash
this.$emit('input', content)
}
},
showLinkMenu(attrs) {
this.linkUrl = attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
try {
const $el = this.$refs.linkInput.$el.getElementsByTagName('input')[0]
$el.focus()
$el.select()
} catch (err) {}
})
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
this.editor.focus()
},
hideMenu(isActive) {
isActive = false
this.hideLinkMenu()
},
setLinkUrl(command, url) {
const links = linkify().match(url)
if (links) {
// add valid link
command({
href: links.pop().url
})
this.hideLinkMenu()
this.editor.focus()
} else if (!url) {
// remove link
command({ href: null })
}
}
}
}
</script>
<style lang="scss">
.ProseMirror {
padding: $space-base;
margin: -$space-base;
min-height: $space-large;
}
.ProseMirror:focus {
outline: none;
}
.editor p.is-empty:first-child::before {
content: attr(data-empty-text);
float: left;
color: $text-color-disabled;
padding-left: $space-xx-small;
pointer-events: none;
height: 0;
}
.menubar__button {
font-weight: normal;
}
li > p {
margin-top: $space-xx-small;
margin-bottom: $space-xx-small;
}
.editor {
&__floating-menu {
position: absolute;
margin-top: -0.25rem;
margin-left: $space-xx-small;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
background-color: #fff;
&.is-active {
opacity: 1;
visibility: visible;
}
}
.menububble {
position: absolute;
// margin-top: -0.5rem;
visibility: hidden;
opacity: 0;
transition: opacity 200ms, visibility 200ms;
// transition-delay: 50ms;
transform: translate(-50%, -10%);
background-color: $background-color-inverse-soft;
// color: $text-color-inverse;
border-radius: $border-radius-base;
padding: $space-xx-small;
box-shadow: $box-shadow-large;
.ds-button {
color: $text-color-inverse;
&.ds-button-hover,
&:hover {
color: $text-color-base;
}
}
&.is-active {
opacity: 1;
visibility: visible;
}
.tooltip-arrow {
left: calc(50% - 10px);
}
input,
button {
border: none;
border-radius: 2px;
}
.ds-input {
height: auto;
}
input {
padding: $space-xx-small $space-x-small;
}
}
}
</style>

View File

@ -0,0 +1,83 @@
import { Extension, Plugin } from 'tiptap'
// import { Slice, Fragment } from 'prosemirror-model'
export default class EventHandler extends Extension {
get name() {
return 'event_handler'
}
get plugins() {
return [
new Plugin({
props: {
transformPastedText(text) {
// console.log('#### transformPastedText', text)
return text.trim()
},
transformPastedHTML(html) {
html = html
// remove all tags with "space only"
.replace(/<[a-z-]+>[\s]+<\/[a-z-]+>/gim, '')
// remove all iframes
.replace(
/(<iframe(?!.*?src=(['"]).*?\2)[^>]*)(>)[^>]*\/*>/gim,
''
)
.replace(/[\n]{3,}/gim, '\n\n')
.replace(/(\r\n|\n\r|\r|\n)/g, '<br>$1')
// replace all p tags with line breaks (and spaces) only by single linebreaks
// limit linebreaks to max 2 (equivalent to html "br" linebreak)
.replace(/(<br ?\/?>\s*){2,}/gim, '<br>')
// remove additional linebreaks after p tags
.replace(
/<\/(p|div|th|tr)>\s*(<br ?\/?>\s*)+\s*<(p|div|th|tr)>/gim,
'</p><p>'
)
// remove additional linebreaks inside p tags
.replace(
/<[a-z-]+>(<[a-z-]+>)*\s*(<br ?\/?>\s*)+\s*(<\/[a-z-]+>)*<\/[a-z-]+>/gim,
''
)
// remove additional linebreaks when first child inside p tags
.replace(/<p>(\s*<br ?\/?>\s*)+/gim, '<p>')
// remove additional linebreaks when last child inside p tags
.replace(/(\s*<br ?\/?>\s*)+<\/p>/gim, '</p>')
// console.log('#### transformPastedHTML', html)
return html
}
// transformPasted(slice) {
// // console.log('#### transformPasted', slice.content)
// let content = []
// let size = 0
// slice.content.forEach((node, offset, index) => {
// // console.log(node)
// // console.log('isBlock', node.type.isBlock)
// // console.log('childCount', node.content.childCount)
// // console.log('index', index)
// if (node.content.childCount) {
// content.push(node.content)
// size += node.content.size
// }
// })
// console.log(content)
// console.log(slice.content)
// let fragment = Fragment.fromArray(content)
// fragment.size = size
// console.log('#fragment', fragment, slice.content)
// console.log('----')
// console.log('#1', slice)
// // const newSlice = new Slice(fragment, slice.openStart, slice.openEnd)
// slice.fragment = fragment
// // slice.content.content = fragment.content
// // slice.content.size = fragment.size
// console.log('#2', slice)
// // console.log(newSlice)
// console.log('----')
// return slice
// // return newSlice
// }
}
})
]
}
}

View File

@ -0,0 +1,68 @@
<template>
<ds-space
class="hc-empty"
centered
:margin="margin"
>
<ds-text>
<img
:src="iconPath"
width="80"
class="hc-empty-icon"
style="margin-bottom: 5px"
alt="Empty"
><br>
<ds-text
v-show="message"
class="hc-empty-message"
color="softer"
>
{{ message }}
</ds-text>
</ds-text>
</ds-space>
</template>
<script>
export default {
name: 'HcEmpty',
props: {
/**
* Icon that should be shown
* @options messages|events|alert|tasks|docs|file
*/
icon: {
type: String,
required: true,
validator: value => {
return value.match(/(messages|events|alert|tasks|docs|file)/)
}
},
/**
* Message that appears under the icon
*/
message: {
type: String,
default: null
},
/**
* Vertical spacing
*/
margin: {
type: [String, Object],
default: 'x-large'
}
},
computed: {
iconPath() {
return `/img/empty/${this.icon}.svg`
}
}
}
</script>
<style lang="scss">
.hc-empty-icon {
padding-bottom: $font-space-large;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<ds-button
:disabled="disabled || !followId"
:loading="loading"
:icon="icon"
:primary="isFollowed && !hovered"
:danger="isFollowed && hovered"
fullwidth
@mouseenter.native="onHover"
@mouseleave.native="hovered = false"
@click.prevent="toggle"
>
{{ label }}
</ds-button>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'HcFollowButton',
props: {
followId: { type: String, default: null },
isFollowed: { type: Boolean, default: false }
},
data() {
return {
disabled: false,
loading: false,
hovered: false
}
},
computed: {
icon() {
if (this.isFollowed && this.hovered) {
return 'close'
} else {
return this.isFollowed ? 'check' : 'plus'
}
},
label() {
if (this.isFollowed) {
return this.$t('followButton.following')
} else {
return this.$t('followButton.follow')
}
}
},
watch: {
isFollowed() {
this.loading = false
this.hovered = false
}
},
methods: {
onHover() {
if (!this.disabled && !this.loading) {
this.hovered = true
}
},
toggle() {
const follow = !this.isFollowed
const mutation = follow ? 'follow' : 'unfollow'
this.hovered = false
this.$emit('optimistic', follow)
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!) {
${mutation}(id: $id, type: User)
}
`,
variables: {
id: this.followId
}
})
.then(res => {
// this.$emit('optimistic', follow ? res.data.follow : follow)
this.$emit('update', follow)
})
.catch(() => {
this.$emit('optimistic', !follow)
})
}
}
}
</script>

View File

@ -0,0 +1,23 @@
<template>
<ds-space
margin-top="large"
style="text-align: center"
>
<ds-button
:loading="loading"
icon="arrow-down"
ghost
@click="$emit('click')"
>
{{ $t('actions.loadMore') }}
</ds-button>
</ds-space>
</template>
<script>
export default {
props: {
loading: { type: Boolean, default: false }
}
}
</script>

View File

@ -0,0 +1,108 @@
<template>
<dropdown
ref="menu"
:placement="placement"
:offset="offset"
>
<a
slot="default"
slot-scope="{toggleMenu}"
class="locale-menu"
href="#"
@click.prevent="toggleMenu()"
>
<ds-icon
style="margin-right: 2px;"
name="globe"
/> {{ current.code.toUpperCase() }}
<ds-icon
style="margin-left: 2px"
size="xx-small"
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>
</template>
<script>
import Dropdown from '~/components/Dropdown'
import find from 'lodash/find'
import orderBy from 'lodash/orderBy'
export default {
components: {
Dropdown
},
props: {
placement: { type: String, default: 'bottom-start' },
offset: { type: [String, Number], default: '16' }
},
data() {
return {
locales: orderBy(process.env.locales, 'name')
}
},
computed: {
current() {
return find(this.locales, { code: this.$i18n.locale() })
},
routes() {
let routes = this.locales.map(locale => {
return {
name: locale.name,
path: locale.code
}
})
return routes
}
},
methods: {
changeLanguage(locale, toggleMenu) {
this.$i18n.set(locale)
toggleMenu()
},
matcher(locale) {
return locale === this.$i18n.locale()
}
}
}
</script>
<style lang="scss">
.locale-menu {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $text-color-soft;
}
nav.locale-menu-popover {
margin-left: -$space-small !important;
margin-right: -$space-small !important;
a {
padding: $space-x-small $space-small;
padding-right: $space-base;
}
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="VueToNuxtLogo">
<div class="Triangle Triangle--two" />
<div class="Triangle Triangle--one" />
<div class="Triangle Triangle--three" />
<div class="Triangle Triangle--four" />
</div>
</template>
<style>
.VueToNuxtLogo {
display: inline-block;
animation: turn 2s linear forwards 1s;
transform: rotateX(180deg);
position: relative;
overflow: hidden;
height: 180px;
width: 245px;
}
.Triangle {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
}
.Triangle--one {
border-left: 105px solid transparent;
border-right: 105px solid transparent;
border-bottom: 180px solid #41b883;
}
.Triangle--two {
top: 30px;
left: 35px;
animation: goright 0.5s linear forwards 3.5s;
border-left: 87.5px solid transparent;
border-right: 87.5px solid transparent;
border-bottom: 150px solid #3b8070;
}
.Triangle--three {
top: 60px;
left: 35px;
animation: goright 0.5s linear forwards 3.5s;
border-left: 70px solid transparent;
border-right: 70px solid transparent;
border-bottom: 120px solid #35495e;
}
.Triangle--four {
top: 120px;
left: 70px;
animation: godown 0.5s linear forwards 3s;
border-left: 35px solid transparent;
border-right: 35px solid transparent;
border-bottom: 60px solid #fff;
}
@keyframes turn {
100% {
transform: rotateX(0deg);
}
}
@keyframes godown {
100% {
top: 180px;
}
}
@keyframes goright {
100% {
left: 70px;
}
}
</style>

View File

@ -0,0 +1,124 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import Modal from './Modal.vue'
import DisableModal from './Modal/DisableModal.vue'
import ReportModal from './Modal/ReportModal.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import { getters, mutations } from '../store/modal'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('Modal.vue', () => {
let Wrapper
let wrapper
let store
let state
let mocks
const createWrapper = mountMethod => {
return () => {
store = new Vuex.Store({
state,
getters: {
'modal/open': getters.open,
'modal/data': getters.data
},
mutations: {
'modal/SET_OPEN': mutations.SET_OPEN
}
})
return mountMethod(Modal, { store, mocks, localVue })
}
}
beforeEach(() => {
mocks = {
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$t: () => {}
}
state = {
open: null,
data: {}
}
})
describe('shallowMount', () => {
const Wrapper = createWrapper(shallowMount)
it('initially empty', () => {
wrapper = Wrapper()
expect(wrapper.contains(DisableModal)).toBe(false)
expect(wrapper.contains(ReportModal)).toBe(false)
})
describe('store/modal holds data to disable', () => {
beforeEach(() => {
state = {
open: 'disable',
data: {
type: 'contribution',
resource: {
id: 'c456',
title: 'some title'
}
}
}
wrapper = Wrapper()
})
it('renders disable modal', () => {
expect(wrapper.contains(DisableModal)).toBe(true)
})
it('passes data to disable modal', () => {
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'contribution',
name: 'some title',
id: 'c456'
})
})
describe('child component emits close', () => {
it('turns empty', () => {
wrapper.find(DisableModal).vm.$emit('close')
expect(wrapper.contains(DisableModal)).toBe(false)
})
})
describe('store/modal data contains a comment', () => {
it('passes author name to disable modal', () => {
state.data = {
type: 'comment',
resource: { id: 'c456', author: { name: 'Author name' } }
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: 'Author name',
id: 'c456'
})
})
it('does not crash if author is undefined', () => {
state.data = { type: 'comment', resource: { id: 'c456' } }
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: '',
id: 'c456'
})
})
})
})
})
})

View File

@ -0,0 +1,59 @@
<template>
<div class="modal-wrapper">
<disable-modal
v-if="open === 'disable'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
<report-modal
v-if="open === 'report'"
:id="data.resource.id"
:type="data.type"
:name="name"
@close="close"
/>
</div>
</template>
<script>
import DisableModal from '~/components/Modal/DisableModal'
import ReportModal from '~/components/Modal/ReportModal'
import { mapGetters } from 'vuex'
export default {
name: 'Modal',
components: {
DisableModal,
ReportModal
},
computed: {
...mapGetters({
data: 'modal/data',
open: 'modal/open'
}),
name() {
if (!this.data || !this.data.resource) return ''
const {
resource: { name, title, author }
} = this.data
switch (this.data.type) {
case 'user':
return name
case 'contribution':
return title
case 'comment':
return author && author.name
default:
return null
}
}
},
methods: {
close() {
this.$store.commit('modal/SET_OPEN', {})
}
}
}
</script>

View File

@ -0,0 +1,150 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import DisableModal from './DisableModal.vue'
import Vue from 'vue'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('DisableModal.vue', () => {
let store
let mocks
let propsData
let wrapper
beforeEach(() => {
propsData = {
type: 'contribution',
name: 'blah',
id: 'c42'
}
mocks = {
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$t: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue()
}
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DisableModal, { propsData, mocks, localVue })
}
describe('given a user', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u2',
name: 'Bob Ross'
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['disable.user.message', { name: 'Bob Ross' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a contribution', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c3',
name: 'This is some post title.'
}
})
it('mentions contribution title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [
['disable.contribution.message', { name: 'This is some post title.' }]
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(DisableModal, { propsData, mocks, localVue })
}
beforeEach(jest.useFakeTimers)
describe('given id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4711'
}
})
describe('click cancel button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.cancel').trigger('click')
})
it('does not emit "close" yet', () => {
expect(wrapper.emitted().close).toBeFalsy()
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
describe('click confirm button', () => {
beforeEach(async () => {
wrapper = Wrapper()
await wrapper.find('button.confirm').trigger('click')
})
it('calls mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('passes id to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls
expect(variables).toEqual({ id: 'u4711' })
})
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
})
})
})

View File

@ -0,0 +1,83 @@
<template>
<ds-modal
:title="title"
:is-open="isOpen"
@cancel="cancel"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template slot="footer">
<ds-button
class="cancel"
@click="cancel"
>
{{ $t('disable.cancel') }}
</ds-button>
<ds-button
danger
class="confirm"
icon="exclamation-circle"
@click="confirm"
>
{{ $t('disable.submit') }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true }
},
data() {
return {
isOpen: true,
success: false,
loading: false
}
},
computed: {
title() {
return this.$t(`disable.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`disable.${this.type}.message`, { name })
}
},
methods: {
cancel() {
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
disable(id: $id)
}
`,
variables: { id: this.id }
})
this.$toast.success(this.$t('disable.success'))
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} catch (err) {
this.$toast.error(err.message)
}
}
}
}
</script>

View File

@ -0,0 +1,168 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import ReportModal from './ReportModal.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('ReportModal.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c43'
}
mocks = {
$t: jest.fn(),
$filters: {
truncate: a => a
},
$toast: {
success: () => {},
error: () => {}
},
$apollo: {
mutate: jest.fn().mockResolvedValue()
}
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(ReportModal, { propsData, mocks, localVue })
}
describe('defaults', () => {
it('success false', () => {
expect(Wrapper().vm.success).toBe(false)
})
it('loading false', () => {
expect(Wrapper().vm.loading).toBe(false)
})
})
describe('given a user', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4',
name: 'Bob Ross'
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.user.message', { name: 'Bob Ross' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a post', () => {
beforeEach(() => {
propsData = {
id: 'p23',
type: 'post',
name: 'It is a post'
}
})
it('mentions post title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.post.message', { name: 'It is a post' }]]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(ReportModal, { propsData, mocks, localVue })
}
beforeEach(jest.useFakeTimers)
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
describe('given id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u4711'
}
wrapper = Wrapper()
})
describe('click cancel button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.cancel').trigger('click')
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('emits "close"', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
})
describe('click confirm button', () => {
beforeEach(() => {
wrapper.find('button.confirm').trigger('click')
})
it('calls report mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('sets success', () => {
expect(wrapper.vm.success).toBe(true)
})
it('displays a success message', () => {
const calls = mocks.$t.mock.calls
const expected = [['report.success']]
expect(calls).toEqual(expect.arrayContaining(expected))
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('fades away', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
it('resets success', () => {
expect(wrapper.vm.success).toBe(false)
})
})
})
})
})
})

View File

@ -0,0 +1,127 @@
<template>
<ds-modal
:title="title"
:is-open="isOpen"
@cancel="cancel"
>
<transition name="ds-transition-fade">
<ds-flex
v-if="success"
class="hc-modal-success"
centered
>
<sweetalert-icon icon="success" />
</ds-flex>
</transition>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template
slot="footer"
>
<ds-button
class="cancel"
icon="close"
@click="cancel"
>
{{ $t('report.cancel') }}
</ds-button>
<ds-button
danger
class="confirm"
icon="exclamation-circle"
:loading="loading"
@click="confirm"
>
{{ $t('report.submit') }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
name: 'ReportModal',
components: {
SweetalertIcon
},
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
id: { type: String, required: true }
},
data() {
return {
isOpen: true,
success: false,
loading: false
}
},
computed: {
title() {
return this.$t(`report.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`report.${this.type}.message`, { name })
}
},
methods: {
async cancel() {
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
},
async confirm() {
this.loading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
report(id: $id) {
id
}
}
`,
variables: { id: this.id }
})
this.success = true
this.$toast.success(this.$t('report.success'))
setTimeout(() => {
this.isOpen = false
setTimeout(() => {
this.success = false
this.$emit('close')
}, 500)
}, 1500)
} catch (err) {
this.success = false
this.$toast.error(err.message)
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss">
.hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: #fff;
opacity: 1;
z-index: $z-index-modal;
border-radius: $border-radius-x-large;
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<ds-card
:header="post.title"
:image="post.image"
:class="{'post-card': true, 'disabled-content': post.disabled}"
>
<a
v-router-link
class="post-link"
:href="href(post)"
>
{{ post.title }}
</a>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<ds-space margin-bottom="large">
<div
class="hc-editor-content"
v-html="excerpt"
/>
</ds-space>
<!-- eslint-enable vue/no-v-html -->
<ds-space>
<ds-text
v-if="post.createdAt"
align="right"
size="small"
color="soft"
>
{{ post.createdAt | dateTime('dd. MMMM yyyy HH:mm') }}
</ds-text>
</ds-space>
<ds-space
margin="small"
style="position: absolute; bottom: 44px; z-index: 1;"
>
<!-- TODO: find better solution for rendering errors -->
<no-ssr>
<hc-user
:user="post.author"
:trunc="35"
/>
</no-ssr>
</ds-space>
<template slot="footer">
<div style="display: inline-block; opacity: .5;">
<ds-icon
v-for="category in post.categories"
:key="category.id"
v-tooltip="{content: category.name, placement: 'bottom-start', delay: { show: 500 }}"
:name="category.icon"
/>&nbsp;
</div>
<div style="display: inline-block; float: right">
<span :style="{ opacity: post.shoutedCount ? 1 : .5 }">
<ds-icon name="bullhorn" /> <small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<span :style="{ opacity: post.commentsCount ? 1 : .5 }">
<ds-icon name="comments" /> <small>{{ post.commentsCount }}</small>
</span>
<no-ssr>
<content-menu
resource-type="contribution"
:resource="post"
:is-owner="isAuthor"
/>
</no-ssr>
</div>
</template>
</ds-card>
</template>
<script>
import HcUser from '~/components/User.vue'
import ContentMenu from '~/components/ContentMenu'
import { randomBytes } from 'crypto'
export default {
name: 'HcPostCard',
components: {
HcUser,
ContentMenu
},
props: {
post: {
type: Object,
required: true
}
},
computed: {
excerpt() {
// remove all links from excerpt to prevent issues with the serounding link
let excerpt = this.post.contentExcerpt.replace(/<a.*>(.+)<\/a>/gim, '$1')
// do not display content that is only linebreaks
if (excerpt.replace(/<br>/gim, '').trim() === '') {
excerpt = ''
}
return excerpt
},
isAuthor() {
return this.$store.getters['auth/user'].id === this.post.author.id
}
},
methods: {
href(post) {
return this.$router.resolve({
name: 'post-slug',
params: { slug: post.slug }
}).href
}
}
}
</script>
<style lang="scss">
.post-card {
cursor: pointer;
position: relative;
.ds-card-footer {
z-index: 1;
}
.content-menu {
display: inline-block;
margin-left: $space-xx-small;
margin-right: -$space-x-small;
z-index: 1;
}
}
.post-link {
display: block;
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-indent: -999999px;
}
</style>

View File

@ -0,0 +1,7 @@
# COMPONENTS
**This directory is not required, you can delete it if you don't want to use it.**
The components directory contains your Vue.js Components.
_Nuxt.js doesn't supercharge these components._

View File

@ -0,0 +1,144 @@
import { mount, createLocalVue } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.filter('truncate', () => 'truncated string')
localVue.filter('dateTime', () => Date.now)
describe('SearchInput.vue', () => {
let wrapper
let mocks
let propsData
beforeEach(() => {
propsData = {}
})
describe('mount', () => {
const Wrapper = () => {
mocks = {
$t: () => {}
}
return mount(SearchInput, { mocks, localVue, propsData })
}
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
it('has id "nav-search"', () => {
expect(Wrapper().contains('#nav-search')).toBe(true)
})
it('defaults to an empty value', () => {
expect(Wrapper().vm.value).toBe('')
})
it('defaults to id "nav-search"', () => {
expect(Wrapper().vm.id).toBe('nav-search')
})
it('default to a 300 millisecond delay from the time the user stops typing to when the search starts', () => {
expect(Wrapper().vm.delay).toEqual(300)
})
it('defaults to an empty array as results', () => {
expect(Wrapper().vm.results).toEqual([])
})
it('defaults to pending false, as in the search is not pending', () => {
expect(Wrapper().vm.pending).toBe(false)
})
it('accepts values as a string', () => {
propsData = { value: 'abc' }
const wrapper = Wrapper()
expect(wrapper.vm.value).toEqual('abc')
})
describe('testing custom functions', () => {
let select
let wrapper
beforeEach(() => {
wrapper = Wrapper()
select = wrapper.find('.ds-select')
select.trigger('focus')
select.element.value = 'abcd'
})
it('opens the select dropdown when focused on', () => {
expect(wrapper.vm.isOpen).toBe(true)
})
it('opens the select dropdown and blurs after focused on', () => {
select.trigger('blur')
expect(wrapper.vm.isOpen).toBe(false)
})
it('is clearable', () => {
select.trigger('input')
select.trigger('keyup.esc')
expect(wrapper.emitted().clear.length).toBe(1)
})
it('changes the unprocessedSearchInput as the value changes', () => {
select.trigger('input')
expect(wrapper.vm.unprocessedSearchInput).toBe('abcd')
})
it('searches for the term when enter is pressed', async () => {
select.trigger('input')
select.trigger('keyup.enter')
await expect(wrapper.emitted().search[0]).toEqual(['abcd'])
})
it('calls onDelete when the delete key is pressed', () => {
const spy = jest.spyOn(wrapper.vm, 'onDelete')
select.trigger('input')
select.trigger('keyup.delete')
expect(spy).toHaveBeenCalledTimes(1)
})
it('calls query when a user starts a search by pressing enter', () => {
const spy = jest.spyOn(wrapper.vm, 'query')
select.trigger('input')
select.trigger('keyup.enter')
expect(spy).toHaveBeenCalledWith('abcd')
})
it('calls onSelect when a user selects an item in the search dropdown menu', async () => {
// searched for term in the browser, copied the results from Vuex in Vue dev tools
propsData = {
results: [
{
__typename: 'Post',
author: {
__typename: 'User',
id: 'u5',
name: 'Trick',
slug: 'trick'
},
commentsCount: 0,
createdAt: '2019-03-13T11:00:20.835Z',
id: 'p10',
label: 'Eos aut illo omnis quis eaque et iure aut.',
shoutedCount: 0,
slug: 'eos-aut-illo-omnis-quis-eaque-et-iure-aut',
value: 'Eos aut illo omnis quis eaque et iure aut.'
}
]
}
wrapper = Wrapper()
select.trigger('input')
const results = wrapper.find('.ds-select-option')
results.trigger('click')
await expect(wrapper.emitted().select[0]).toEqual(propsData.results)
})
})
})
})

View File

@ -0,0 +1,289 @@
<template>
<div
class="search"
aria-label="search"
role="search"
:class="{
'is-active': isActive,
'is-open': isOpen
}"
>
<div class="field">
<div class="control">
<a
v-if="isActive"
class="search-clear-btn"
@click="clear"
>
&nbsp;
</a>
<ds-select
:id="id"
ref="input"
v-model="searchValue"
class="input"
name="search"
type="search"
icon="search"
label-prop="id"
:no-options-available="emptyText"
:icon-right="isActive ? 'close' : null"
:filter="item => item"
:options="results"
:auto-reset-search="!searchValue"
:placeholder="$t('search.placeholder')"
:loading="pending"
@keyup.enter.native="onEnter"
@focus.capture.native="onFocus"
@blur.capture.native="onBlur"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@input.exact="onSelect"
@input.native="handleInput"
@click.capture.native="isOpen = true"
>
<template
slot="option"
slot-scope="{option}"
>
<ds-flex>
<ds-flex-item class="search-option-label">
<ds-text>
{{ option.label | truncate(70) }}
</ds-text>
</ds-flex-item>
<ds-flex-item
class="search-option-meta"
width="280px"
>
<ds-flex>
<ds-flex-item>
<ds-text
size="small"
color="softer"
class="search-meta"
>
<span style="text-align: right;">
<b>{{ option.commentsCount }}</b> <ds-icon name="comments" />
</span>
<span style="width: 36px; display: inline-block; text-align: right;">
<b>{{ option.shoutedCount }}</b> <ds-icon name="bullhorn" />
</span>
</ds-text>
</ds-flex-item>
<ds-flex-item>
<ds-text
size="small"
color="softer"
align="right"
>
{{ option.author.name | truncate(32) }} - {{ option.createdAt | dateTime('dd.MM.yyyy') }}
</ds-text>
</ds-flex-item>
</ds-flex>
</ds-flex-item>
</ds-flex>
</template>
</ds-select>
</div>
</div>
</div>
</template>
<script>
import { isEmpty } from 'lodash'
export default {
name: 'SearchInput',
props: {
id: {
type: String,
default: 'nav-search'
},
value: {
type: String,
default: ''
},
results: {
type: Array,
default: () => []
},
delay: {
type: Number,
default: 300
},
pending: {
type: Boolean,
default: false
}
},
data() {
return {
searchProcess: null,
isOpen: false,
lastSearchTerm: '',
unprocessedSearchInput: '',
searchValue: ''
}
},
computed: {
// #: Unused at the moment?
isActive() {
return !isEmpty(this.lastSearchTerm)
},
emptyText() {
return this.isActive && !this.pending
? this.$t('search.failed')
: this.$t('search.hint')
}
},
methods: {
async query(value) {
if (isEmpty(value) || value.length < 3) {
this.clear()
return
}
this.$emit('search', value)
},
handleInput(e) {
clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : ''
this.isOpen = true
this.unprocessedSearchInput = value
this.searchProcess = setTimeout(() => {
this.lastSearchTerm = value
this.query(value)
}, this.delay)
},
onSelect(item) {
this.isOpen = false
this.$emit('select', item)
this.$nextTick(() => {
this.searchValue = this.lastSearchTerm
})
},
onFocus(e) {
clearTimeout(this.searchProcess)
this.isOpen = true
},
onBlur(e) {
this.searchValue = this.lastSearchTerm
// this.$nextTick(() => {
// this.searchValue = this.lastSearchTerm
// })
this.isOpen = false
clearTimeout(this.searchProcess)
},
onDelete(e) {
clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(e)
}
},
/**
* TODO: on enter we should go to a dedicated seach page!?
*/
onEnter(e) {
// this.isOpen = false
clearTimeout(this.searchProcess)
if (!this.pending) {
// this.lastSearchTerm = this.unprocessedSearchInput
this.query(this.unprocessedSearchInput)
}
},
clear() {
this.$emit('clear')
clearTimeout(this.searchProcess)
this.isOpen = false
this.unprocessedSearchInput = ''
this.lastSearchTerm = ''
this.searchValue = ''
}
}
}
</script>
<style lang="scss">
.search {
display: flex;
align-self: center;
width: 100%;
position: relative;
$padding-left: $space-x-small;
.search-option-label {
align-self: center;
padding-left: $padding-left;
}
.search-option-meta {
align-self: center;
.ds-flex {
flex-direction: column;
}
}
&,
.ds-select-dropdown {
transition: box-shadow 100ms;
max-height: 70vh;
}
&.is-open {
.ds-select-dropdown {
box-shadow: $box-shadow-x-large;
}
}
.ds-select-dropdown-message {
opacity: 0.5;
padding-left: $padding-left;
}
.search-clear-btn {
right: 0;
z-index: 10;
position: absolute;
height: 100%;
width: 36px;
cursor: pointer;
}
.search-meta {
float: right;
padding-top: 2px;
white-space: nowrap;
word-wrap: none;
.ds-icon {
vertical-align: sub;
}
}
.ds-select {
z-index: $z-index-dropdown + 1;
}
.ds-select-option-hover {
.ds-text-size-small,
.ds-text-size-small-x {
color: $text-color-soft;
}
}
.field {
width: 100%;
display: flex;
align-items: center;
}
.control {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<ds-space
margin="large"
style="text-align: center"
>
<ds-button
:loading="loading"
:disabled="disabled"
:ghost="!shouted"
:primary="shouted"
size="x-large"
icon="bullhorn"
@click="toggle"
/>
<ds-space margin-bottom="xx-small" />
<ds-text
color="soft"
class="shout-button-text"
>
<ds-heading
style="display: inline"
tag="h3"
>
{{ shoutedCount }}x
</ds-heading> {{ $t('shoutButton.shouted') }}
</ds-text>
</ds-space>
</template>
<script>
import gql from 'graphql-tag'
export default {
props: {
count: { type: Number, default: 0 },
postId: { type: String, default: null },
isShouted: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
},
data() {
return {
loading: false,
shoutedCount: this.count,
shouted: false
}
},
watch: {
isShouted: {
immediate: true,
handler: function(shouted) {
this.shouted = shouted
}
}
},
methods: {
toggle() {
const shout = !this.shouted
const mutation = shout ? 'shout' : 'unshout'
const count = shout ? this.shoutedCount + 1 : this.shoutedCount - 1
const backup = {
shoutedCount: this.shoutedCount,
shouted: this.shouted
}
this.shoutedCount = count
this.shouted = shout
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!) {
${mutation}(id: $id, type: Post)
}
`,
variables: {
id: this.postId
}
})
.then(res => {
if (res && res.data) {
this.$emit('update', shout)
}
})
.catch(() => {
this.shoutedCount = backup.shoutedCount
this.shouted = backup.shouted
})
.finally(() => {
this.loading = false
})
}
}
}
</script>
<style lang="scss">
.shout-button-text {
user-select: none;
}
</style>

View File

@ -0,0 +1,102 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import User from './User.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
const filter = jest.fn(str => str)
localVue.use(Vuex)
localVue.use(VTooltip)
localVue.use(Styleguide)
localVue.filter('truncate', filter)
describe('User.vue', () => {
let wrapper
let Wrapper
let propsData
let mocks
let stubs
let getters
let user
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn()
}
stubs = {
NuxtLink: RouterLinkStub
}
getters = {
'auth/user': () => {
return {}
},
'auth/isModerator': () => false
}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters
})
return mount(User, { store, propsData, mocks, stubs, localVue })
}
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(wrapper.text()).toMatch('Anonymus')
})
describe('given an user', () => {
beforeEach(() => {
propsData.user = {
name: 'Tilda Swinton',
slug: 'tilda-swinton'
}
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous')
expect(wrapper.text()).toMatch('Tilda Swinton')
})
describe('user is disabled', () => {
beforeEach(() => {
propsData.user.disabled = true
})
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(wrapper.text()).toMatch('Anonymus')
})
describe('current user is a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous')
expect(wrapper.text()).toMatch('Tilda Swinton')
})
it('has "disabled-content" class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
})
})
})
})
})

182
webapp/components/User.vue Normal file
View File

@ -0,0 +1,182 @@
<template>
<div v-if="!user || ((user.disabled || user.deleted) && !isModerator)">
<div style="display: inline-block; float: left; margin-right: 4px; height: 100%; vertical-align: middle;">
<ds-avatar
style="display: inline-block; vertical-align: middle;"
size="32px"
/>
</div>
<div style="display: inline-block; height: 100%; vertical-align: middle;">
<b
class="username"
style="vertical-align: middle;"
>
Anonymus
</b>
</div>
</div>
<dropdown
v-else
:class="{'disabled-content': user.disabled}"
placement="top-start"
offset="0"
>
<template
slot="default"
slot-scope="{openMenu, closeMenu, isOpen}"
>
<nuxt-link
:to="userLink"
:class="['user', isOpen && 'active']"
>
<div
@mouseover="openMenu(true)"
@mouseleave="closeMenu(true)"
>
<div style="display: inline-block; float: left; margin-right: 4px; height: 100%; vertical-align: middle;">
<ds-avatar
:image="user.avatar"
:name="user.name"
style="display: inline-block; vertical-align: middle;"
size="32px"
/>
</div>
<div style="display: inline-block; height: 100%; vertical-align: middle;">
<b
class="username"
style="vertical-align: middle;"
>
{{ user.name | truncate(trunc, 18) }}
</b>
</div>
</div>
</nuxt-link>
</template>
<template
slot="popover"
>
<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
>
<ds-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="fanCount"
: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.commentsCount"
:label="$t('common.comment', null, user.commentsCount)"
/>
</ds-space>
</ds-flex-item>
</ds-flex>
<ds-flex
v-if="!itsMe"
gutter="x-small"
style="margin-bottom: 0;"
>
<ds-flex-item :width="{base: 3}">
<hc-follow-button
:follow-id="user.id"
:is-followed="user.followedByCurrentUser"
@optimistic="follow => user.followedByCurrentUser = follow"
@update="follow => user.followedByCurrentUser = follow"
/>
</ds-flex-item>
<ds-flex-item :width="{base: 1}">
<ds-button fullwidth>
<ds-icon name="user-times" />
</ds-button>
</ds-flex-item>
</ds-flex>
<!--<ds-space margin-bottom="x-small" />-->
</div>
</template>
</dropdown>
</template>
<script>
import HcFollowButton from '~/components/FollowButton.vue'
import HcBadges from '~/components/Badges.vue'
import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
export default {
name: 'HcUser',
components: {
HcFollowButton,
HcBadges,
Dropdown
},
props: {
user: { type: Object, default: null },
trunc: { type: Number, default: null }
},
computed: {
...mapGetters({
isModerator: 'auth/isModerator'
}),
itsMe() {
return this.user.slug === this.$store.getters['auth/user'].slug
},
fanCount() {
let count = Number(this.user.followedByCount) || 0
return count
},
userLink() {
const { slug } = this.user
if (!slug) return ''
return { name: 'profile-slug', params: { slug } }
}
}
}
</script>
<style lang="scss">
.profile-avatar {
display: block;
margin: auto;
margin-top: -45px;
border: #fff 5px solid;
}
.user {
white-space: nowrap;
position: relative;
display: flex;
align-items: center;
&:hover,
&.active {
z-index: 999;
}
}
</style>

View File

@ -0,0 +1,12 @@
export default {
head() {
return {
htmlAttrs: {
lang: this.$i18n.locale()
},
bodyAttrs: {
class: `page-name-${this.$route.name}`
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"SEED_SERVER_HOST": "http://localhost:4001",
"NEO4J_URI": "bolt://localhost:7687",
"NEO4J_USERNAME": "neo4j",
"NEO4J_PASSWORD": "letmein"
}

5
webapp/cypress.json Normal file
View File

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

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@ -0,0 +1,17 @@
{
"admin": {
"email": "admin@example.org",
"password": "1234",
"name": "Peter Lustig"
},
"moderator": {
"email": "moderator@example.org",
"password": "1234",
"name": "Bob der Bausmeister"
},
"user": {
"email": "user@example.org",
"password": "1234",
"name": "Jenny Rostock"
}
}

View File

@ -0,0 +1,23 @@
Feature: Authentication
As a database administrator
I want users to sign in
In order to attribute posts and other contributions to their authors
Background:
Given I have a user account
Scenario: Log in
When I visit the "/login" page
And I fill in my email and password combination and click submit
Then I can click on my profile picture in the top right corner
And I can see my name "Peter Lustig" in the dropdown menu
Scenario: Refresh and stay logged in
Given I am logged in
When I refresh the page
Then I am still logged in
Scenario: Log out
Given I am logged in
When I log out through the menu in the top right corner
Then I see the login screen again

View File

@ -0,0 +1,23 @@
Feature: Internationalization
As a user who is not very fluent in English
I would like to see the user interface translated to my preferred language
In order to be able to understand the interface
Background:
Given I am on the "login" page
Scenario Outline: I select "<language>" in the language menu and see "<buttonLabel>"
When I select "<language>" in the language menu
Then the whole user interface appears in "<language>"
Then I see a button with the label "<buttonLabel>"
Examples: Login Button
| language | buttonLabel |
| Français | Connexion |
| Deutsch | Einloggen |
| English | Login |
Scenario: Keep preferred language after refresh
Given I previously switched the language to "Français"
And I refresh the page
Then the whole user interface appears in "Français"

View File

@ -0,0 +1,40 @@
Feature: Tags and Categories
As a database administrator
I would like to see a summary of all tags and categories and their usage
In order to be able to decide which tags and categories are popular or not
The currently deployed application, codename "Alpha", distinguishes between
categories and tags. Each post can have a number of categories and/or tags.
A few categories are required for each post, tags are completely optional.
Both help to find relevant posts in the database, e.g. users can filter for
categories.
If administrators summary of all tags and categories and how often they are
used, they learn what new category might be convenient for users, e.g. by
looking at the popularity of a tag.
Background:
Given my user account has the role "admin"
And we have a selection of tags and categories as well as posts
And I am logged in
Scenario: See an overview of categories
When I navigate to the administration dashboard
And I click on the menu item "Categories"
Then I can see a list of categories ordered by post count:
| Icon | Name | Posts |
| | Just For Fun | 2 |
| | Happyness & Values | 1 |
| | Health & Wellbeing | 0 |
Scenario: See an overview of tags
When I navigate to the administration dashboard
And I click on the menu item "Tags"
Then I can see a list of tags ordered by user count:
| # | Name | Users | Posts |
| 1 | Democracy | 2 | 3 |
| 2 | Ecology | 1 | 1 |
| 3 | Nature | 1 | 2 |

View File

@ -0,0 +1,37 @@
Feature: About me and location
As a user
I would like to add some about me text and a location
So others can get some info about me and my location
The location and about me are displayed on the user profile. Later it will be possible
to search for users by location.
Background:
Given I have a user account
And I am logged in
And I am on the "settings" page
Scenario: Change username
When I save "Hansi" as my new name
Then I can see my new name "Hansi" when I click on my profile picture in the top right
And when I refresh the page
Then the name "Hansi" is still there
Scenario Outline: I set my location to "<location>"
When I save "<location>" as my location
When people visit my profile page
Then they can see the location in the info box below my avatar
Examples: Location
| location | type |
| Paris | City |
| Saxony-Anhalt | Region |
| Germany | Country |
Scenario: Display a description on profile page
Given I have the following self-description:
"""
Ich lebe fettlos, fleischlos, fischlos dahin, fühle mich aber ganz wohl dabei
"""
When people visit my profile page
Then they can see the text in the info box below my avatar

View File

@ -0,0 +1,41 @@
Feature: Search
As a user
I would like to be able to search for specific words
In order to find related content
Background:
Given I have a user account
And we have the following posts in our database:
| Author | id | title | content |
| Brianna Wiest | p1 | 101 Essays that will change the way you think | 101 Essays, of course! |
| Brianna Wiest | p1 | No searched for content | will be found in this post, I guarantee |
Given I am logged in
Scenario: Search for specific words
When I search for "Essays"
Then I should have one post in the select dropdown
Then I should see the following posts in the select dropdown:
| title |
| 101 Essays that will change the way you think |
Scenario: Press enter starts search
When I type "Essa" and press Enter
Then I should have one post in the select dropdown
Then I should see the following posts in the select dropdown:
| title |
| 101 Essays that will change the way you think |
Scenario: Press escape clears search
When I type "Ess" and press escape
Then the search field should clear
Scenario: Select entry goes to post
When I search for "Essays"
And I select an entry
Then I should be on the post's page
Scenario: Select dropdown content
When I search for "Essays"
Then I should have one post in the select dropdown
Then I should see posts with the searched-for term in the select dropdown
And I should not see posts without the searched-for term in the select dropdown

View File

@ -0,0 +1,25 @@
Feature: Create a post
As a user
I would like to create a post
To say something to everyone in the community
Background:
Given I have a user account
And I am logged in
And I am on the "landing" page
Scenario: Create a post
When I click on the big plus icon in the bottom right corner to create post
And I choose "My first post" as the title of the post
And I type in the following text:
"""
Human Connection is a free and open-source social network
for active citizenship.
"""
And I click on "Save"
Then I get redirected to "/post/my-first-post/"
And the post was saved successfully
Scenario: See a post on the landing page
Given I previously created a post
Then the post shows up on the landing page at position 1

View File

@ -0,0 +1,46 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
/* global cy */
When('I navigate to the administration dashboard', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover')
.find('a[href="/admin"]')
.click()
})
Then('I can see a list of categories ordered by post count:', table => {
cy.get('thead')
.find('tr th')
.should('have.length', 3)
table.hashes().forEach(({ Name, Posts }, index) => {
cy.get(`tbody > :nth-child(${index + 1}) > :nth-child(2)`).should(
'contain',
Name.trim()
)
cy.get(`tbody > :nth-child(${index + 1}) > :nth-child(3)`).should(
'contain',
Posts
)
})
})
Then('I can see a list of tags ordered by user count:', table => {
cy.get('thead')
.find('tr th')
.should('have.length', 4)
table.hashes().forEach(({ Name, Users, Posts }, index) => {
cy.get(`tbody > :nth-child(${index + 1}) > :nth-child(2)`).should(
'contain',
Name.trim()
)
cy.get(`tbody > :nth-child(${index + 1}) > :nth-child(3)`).should(
'contain',
Users
)
cy.get(`tbody > :nth-child(${index + 1}) > :nth-child(4)`).should(
'contain',
Posts
)
})
})

View File

@ -0,0 +1,145 @@
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'
/* global cy */
let lastReportTitle
let davidIrvingPostTitle = 'The Truth about the Holocaust'
let davidIrvingPostSlug = 'the-truth-about-the-holocaust'
let davidIrvingName = 'David Irving'
const savePostTitle = $post => {
return $post
.first()
.find('.ds-heading')
.first()
.invoke('text')
.then(title => {
lastReportTitle = title
})
}
Given("I see David Irving's post on the landing page", page => {
cy.openPage('landing')
})
Given("I see David Irving's post on the post page", page => {
cy.visit(`/post/${davidIrvingPostSlug}`)
cy.contains(davidIrvingPostTitle) // wait
})
Given('I am logged in with a {string} role', role => {
cy.factory().create('User', {
email: `${role}@example.org`,
password: '1234',
role
})
cy.login({
email: `${role}@example.org`,
password: '1234'
})
})
When('I click on "Report Post" from the triple dot menu of the post', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
.find('.content-menu-trigger')
.click()
cy.get('.popover .ds-menu-item-link')
.contains('Report Post')
.click()
})
When(
'I click on "Report User" from the triple dot menu in the user info box',
() => {
cy.contains('.ds-card', davidIrvingName)
.find('.content-menu-trigger')
.first()
.click()
cy.get('.popover .ds-menu-item-link')
.contains('Report User')
.click()
}
)
When('I click on the author', () => {
cy.get('a.user')
.first()
.click()
.wait(200)
})
When('I report the author', () => {
cy.get('.page-name-profile-slug').then(() => {
invokeReportOnElement('.ds-card').then(() => {
cy.get('button')
.contains('Send')
.click()
})
})
})
When('I click on send in the confirmation dialog', () => {
cy.get('button')
.contains('Send')
.click()
})
Then('I get a success message', () => {
cy.get('.iziToast-message').contains('Thanks')
})
Then('I see my reported user', () => {
cy.get('table').then(() => {
cy.get('tbody tr')
.first()
.contains(lastReportTitle.trim())
})
})
Then(`I can't see the moderation menu item`, () => {
cy.get('.avatar-menu-popover')
.find('a[href="/settings"]', 'Settings')
.should('exist') // OK, the dropdown is actually open
cy.get('.avatar-menu-popover')
.find('a[href="/moderation"]', 'Moderation')
.should('not.exist')
})
When(/^I confirm the reporting dialog .*:$/, message => {
cy.contains(message) // wait for element to become visible
cy.get('.ds-modal').within(() => {
cy.get('button')
.contains('Report')
.click()
})
})
Given('somebody reported the following posts:', table => {
table.hashes().forEach(({ id }) => {
const submitter = {
email: `submitter${id}@example.org`,
password: '1234'
}
cy.factory()
.create('User', submitter)
.authenticateAs(submitter)
.create('Report', {
id,
description: 'Offensive content'
})
})
})
Then('I see all the reported posts including the one from above', () => {
cy.get('table tbody').within(() => {
cy.contains('tr', davidIrvingPostTitle)
})
})
Then('each list item links to the post page', () => {
cy.contains(davidIrvingPostTitle).click()
cy.location('pathname').should('contain', '/post')
})

View File

@ -0,0 +1,69 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
When('I search for {string}', value => {
cy.get('#nav-search')
.focus()
.type(value)
})
Then('I should have one post in the select dropdown', () => {
cy.get('.ds-select-dropdown').should($li => {
expect($li).to.have.length(1)
})
})
Then('I should see the following posts in the select dropdown:', table => {
table.hashes().forEach(({ title }) => {
cy.get('.ds-select-dropdown').should('contain', title)
})
})
When('I type {string} and press Enter', value => {
cy.get('#nav-search')
.focus()
.type(value)
.type('{enter}', { force: true })
})
When('I type {string} and press escape', value => {
cy.get('#nav-search')
.focus()
.type(value)
.type('{esc}')
})
Then('the search field should clear', () => {
cy.get('#nav-search').should('have.text', '')
})
When('I select an entry', () => {
cy.get('.ds-select-dropdown ul li')
.first()
.trigger('click')
})
Then("I should be on the post's page", () => {
cy.location('pathname').should(
'eq',
'/post/101-essays-that-will-change-the-way-you-think/'
)
})
Then(
'I should see posts with the searched-for term in the select dropdown',
() => {
cy.get('.ds-select-dropdown').should(
'contain',
'101 Essays that will change the way you think'
)
}
)
Then(
'I should not see posts without the searched-for term in the select dropdown',
() => {
cy.get('.ds-select-dropdown').should(
'not.contain',
'No searched for content'
)
}
)

View File

@ -0,0 +1,63 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
/* global cy */
let aboutMeText
let myLocation
const matchNameInUserMenu = name => {
cy.get('.avatar-menu').click() // open
cy.get('.avatar-menu-popover').contains(name)
cy.get('.avatar-menu').click() // close again
}
When('I save {string} as my new name', name => {
cy.get('input[id=name]')
.clear()
.type(name)
cy.get('[type=submit]')
.click()
.not('[disabled]')
})
When('I save {string} as my location', location => {
cy.get('input[id=city]').type(location)
cy.get('.ds-select-option')
.contains(location)
.click()
cy.get('[type=submit]')
.click()
.not('[disabled]')
myLocation = location
})
When('I have the following self-description:', text => {
cy.get('textarea[id=bio]')
.clear()
.type(text)
cy.get('[type=submit]')
.click()
.not('[disabled]')
aboutMeText = text
})
When('people visit my profile page', url => {
cy.openPage('/profile/peter-pan')
})
When('they can see the text in the info box below my avatar', () => {
cy.contains(aboutMeText)
})
Then('they can see the location in the info box below my avatar', () => {
cy.contains(myLocation)
})
Then('the name {string} is still there', name => {
matchNameInUserMenu(name)
})
Then(
'I can see my new name {string} when I click on my profile picture in the top right',
name => matchNameInUserMenu(name)
)

View File

@ -0,0 +1,246 @@
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'
import { getLangByName } from '../../support/helpers'
/* global cy */
let lastPost = {}
const loginCredentials = {
email: 'peterpan@example.org',
password: '1234'
}
const narratorParams = {
name: 'Peter Pan',
...loginCredentials
}
Given('I am logged in', () => {
cy.login(loginCredentials)
})
Given('we have a selection of tags and categories as well as posts', () => {
cy.factory()
.authenticateAs(loginCredentials)
.create('Category', {
id: 'cat1',
name: 'Just For Fun',
slug: 'justforfun',
icon: 'smile'
})
.create('Category', {
id: 'cat2',
name: 'Happyness & Values',
slug: 'happyness-values',
icon: 'heart-o'
})
.create('Category', {
id: 'cat3',
name: 'Health & Wellbeing',
slug: 'health-wellbeing',
icon: 'medkit'
})
.create('Tag', { id: 't1', name: 'Ecology' })
.create('Tag', { id: 't2', name: 'Nature' })
.create('Tag', { id: 't3', name: 'Democracy' })
const someAuthor = {
id: 'authorId',
email: 'author@example.org',
password: '1234'
}
cy.factory()
.create('User', someAuthor)
.authenticateAs(someAuthor)
.create('Post', { id: 'p0' })
.create('Post', { id: 'p1' })
cy.factory()
.authenticateAs(loginCredentials)
.create('Post', { id: 'p2' })
.relate('Post', 'Categories', { from: 'p0', to: 'cat1' })
.relate('Post', 'Categories', { from: 'p1', to: 'cat2' })
.relate('Post', 'Categories', { from: 'p2', to: 'cat1' })
.relate('Post', 'Tags', { from: 'p0', to: 't1' })
.relate('Post', 'Tags', { from: 'p0', to: 't2' })
.relate('Post', 'Tags', { from: 'p0', to: 't3' })
.relate('Post', 'Tags', { from: 'p1', to: 't2' })
.relate('Post', 'Tags', { from: 'p1', to: 't3' })
.relate('Post', 'Tags', { from: 'p2', to: 't3' })
})
Given('we have the following user accounts:', table => {
table.hashes().forEach(params => {
cy.factory().create('User', params)
})
})
Given('I have a user account', () => {
cy.factory().create('User', narratorParams)
})
Given('my user account has the role {string}', role => {
cy.factory().create('User', {
role,
...loginCredentials
})
})
When('I log out', cy.logout)
When('I visit the {string} page', page => {
cy.openPage(page)
})
Given('I am on the {string} page', page => {
cy.openPage(page)
})
When('I fill in my email and password combination and click submit', () => {
cy.login(loginCredentials)
})
When(/(?:when )?I refresh the page/, () => {
cy.reload()
})
When('I log out through the menu in the top right corner', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover')
.find('a[href="/logout"]')
.click()
})
Then('I can see my name {string} in the dropdown menu', () => {
cy.get('.avatar-menu-popover').should('contain', narratorParams.name)
})
Then('I see the login screen again', () => {
cy.location('pathname').should('contain', '/login')
})
Then('I can click on my profile picture in the top right corner', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover')
})
Then('I am still logged in', () => {
cy.get('.avatar-menu').click()
cy.get('.avatar-menu-popover').contains(narratorParams.name)
})
When('I select {string} in the language menu', name => {
cy.switchLanguage(name, true)
})
Given('I previously switched the language to {string}', name => {
cy.switchLanguage(name, true)
})
Then('the whole user interface appears in {string}', name => {
const lang = getLangByName(name)
cy.get(`html[lang=${lang.code}]`)
cy.getCookie('locale').should('have.property', 'value', lang.code)
})
Then('I see a button with the label {string}', label => {
cy.contains('button', label)
})
When(`I click on {string}`, linkOrButton => {
cy.contains(linkOrButton).click()
})
When(`I click on the menu item {string}`, linkOrButton => {
cy.contains('.ds-menu-item', linkOrButton).click()
})
When('I press {string}', label => {
cy.contains(label).click()
})
Given('we have the following posts in our database:', table => {
table.hashes().forEach(({ Author, ...postAttributes }) => {
const userAttributes = {
name: Author,
email: `${Author}@example.org`,
password: '1234'
}
postAttributes.deleted = Boolean(postAttributes.deleted)
const disabled = Boolean(postAttributes.disabled)
cy.factory()
.create('User', userAttributes)
.authenticateAs(userAttributes)
.create('Post', postAttributes)
if (disabled) {
const moderatorParams = {
email: 'moderator@example.org',
role: 'moderator',
password: '1234'
}
cy.factory()
.create('User', moderatorParams)
.authenticateAs(moderatorParams)
.mutate('mutation($id: ID!) { disable(id: $id) }', postAttributes)
}
})
})
Then('I see a success message:', message => {
cy.contains(message)
})
When('I click on the avatar menu in the top right corner', () => {
cy.get('.avatar-menu').click()
})
When(
'I click on the big plus icon in the bottom right corner to create post',
() => {
cy.get('.post-add-button').click()
}
)
Given('I previously created a post', () => {
cy.factory()
.authenticateAs(loginCredentials)
.create('Post', lastPost)
})
When('I choose {string} as the title of the post', title => {
lastPost.title = title.replace('\n', ' ')
cy.get('input[name="title"]').type(lastPost.title)
})
When('I type in the following text:', text => {
lastPost.content = text.replace('\n', ' ')
cy.get('.ProseMirror').type(lastPost.content)
})
Then('the post shows up on the landing page at position {int}', index => {
cy.openPage('landing')
const selector = `:nth-child(${index}) > .ds-card > .ds-card-content`
cy.get(selector).should('contain', lastPost.title)
cy.get(selector).should('contain', lastPost.content)
})
Then('I get redirected to {string}', route => {
cy.location('pathname').should('contain', route)
})
Then('the post was saved successfully', () => {
cy.get('.ds-card-header > .ds-heading').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)
})
Then('the first post on the landing page has the title:', title => {
cy.get('.post-card:first').should('contain', title)
})
Then(
'the page {string} returns a 404 error with a message:',
(route, message) => {
// TODO: how can we check HTTP codes with cypress?
cy.visit(route, { failOnStatusCode: false })
cy.get('.error').should('contain', message)
}
)

View File

@ -0,0 +1,26 @@
Feature: Hide Posts
As the moderator team
we'd like to be able to hide posts from the public
to enforce our network's code of conduct and/or legal regulations
Background:
Given we have the following posts in our database:
| id | title | deleted | disabled |
| p1 | This post should be visible | | |
| p2 | This post is disabled | | x |
| p3 | This post is deleted | x | |
Scenario: Disabled posts don't show up on the landing page
Given I am logged in with a "user" role
Then I should see only 1 post on the landing page
And the first post on the landing page has the title:
"""
This post should be visible
"""
Scenario: Visiting a disabled post's page should return 404
Given I am logged in with a "user" role
Then the page "/post/this-post-is-disabled" returns a 404 error with a message:
"""
This post could not be found
"""

View File

@ -0,0 +1,59 @@
Feature: Report and Moderate
As a user
I would like to report content that viloates the community guidlines
So the moderators can take action on it
As a moderator
I would like to see all reported content
So I can look into it and decide what to do
Background:
Given we have the following posts in our database:
| Author | id | title | content |
| David Irving | p1 | The Truth about the Holocaust | It never existed! |
Scenario Outline: Report a post from various pages
Given I am logged in with a "user" role
When I see David Irving's post on the <Page>
And I click on "Report Post" from the triple dot menu of the post
And I confirm the reporting dialog because it is a criminal act under German law:
"""
Do you really want to report the contribution "The Truth about the Holocaust"?
"""
Then I see a success message:
"""
Thanks for reporting!
"""
Examples:
| Page |
| landing page |
| post page |
Scenario: Report user
Given I am logged in with a "user" role
And I see David Irving's post on the post page
When I click on the author
And I click on "Report User" from the triple dot menu in the user info box
And I confirm the reporting dialog because he is a holocaust denier:
"""
Do you really want to report the user "David Irving"?
"""
Then I see a success message:
"""
Thanks for reporting!
"""
Scenario: Review reported content
Given somebody reported the following posts:
| id |
| p1 |
And I am logged in with a "moderator" role
When I click on the avatar menu in the top right corner
And I click on "Moderation"
Then I see all the reported posts including the one from above
And each list item links to the post page
Scenario: Normal user can't see the moderation page
Given I am logged in with a "user" role
When I click on the avatar menu in the top right corner
Then I can't see the moderation menu item

View File

@ -0,0 +1,20 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const cucumber = require('cypress-cucumber-preprocessor').default
module.exports = on => {
// (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('file:preprocessor', cucumber())
}

View File

@ -0,0 +1,75 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
/* globals Cypress cy */
import { getLangByName } from './helpers'
import users from '../fixtures/users.json'
const switchLang = name => {
cy.get('.locale-menu').click()
cy.contains('.locale-menu-popover a', name).click()
}
Cypress.Commands.add('switchLanguage', (name, force) => {
const code = getLangByName(name).code
if (force) {
switchLang(name)
} else {
cy.get('html').then($html => {
if ($html && $html.attr('lang') !== code) {
switchLang(name)
}
})
}
})
Cypress.Commands.add('login', ({ email, password }) => {
cy.visit(`/login`)
cy.get('input[name=email]')
.trigger('focus')
.type(email)
cy.get('input[name=password]')
.trigger('focus')
.type(password)
cy.get('button[name=submit]')
.as('submitButton')
.click()
cy.location('pathname').should('eq', '/') // we're in!
})
Cypress.Commands.add('logout', (email, password) => {
cy.visit(`/logout`)
cy.location('pathname').should('contain', '/login') // we're out
})
Cypress.Commands.add('openPage', page => {
if (page === 'landing') {
page = ''
}
cy.visit(`/${page}`)
})
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View File

@ -0,0 +1,51 @@
// TODO: find a better way how to import the factories
import Factory from '../../../Nitro-Backend/src/seed/factories'
import { getDriver } from '../../../Nitro-Backend/src/bootstrap/neo4j'
const neo4jDriver = getDriver({
uri: Cypress.env('NEO4J_URI'),
username: Cypress.env('NEO4J_USERNAME'),
password: Cypress.env('NEO4J_PASSWORD')
})
const factory = Factory({ neo4jDriver })
const seedServerHost = Cypress.env('SEED_SERVER_HOST')
beforeEach(async () => {
await factory.cleanDatabase({ seedServerHost, neo4jDriver })
})
Cypress.Commands.add('factory', () => {
return Factory({ seedServerHost })
})
Cypress.Commands.add(
'create',
{ prevSubject: true },
(factory, node, properties) => {
return factory.create(node, properties)
}
)
Cypress.Commands.add(
'relate',
{ prevSubject: true },
(factory, node, relationship, properties) => {
return factory.relate(node, relationship, properties)
}
)
Cypress.Commands.add(
'mutate',
{ prevSubject: true },
(factory, mutation, variables) => {
return factory.mutate(mutation, variables)
}
)
Cypress.Commands.add(
'authenticateAs',
{ prevSubject: true },
(factory, loginCredentials) => {
return factory.authenticateAs(loginCredentials)
}
)

View File

@ -0,0 +1,10 @@
import find from 'lodash/find'
const helpers = {
locales: require('../../locales'),
getLangByName: name => {
return find(helpers.locales, { name })
}
}
export default helpers

View File

@ -0,0 +1,21 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import './factories'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -0,0 +1,16 @@
version: '3.7'
services:
webapp:
build:
context: .
target: build-and-test
volumes:
- .:/nitro-web
- node_modules:/nitro-web/node_modules
- nuxt:/nitro-web/.nuxt
command: yarn run dev
volumes:
node_modules:
nuxt:

View File

@ -0,0 +1,9 @@
version: "3.7"
services:
webapp:
build:
context: .
target: build-and-test
environment:
- BACKEND_URL=http://backend:4123

24
webapp/docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
version: '3.7'
services:
webapp:
image: humanconnection/nitro-web:latest
build:
context: .
target: production
ports:
- 3000:3000
- 8080:8080
networks:
- hc-network
environment:
- HOST=0.0.0.0
- BACKEND_URL=http://backend:4000
- MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.bZ8KK9l70omjXbEkkbHGsQ"
networks:
hc-network:
name: hc-network
volumes:
node_modules:

View File

@ -0,0 +1,69 @@
import gql from 'graphql-tag'
export default app => {
return gql(`
query {
Report(first: 20, orderBy: createdAt_desc) {
id
description
type
createdAt
submitter {
disabled
deleted
name
slug
}
user {
name
slug
disabled
deleted
disabledBy {
slug
name
}
}
comment {
contentExcerpt
author {
name
slug
disabled
deleted
}
post {
disabled
deleted
title
slug
}
disabledBy {
disabled
deleted
slug
name
}
}
post {
title
slug
disabled
deleted
author {
disabled
deleted
name
slug
}
disabledBy {
disabled
deleted
slug
name
}
}
}
}
`)
}

View File

@ -0,0 +1,28 @@
import gql from 'graphql-tag'
export default app => {
return {
CreatePost: gql(`
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
id
title
slug
content
contentExcerpt
}
}
`),
UpdatePost: gql(`
mutation($id: ID!, $title: String!, $content: String!) {
UpdatePost(id: $id, title: $title, content: $content) {
id
title
slug
content
contentExcerpt
}
}
`)
}
}

View File

@ -0,0 +1,102 @@
import gql from 'graphql-tag'
export default app => {
const lang = app.$i18n.locale().toUpperCase()
return gql(`
query User($slug: String!, $first: Int, $offset: Int) {
User(slug: $slug) {
id
name
avatar
about
disabled
deleted
locationName
location {
name: name${lang}
}
createdAt
badges {
id
key
icon
}
badgesCount
shoutedCount
commentsCount
followingCount
following(first: 7) {
id
name
slug
avatar
disabled
deleted
followedByCount
followedByCurrentUser
contributionsCount
commentsCount
badges {
id
key
icon
}
location {
name: name${lang}
}
}
followedByCount
followedByCurrentUser
followedBy(first: 7) {
id
name
disabled
deleted
slug
avatar
followedByCount
followedByCurrentUser
contributionsCount
commentsCount
badges {
id
key
icon
}
location {
name: name${lang}
}
}
contributionsCount
contributions(first: $first, offset: $offset, orderBy: createdAt_desc) {
id
slug
title
contentExcerpt
shoutedCount
commentsCount
deleted
image
createdAt
disabled
deleted
categories {
id
name
icon
}
author {
id
avatar
name
disabled
deleted
location {
name: name${lang}
}
}
}
}
}
`)
}

7
webapp/layouts/README.md Normal file
View File

@ -0,0 +1,7 @@
# LAYOUTS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your Application Layouts.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts).

18
webapp/layouts/blank.vue Normal file
View File

@ -0,0 +1,18 @@
<template>
<div class="layout-blank">
<ds-container>
<div style="padding: 5rem 2rem;">
<nuxt />
</div>
</ds-container>
<div id="overlay" />
</div>
</template>
<script>
import seo from '~/components/mixins/seo'
export default {
mixins: [seo]
}
</script>

285
webapp/layouts/default.vue Normal file
View File

@ -0,0 +1,285 @@
<template>
<div class="layout-default">
<div class="main-navigation">
<ds-container class="main-navigation-container">
<div class="main-navigation-left">
<a
v-router-link
style="display: inline-flex"
href="/"
>
<ds-logo />
</a>
</div>
<div class="main-navigation-center hc-navbar-search">
<search-input
id="nav-search"
:delay="300"
:pending="quickSearchPending"
:results="quickSearchResults"
@clear="quickSearchClear"
@search="value => quickSearch({ value })"
@select="goToPost"
/>
</div>
<div class="main-navigation-right">
<no-ssr>
<locale-switch
class="topbar-locale-switch"
placement="bottom"
offset="23"
/>
</no-ssr>
<template v-if="isLoggedIn">
<no-ssr>
<dropdown class="avatar-menu">
<template
slot="default"
slot-scope="{toggleMenu}"
>
<a
class="avatar-menu-trigger"
:href="$router.resolve({name: 'profile-slug', params: {slug: user.slug}}).href"
@click.prevent="toggleMenu"
>
<ds-avatar
:image="user.avatar"
:name="user.name"
size="42"
/>
<ds-icon
size="xx-small"
name="angle-down"
/>
</a>
</template>
<template
slot="popover"
slot-scope="{closeMenu}"
>
<div class="avatar-menu-popover">
{{ $t('login.hello') }} <b>{{ user.name }}</b>
<template v-if="user.role !== 'user'">
<ds-text
color="softer"
size="small"
style="margin-bottom: 0"
>
{{ user.role | camelCase }}
</ds-text>
</template>
<hr>
<ds-menu
:routes="routes"
:matcher="matcher"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" /> {{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr>
<nuxt-link
class="logout-link"
:to="{ name: 'logout'}"
>
<ds-icon name="sign-out" /> {{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
</no-ssr>
</template>
</div>
</ds-container>
</div>
<ds-container>
<div style="padding: 6rem 2rem 5rem;">
<nuxt />
</div>
</ds-container>
<div id="overlay" />
<no-ssr>
<modal />
</no-ssr>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch'
import Dropdown from '~/components/Dropdown'
import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal'
import seo from '~/components/mixins/seo'
export default {
components: {
Dropdown,
LocaleSwitch,
SearchInput,
Modal,
LocaleSwitch
},
mixins: [seo],
data() {
return {
mobileSearchVisible: false
}
},
computed: {
...mapGetters({
user: 'auth/user',
isLoggedIn: 'auth/isLoggedIn',
isModerator: 'auth/isModerator',
isAdmin: 'auth/isAdmin',
quickSearchResults: 'search/quickResults',
quickSearchPending: 'search/quickPending'
}),
routes() {
if (!this.user.slug) {
return []
}
let routes = [
{
name: this.$t('profile.name'),
path: `/profile/${this.user.slug}`,
icon: 'user'
},
{
name: this.$t('settings.name'),
path: `/settings`,
icon: 'cogs'
}
]
if (this.isModerator) {
routes.push({
name: this.$t('moderation.name'),
path: `/moderation`,
icon: 'balance-scale'
})
}
if (this.isAdmin) {
routes.push({
name: this.$t('admin.name'),
path: `/admin`,
icon: 'shield'
})
}
return routes
}
},
methods: {
...mapActions({
quickSearchClear: 'search/quickClear',
quickSearch: 'search/quickSearch'
}),
goToPost(item) {
this.$nextTick(() => {
this.$router.push({
name: 'post-slug',
params: { slug: item.slug }
})
})
},
matcher(url, route) {
if (url.indexOf('/profile') === 0) {
// do only match own profile
return this.$route.path === url
}
return this.$route.path.indexOf(url) === 0
}
}
}
</script>
<style lang="scss">
.topbar-locale-switch {
display: flex;
margin-right: $space-xx-small;
align-self: center;
display: inline-flex;
}
.main-navigation {
a {
color: $text-color-soft;
}
}
.main-navigation-container {
padding: $space-x-small $space-large !important;
width: 100%;
align-items: center;
display: flex;
}
.main-navigation-left {
display: flex;
flex: 1;
width: 100%;
align-items: center;
}
.main-navigation-center {
display: flex;
flex: auto;
width: 100%;
padding-right: $space-large;
padding-left: $space-large;
}
.main-navigation-right {
display: flex;
flex: 1;
}
.avatar-menu-trigger {
user-select: none;
display: flex;
align-items: center;
padding-left: $space-xx-small;
}
.avatar-menu-popover {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
hr {
color: $color-neutral-90;
background-color: $color-neutral-90;
}
.logout-link {
margin-left: -$space-small;
margin-right: -$space-small;
margin-top: -$space-xxx-small;
margin-bottom: -$space-x-small;
padding: $space-x-small $space-small;
// subtract menu border with from padding
padding-left: $space-small - 2;
color: $text-color-base;
&:hover {
color: $text-color-link-active;
}
}
nav {
margin-left: -$space-small;
margin-right: -$space-small;
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
a {
padding-left: 12px;
}
}
}
</style>

201
webapp/locales/de.json Normal file
View File

@ -0,0 +1,201 @@
{
"login": {
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
"login": "Einloggen",
"logout": "Ausloggen",
"email": "Deine E-Mail",
"password": "Dein Passwort",
"moreInfo": "Was ist Human Connection?",
"hello": "Hallo"
},
"profile": {
"name": "Mein Profil",
"memberSince": "Mitglied seit",
"follow": "Folgen",
"followers": "Folgen",
"following": "Folgt",
"shouted": "Empfohlen",
"commented": "Kommentiert"
},
"search": {
"placeholder": "Suchen",
"hint": "Wonach suchst du?",
"failed": "Nichts gefunden"
},
"settings": {
"name": "Einstellungen",
"data": {
"name": "Deine Daten",
"labelName": "Dein Name",
"labelCity": "Deine Stadt oder Region",
"labelBio": "Über dich"
},
"security": {
"name": "Sicherheit"
},
"invites": {
"name": "Einladungen"
},
"download": {
"name": "Daten herunterladen"
},
"delete": {
"name": "Konto löschen"
},
"organizations": {
"name": "Meine Organisationen"
},
"languages": {
"name": "Sprachen"
}
},
"admin": {
"name": "Systemverwaltung",
"dashboard": {
"name": "Startzentrale",
"users": "Benutzer",
"posts": "Beiträge",
"comments": "Kommentare",
"notifications": "Benachrichtigungen",
"organizations": "Organisationen",
"projects": "Projekte",
"invites": "Einladungen",
"follows": "Folgen",
"shouts": "Shouts"
},
"organizations": {
"name": "Organisationen"
},
"users": {
"name": "Benutzer"
},
"pages": {
"name": "Seiten"
},
"notifications": {
"name": "Benachrichtigungen"
},
"categories": {
"name": "Kategorien",
"categoryName": "Name",
"postCount": "Beiträge"
},
"tags": {
"name": "Schlagworte",
"tagCountUnique": "Benutzer",
"tagCount": "Beiträge"
},
"settings": {
"name": "Einstellungen"
}
},
"post": {
"name": "Beitrag",
"moreInfo": {
"name": "Mehr Info"
},
"takeAction": {
"name": "Aktiv werden"
}
},
"quotes": {
"african": {
"quote": "Viele kleine Leute an vielen kleinen Orten, die viele kleine Dinge tun, werden das Antlitz dieser Welt verändern.",
"author": "Afrikanisches Sprichwort"
}
},
"common": {
"post": "Beitrag ::: Beiträge",
"comment": "Kommentar ::: Kommentare",
"letsTalk": "Miteinander reden",
"versus": "Versus",
"moreInfo": "Mehr Info",
"takeAction": "Aktiv werden",
"shout": "Empfehlung ::: Empfehlungen",
"user": "Benutzer ::: Benutzer",
"category": "Kategorie ::: Kategorien",
"organization": "Organisation ::: Organisationen",
"project": "Projekt ::: Projekte",
"tag": "Tag ::: Tags",
"name": "Name",
"loadMore": "mehr laden",
"loading": "wird geladen",
"reportContent": "Melden"
},
"actions": {
"loading": "lade",
"loadMore": "mehr laden",
"create": "Erstellen",
"save": "Speichern",
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen"
},
"moderation": {
"name": "Moderation",
"reports": {
"empty": "Glückwunsch, es gibt nichts zu moderieren.",
"name": "Meldungen",
"submitter": "gemeldet von",
"disabledBy": "deaktiviert von"
}
},
"disable": {
"submit": "Deaktivieren",
"cancel": "Abbrechen",
"success": "Erfolgreich deaktiviert",
"user": {
"title": "Nutzer sperren",
"type": "Nutzer",
"message": "Bist du sicher, dass du den Nutzer \"<b>{name}</b>\" deaktivieren möchtest?"
},
"contribution": {
"title": "Beitrag sperren",
"type": "Beitrag",
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" deaktivieren möchtest?"
},
"comment": {
"title": "Kommentar sperren",
"type": "Kommentar",
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" deaktivieren möchtest?"
}
},
"report": {
"submit": "Melden",
"cancel": "Abbrechen",
"success": "Vielen Dank für diese Meldung!",
"user": {
"title": "Nutzer melden",
"type": "Nutzer",
"message": "Bist du sicher, dass du den Nutzer \"<b>{name}</b>\" melden möchtest?"
},
"contribution": {
"title": "Beitrag melden",
"type": "Beitrag",
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" melden möchtest?"
},
"comment": {
"title": "Kommentar melden",
"type": "Kommentar",
"message": "Bist du sicher, dass du den Kommentar von \"<b>{name}</b>\" melden möchtest?"
}
},
"contribution": {
"edit": "Beitrag bearbeiten",
"delete": "Beitrag löschen"
},
"comment": {
"edit": "Kommentar bearbeiten",
"delete": "Kommentar löschen",
"content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
}
},
"followButton": {
"follow": "Folgen",
"following": "Folge Ich"
},
"shoutButton": {
"shouted": "empfohlen"
}
}

201
webapp/locales/en.json Normal file
View File

@ -0,0 +1,201 @@
{
"login": {
"copy": "If you already have a human-connection account, login here.",
"login": "Login",
"logout": "Logout",
"email": "Your Email",
"password": "Your Password",
"moreInfo": "What is Human Connection?",
"hello": "Hello"
},
"profile": {
"name": "My Profile",
"memberSince": "Member since",
"follow": "Follow",
"followers": "Followers",
"following": "Following",
"shouted": "Shouted",
"commented": "Commented"
},
"search": {
"placeholder": "Search",
"hint": "What are you searching for?",
"failed": "Nothing found"
},
"settings": {
"name": "Settings",
"data": {
"name": "Your data",
"labelName": "Your Name",
"labelCity": "Your City or Region",
"labelBio": "About You"
},
"security": {
"name": "Security"
},
"invites": {
"name": "Invites"
},
"download": {
"name": "Download Data"
},
"delete": {
"name": "Delete Account"
},
"organizations": {
"name": "My Organizations"
},
"languages": {
"name": "Languages"
}
},
"admin": {
"name": "Admin",
"dashboard": {
"name": "Dashboard",
"users": "Users",
"posts": "Posts",
"comments": "Comments",
"notifications": "Notifications",
"organizations": "Organizations",
"projects": "Projects",
"invites": "Invites",
"follows": "Follows",
"shouts": "Shouts"
},
"organizations": {
"name": "Organizations"
},
"users": {
"name": "Users"
},
"pages": {
"name": "Pages"
},
"notifications": {
"name": "Notifications"
},
"categories": {
"name": "Categories",
"categoryName": "Name",
"postCount": "Posts"
},
"tags": {
"name": "Tags",
"tagCountUnique": "Users",
"tagCount": "Posts"
},
"settings": {
"name": "Settings"
}
},
"post": {
"name": "Post",
"moreInfo": {
"name": "More info"
},
"takeAction": {
"name": "Take action"
}
},
"quotes": {
"african": {
"quote": "Many small people in many small places do many small things, that can alter the face of the world.",
"author": "African proverb"
}
},
"common": {
"post": "Post ::: Posts",
"comment": "Comment ::: Comments",
"letsTalk": "Let`s Talk",
"versus": "Versus",
"moreInfo": "More Info",
"takeAction": "Take Action",
"shout": "Shout ::: Shouts",
"user": "User ::: Users",
"category": "Category ::: Categories",
"organization": "Organization ::: Organizations",
"project": "Project ::: Projects",
"tag": "Tag ::: Tags",
"name": "Name",
"loadMore": "load more",
"loading": "loading",
"reportContent": "Report"
},
"actions": {
"loading": "loading",
"loadMore": "load more",
"create": "Create",
"save": "Save",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel"
},
"moderation": {
"name": "Moderation",
"reports": {
"empty": "Congratulations, nothing to moderate.",
"name": "Reports",
"submitter": "reported by",
"disabledBy": "disabled by"
}
},
"disable": {
"submit": "Disable",
"cancel": "Cancel",
"success": "Disabled successfully",
"user": {
"title": "Disable User",
"type": "User",
"message": "Do you really want to disable the user \"<b>{name}</b>\"?"
},
"contribution": {
"title": "Disable Contribution",
"type": "Contribution",
"message": "Do you really want to disable the contribution \"<b>{name}</b>\"?"
},
"comment": {
"title": "Disable Comment",
"type": "Comment",
"message": "Do you really want to disable the comment from \"<b>{name}</b>\"?"
}
},
"report": {
"submit": "Report",
"cancel": "Cancel",
"success": "Thanks for reporting!",
"user": {
"title": "Report User",
"type": "User",
"message": "Do you really want to report the user \"<b>{name}</b>\"?"
},
"contribution": {
"title": "Report Post",
"type": "Contribution",
"message": "Do you really want to report the contribution \"<b>{name}</b>\"?"
},
"comment": {
"title": "Report Comment",
"type": "Comment",
"message": "Do you really want to report the comment from \"<b>{name}</b>\"?"
}
},
"contribution": {
"edit": "Edit Contribution",
"delete": "Delete Contribution"
},
"comment": {
"edit": "Edit Comment",
"delete": "Delete Comment",
"content": {
"unavailable-placeholder": "...this comment is not available anymore"
}
},
"followButton": {
"follow": "Follow",
"following": "Following"
},
"shoutButton": {
"shouted": "shouted"
}
}

116
webapp/locales/es.json Normal file
View File

@ -0,0 +1,116 @@
{
"login": {
"copy": "Si ya tiene una cuenta de Human Connection, inicie sesión aquí.",
"login": "Iniciar sesión",
"logout": "Cierre de sesión",
"email": "Tu correo electrónico",
"password": "Tu contraseña",
"moreInfo": "¿Qué es Human Connection?",
"hello": "Hola"
},
"profile": {
"name": "Mi perfil",
"memberSince": "Miembro desde",
"follow": "Seguir",
"followers": "Seguidores",
"following": "Siguiendo",
"shouted": "Gritar",
"commented": "Comentado"
},
"settings": {
"name": "Configuración",
"data": {
"name": "Sus datos"
},
"security": {
"name": "Seguridad"
},
"invites": {
"name": "Invita"
},
"download": {
"name": "Descargar datos"
},
"delete": {
"name": "Borrar cuenta"
},
"organizations": {
"name": "Mis organizaciones"
},
"languages": {
"name": "Idiomas"
}
},
"admin": {
"name": "Admin",
"dashboard": {
"name": "Tablero",
"users": "Usuarios",
"posts": "Mensajes",
"comments": "Comentarios",
"notifications": "Notificaciones",
"organizations": "Organizaciones",
"projects": "Proyectos",
"invites": "Invita",
"follows": "Sigue",
"shouts": "Gritos"
},
"organizations": {
"name": "Organizaciones"
},
"users": {
"name": "Usuarios"
},
"pages": {
"name": "Páginas"
},
"notifications": {
"name": "Notificaciones"
},
"categories": {
"name": "Categorías",
"categoryName": "Nombre",
"postCount": "Mensajes"
},
"tags": {
"name": "Etiquetas",
"tagCountUnique": "Usuarios",
"tagCount": "Mensajes"
},
"settings": {
"name": "Configuración"
}
},
"post": {
"name": "Mensaje",
"moreInfo": {
"name": "Más info"
},
"takeAction": {
"name": "Tomar acción"
}
},
"quotes": {
"african": {
"quote": "Muchas personas pequeñas en muchos lugares pequeños hacen muchas cosas pequeñas, que pueden alterar la faz del mundo.",
"author": "Proverbio africano"
}
},
"common": {
"post": "Mensaje ::: Mensajes",
"comment": "Comentario ::: Comentarios",
"letsTalk": "Hablemos",
"versus": "Versus",
"moreInfo": "Más info",
"takeAction": "Tomar acción",
"shout": "Grito ::: Gritos",
"user": "Usuario ::: Usuarios",
"category": "Categoría ::: Categorías",
"organization": "Organización ::: Organizaciones",
"project": "Proyecto ::: Proyectos",
"tag": "Etiqueta ::: Etiquetas",
"name": "Nombre",
"loadMore": "cargar más",
"loading": "cargando"
}
}

169
webapp/locales/fr.json Normal file
View File

@ -0,0 +1,169 @@
{
"login": {
"copy": "Si vous avez déjà un compte human-connection, connectez-vous ici.",
"login": "Connexion",
"logout": "Déconnexion",
"email": "Votre courriel",
"password": "Votre mot de passe",
"moreInfo": "Qu'est-ce que Human Connection?",
"hello": "Bonjour"
},
"profile": {
"name": "Mon profil",
"memberSince": "Membre depuis",
"follow": "Suivre",
"followers": "Suiveurs",
"following": "Suivant"
},
"settings": {
"name": "Paramètres",
"data": {
"name": "Vos données"
},
"security": {
"name": "Sécurité"
},
"invites": {
"name": "Invite"
},
"download": {
"name": "Télécharger les données"
},
"delete": {
"name": "Supprimer un compte"
},
"organizations": {
"name": "Mes organisations"
},
"languages": {
"name": "Langues"
}
},
"admin": {
"name": "Admin",
"dashboard": {
"name": "Tableau de bord",
"users": "Utilisateurs",
"posts": "Postes",
"comments": "Commentaires",
"notifications": "Notifications",
"organizations": "Organisations",
"projects": "Projets",
"invites": "Invite",
"follows": "Suit",
"shouts": "Cris"
},
"organizations": {
"name": "Organisations"
},
"users": {
"name": "Utilisateurs"
},
"pages": {
"name": "Pages"
},
"notifications": {
"name": "Notifications"
},
"categories": {
"name": "Catégories",
"categoryName": "Nom",
"postCount": "Postes"
},
"tags": {
"name": "Étiquettes",
"tagCountUnique": "Utilisateurs",
"tagCount": "Postes"
},
"settings": {
"name": "Paramètres"
}
},
"post": {
"name": "Post",
"moreInfo": {
"name": "Plus d'infos"
},
"takeAction": {
"name": "Passez à l'action"
}
},
"quotes": {
"african": {
"quote": "Beaucoup de petites personnes dans beaucoup de petits endroits font beaucoup de petites choses, qui peuvent changer la face du monde.",
"author": "Proverbe africain"
}
},
"common": {
"post": "Message ::: Messages",
"comment": "Commentaire ::: Commentaires",
"letsTalk": "Parlons-en",
"versus": "Versus",
"moreInfo": "Plus d'infos",
"takeAction": "Passer à l'action",
"shout": "Shout ::: Shouts",
"user": "Utilisateur ::: Utilisateurs",
"category": "Catégorie ::: Catégories",
"organization": "Organisation ::: Organisations",
"project": "Projet ::: Projets",
"tag": "Tag ::: Tags",
"name": "Nom",
"loadMore": "charger plus",
"loading": "chargement",
"reportContent": "Signaler"
},
"moderation": {
"reports": {
"empty": "Félicitations, rien à modérer.",
"name": "Signalisations",
"reporter": "signalé par"
}
},
"disable": {
"user": {
"title": "Désactiver l'utilisateur",
"type": "Utilisateur",
"message": "Souhaitez-vous vraiment désactiver l'utilisateur \" <b> {name} </b> \"?"
},
"contribution": {
"title": "Désactiver l'apport",
"type": "apport",
"message": "Souhaitez-vous vraiment signaler l'entrée\" <b> {name} </b> \"?"
},
"comment": {
"title": "Désactiver le commentaire",
"type": "Commentaire",
"message": "Souhaitez-vous vraiment désactiver le commentaire de \"<b>{name}</b>\" ?"
}
},
"report": {
"submit": "Envoyer le rapport",
"cancel": "Annuler",
"user": {
"title": "Signaler l'utilisateur",
"type": "Utilisateur",
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?"
},
"contribution": {
"title": "Signaler l'entrée",
"type": "Apport",
"message": "Souhaitez-vous vraiment signaler l'entrée\" <b> {name} </b> \"?"
},
"comment": {
"title": "Signaler un commentaire",
"type": "Commentaire",
"message": "Souhaitez-vous vraiment signaler l'utilisateur \" <b> {name} </b> \"?"
}
},
"actions": {
"cancel": "Annuler"
},
"contribution": {
"edit": "Rédiger l'apport",
"delete": "Supprimer l'entrée"
},
"comment": {
"edit": "Rédiger un commentaire",
"delete": "Supprimer le commentaire"
}
}

50
webapp/locales/index.js Normal file
View File

@ -0,0 +1,50 @@
module.exports = [
{
name: 'English',
code: 'en',
iso: 'en-US',
enabled: true
},
{
name: 'Deutsch',
code: 'de',
iso: 'de-DE',
enabled: true
},
{
name: 'Nederlands',
code: 'nl',
iso: 'nl-NL',
enabled: true
},
{
name: 'Français',
code: 'fr',
iso: 'fr-FR',
enabled: true
},
{
name: 'Italiano',
code: 'it',
iso: 'it-IT',
enabled: true
},
{
name: 'Español',
code: 'es',
iso: 'es-ES',
enabled: true
},
{
name: 'Português',
code: 'pt',
iso: 'pt-PT',
enabled: true
},
{
name: 'Polski',
code: 'pl',
iso: 'pl-PL',
enabled: true
}
]

127
webapp/locales/it.json Normal file
View File

@ -0,0 +1,127 @@
{
"login": {
"copy": "Se sei gia registrato su Human Connection, accedi qui.",
"login": "Accesso",
"logout": "Logout",
"email": "La tua email",
"password": "La tua password",
"moreInfo": "Che cosa è Human Connection?",
"hello": "Ciao"
},
"profile": {
"name": "Il mio profilo",
"memberSince": "Membro dal",
"follow": "Seguire",
"followers": "Seguenti",
"following": "Seguendo",
"shouted": "Gridato",
"commented": "Commentato"
},
"settings": {
"name": "Impostazioni",
"data": {
"name": "I tuoi dati",
"labelName": "Nome",
"labelCity": "La tua città o regione",
"labelBio": "Su di te"
},
"security": {
"name": "Sicurezza"
},
"invites": {
"name": "Inviti"
},
"download": {
"name": "Scaricamento dati"
},
"delete": {
"name": "Elimina Account"
},
"organizations": {
"name": "Mie organizzazioni"
},
"languages": {
"name": "Lingue"
}
},
"admin": {
"name": "Admin",
"dashboard": {
"name": "Cruscotto",
"users": "Utenti",
"posts": "Messaggi",
"comments": "Commenti",
"notifications": "Notifiche",
"organizations": "Organizzazioni",
"projects": "Progetti",
"invites": "Inviti",
"follows": "Segue",
"shouts": "Gridi"
},
"organizations": {
"name": "Organizzazioni"
},
"users": {
"name": "Utenti"
},
"pages": {
"name": "Pagine"
},
"notifications": {
"name": "Notifiche"
},
"categories": {
"name": "Categorie",
"categoryName": "Nome",
"postCount": "Messaggi"
},
"tags": {
"name": "Tag",
"tagCountUnique": "Utenti",
"tagCount": "Messaggi"
},
"settings": {
"name": "Impostazioni"
}
},
"post": {
"name": "Messaggio",
"moreInfo": {
"name": "Ulteriori informazioni"
},
"takeAction": {
"name": "Agire"
}
},
"quotes": {
"african": {
"quote": "Molte piccole persone in molti piccoli luoghi fanno molte piccole cose, che possono cambiare la faccia del mondo.",
"author": "Proverbio africano"
}
},
"common": {
"post": "Messaggio ::: Messaggi",
"comment": "Commento ::: Commenti",
"letsTalk": "Discutiamo",
"versus": "Verso",
"moreInfo": "Ulteriori informazioni",
"takeAction": "Agire",
"shout": "Grido ::: Gridi",
"user": "Utente ::: Utenti",
"category": "Categoria ::: Categorie",
"organization": "Organizzazione ::: Organizzazioni",
"project": "Progetto ::: Progetti",
"tag": "Tag ::: Tag",
"name": "Nome",
"loadMore": "Caricare di più",
"loading": "Caricamento in corso"
},
"actions": {
"loading": "Caricamento in corso",
"loadMore": "Carica di più",
"create": "Crea",
"save": "Salva",
"edit": "Modifica",
"delete": "Cancella"
}
}

159
webapp/locales/nl.json Normal file
View File

@ -0,0 +1,159 @@
{
"login": {
"copy": "Als u al een mini-aansluiting account heeft, log dan hier in.",
"login": "Inloggen",
"logout": "Uitloggen",
"email": "Uw E-mail",
"password": "Uw Wachtwoord",
"moreInfo": "Wat is Human Connection?",
"hello": "Hallo"
},
"profile": {
"name": "Mijn profiel",
"memberSince": "Lid sinds",
"follow": "Volgen",
"followers": "Volgelingen",
"following": "Volgt"
},
"settings": {
"name": "Instellingen",
"data": {
"name": "Uw gegevens"
},
"security": {
"name": "Veiligheid"
},
"invites": {
"name": "Uitnodigingen"
},
"download": {
"name": "Gegevens downloaden"
},
"delete": {
"name": "Account verwijderen"
},
"organizations": {
"name": "Mijn Organisaties"
},
"languages": {
"name": "Talen"
}
},
"admin": {
"name": "Admin",
"dashboard": {
"name": "Dashboard",
"users": "Gebruikers",
"posts": "Berichten",
"comments": "Opmerkingen",
"notifications": "Meldingen",
"organizations": "Organisaties",
"projects": "Projecten",
"invites": "Uitnodigingen",
"follows": "Volgt",
"shouts": "Shouts"
},
"organizations": {
"name": "Organisaties"
},
"users": {
"name": "Gebruikers"
},
"pages": {
"name": "Pages"
},
"notifications": {
"name": "Meldingen"
},
"categories": {
"name": "Categorieën",
"categoryName": "Naam",
"postCount": "Berichten"
},
"tags": {
"name": "Tags",
"tagCountUnique": "Gebruikers",
"tagCount": "Berichten"
},
"settings": {
"name": "Instellingen"
}
},
"post": {
"name": "Post",
"moreInfo": {
"name": "Meer info"
},
"takeAction": {
"name": "Onderneem actie"
}
},
"quotes": {
"african": {
"quote": "Veel kleine mensen op veel kleine plaatsen doen veel kleine dingen, die het gezicht van de wereld kunnen veranderen.",
"author": "Afrikaans spreekwoord"
}
},
"common": {
"post": "Bericht ::: Berichten",
"comment": "Opmerking ::: Opmerkingen",
"letsTalk": "Laten we praten",
"versus": "Versus",
"moreInfo": "Meer info",
"takeAction": "Onderneem actie",
"shout": "Shout ::: Shouts",
"user": "Gebruiker ::: Gebruikers",
"category": "Categorie ::: Categorieën",
"organization": "Organisatie ::: Organisaties",
"project": "Project ::: Projecten",
"tag": "Tag ::: Tags",
"name": "Naam",
"loadMore": "meer laden",
"loading": "inlading",
"reportContent": "Melden"
},
"disable": {
"comment": {
"title": "Commentaar uitschakelen",
"type": "Melding",
"message": "Wilt u de reactie van \" <b> {name} </b> \" echt uitschakelen ?"
}
},
"report": {
"submit": "Verzenden",
"cancel": "Annuleren",
"user": {
"title": "Gebruiker melden",
"type": "Gebruiker",
"message": "Wilt u echt het commentaar van \"<b>{name}</b>\" melden?"
},
"contribution": {
"title": "Bijdrage melden",
"type": "Bijdrage",
"message": "Wilt u echt het commentaar van \"<b>{name}</b>\" melden?"
},
"comment": {
"title": "Reactie melden",
"type": "Melding",
"message": "Wilt u echt het commentaar van \"<b>{name}</b>\" melden?"
}
},
"actions": {
"cancel": "Annuleren"
},
"contribution": {
"edit": "Bijdrage bewerken",
"delete": "Bijdrage verwijderen"
},
"comment": {
"edit": "Commentaar bewerken",
"delete": "Commentaar verwijderen"
},
"followButton": {
"follow": "Volgen",
"following": "Volgt"
},
"shoutButton": {
"shouted": "uitgeroepen"
}
}

188
webapp/locales/pl.json Normal file
View File

@ -0,0 +1,188 @@
{
"login": {
"copy": "Jeśli masz już konto Human Connection, zaloguj się tutaj.",
"login": "logowanie",
"logout": "Wyloguj się",
"email": "Twój adres e-mail",
"password": "Twoje hasło",
"moreInfo": "Co to jest Human Connection?",
"hello": "Cześć"
},
"profile": {
"name": "Mój profil",
"memberSince": "Członek od",
"follow": "Obserwuj",
"followers": "Obserwujący",
"following": "Obserwowani",
"shouted": "Krzyknij",
"commented": "Skomentuj"
},
"settings": {
"name": "Ustawienia",
"data": {
"name": "Twoje dane",
"labelName": "Twoje dane",
"labelCity": "Twoje miasto lub region",
"labelBio": "O Tobie"
},
"security": {
"name": "Bezpieczeństwo"
},
"invites": {
"name": "Zaproszenia"
},
"download": {
"name": "Pobierz dane"
},
"delete": {
"name": "Usuń konto"
},
"organizations": {
"name": "Moje organizacje"
},
"languages": {
"name": "Języki"
}
},
"admin": {
"name": "Administrator",
"dashboard": {
"name": "Tablica rozdzielcza",
"users": "Użytkownicy",
"posts": "Posty",
"comments": "Komentarze",
"notifications": "Powiadomienia",
"organizations": "Organizacje",
"projects": "Projekty",
"invites": "Zaproszenia",
"follows": "Obserwowań",
"shouts": "Okrzyk"
},
"organizations": {
"name": "Organizacje"
},
"users": {
"name": "Użytkownicy"
},
"pages": {
"name": "Strony"
},
"notifications": {
"name": "Powiadomienia"
},
"categories": {
"name": "Kategorie",
"categoryName": "Nazwa",
"postCount": "Posty"
},
"tags": {
"name": "Tagi",
"tagCountUnique": "Użytkownicy",
"tagCount": "Posty"
},
"settings": {
"name": "Ustawienia"
}
},
"post": {
"name": "Post",
"moreInfo": {
"name": "Więcej informacji"
},
"takeAction": {
"name": "Podejmij działanie"
}
},
"quotes": {
"african": {
"quote": "Wielu małych ludzi w wielu małych miejscach robi wiele małych rzeczy, które mogą zmienić oblicze świata.",
"author": "Afrykańskie przysłowie"
}
},
"common": {
"post": "Post ::: Posty",
"comment": "Komentarz ::: Komentarze",
"letsTalk": "Porozmawiajmy",
"versus": "Versus",
"moreInfo": "Więcej informacji",
"takeAction": "Podejmij działanie",
"shout": "okrzyk okrzyki",
"user": "Użytkownik ::: Użytkownicy",
"category": "kategoria kategorie",
"organization": "Organizacja ::: Organizacje",
"project": "Projekt ::: Projekty",
"tag": "Tag ::: Tagi",
"name": "imię",
"loadMore": "załaduj więcej",
"loading": "ładowanie",
"reportContent": "Raport"
},
"actions": {
"loading": "ładowanie",
"loadMore": "załaduj więcej",
"create": "Stwórz",
"save": "Zapisz",
"edit": "Edytuj",
"delete": "Usuń",
"cancel": "Anuluj"
},
"moderation": {
"name": "Moderacja",
"reports": {
"empty": "Gratulacje, moderacja nie jest potrzebna",
"name": "Raporty",
"reporter": "zgłoszone przez"
}
},
"disable": {
"user": {
"title": "Ukryj użytkownika",
"type": "Użytkownik",
"message": "Czy na pewno chcesz wyłączyć użytkownika \" <b> {name} </b> \"?"
},
"contribution": {
"title": "Ukryj wpis",
"type": "Wpis / Post",
"message": "Czy na pewno chcesz ukryć wpis \" <b> tytuł} </b> \"?"
},
"comment": {
"title": "Ukryj wpis",
"type": "Komentarz",
"message": "Czy na pewno chcesz ukryć komentarz użytkownika\"<b>(Imie/Avatar</b>\"?"
}
},
"report": {
"submit": "Wyślij raport",
"cancel": "Anuluj",
"user": {
"title": "Zgłoś użytkownika",
"type": "Użytkownik",
"message": "Czy na pewno chcesz zgłosić użytkownika \" <b> {Imie} </b> \"?"
},
"contribution": {
"title": "Zgłoś wpis",
"type": "Wpis / Post",
"message": "Czy na pewno chcesz zgłosić ten wpis użytkownika \" <b> {Imie} </b> \"?"
},
"comment": {
"title": "Zgłoś komentarz",
"type": "Komentarz",
"message": "Czy na pewno chcesz zgłosić komentarz użytkownika\"<b>(Imie/Avatar</b>\"?"
}
},
"contribution": {
"edit": "Edytuj wpis",
"delete": "Usuń wpis"
},
"comment": {
"edit": "Edytuj komentarz",
"delete": "Usuń komentarz"
},
"followButton": {
"follow": "Obserwuj",
"following": "Obserwowani"
},
"shoutButton": {
"shouted": "krzyczeć"
}
}

188
webapp/locales/pt.json Normal file
View File

@ -0,0 +1,188 @@
{
"login": {
"copy": "Se você já tem uma conta no Human Connection, entre aqui.",
"login": "Entrar",
"logout": "Sair",
"email": "Seu email",
"password": "Sua senha",
"moreInfo": "O que é o Human Connection?",
"hello": "Olá"
},
"profile": {
"name": "Meu perfil",
"memberSince": "Membro desde",
"follow": "Seguir",
"followers": "Seguidores",
"following": "Seguindo",
"shouted": "Aclamou",
"commented": "Comentou"
},
"settings": {
"name": "Configurações",
"data": {
"name": "Seus dados",
"labelName": "Seu nome",
"labelCity": "Sua cidade ou estado",
"labelBio": "Sobre você"
},
"security": {
"name": "Segurança"
},
"invites": {
"name": "Convites"
},
"download": {
"name": "Baixar dados"
},
"delete": {
"name": "Apagar conta"
},
"organizations": {
"name": "Minhas Organizações"
},
"languages": {
"name": "Linguagens"
}
},
"admin": {
"name": "Administração",
"dashboard": {
"name": "Painel de controle",
"users": "Usuários",
"posts": "Postagens",
"comments": "Comentários",
"notifications": "Notificações",
"organizations": "Organizações",
"projects": "Projetos",
"invites": "Convites",
"follows": "Segue",
"shouts": "Aclamações"
},
"organizations": {
"name": "Organizações"
},
"users": {
"name": "Usuários"
},
"pages": {
"name": "Páginas"
},
"notifications": {
"name": "Notificações"
},
"categories": {
"name": "Categorias",
"categoryName": "Nome",
"postCount": "Postagens"
},
"tags": {
"name": "Etiquetas",
"tagCountUnique": "Usuários",
"tagCount": "Postagens"
},
"settings": {
"name": "Configurações"
}
},
"post": {
"name": "Postar",
"moreInfo": {
"name": "Mais informações"
},
"takeAction": {
"name": "Tomar uma ação"
}
},
"quotes": {
"african": {
"quote": "Muitas pessoas pequenas, em muitos lugares pequenos, fazem muitas coisas pequenas, que podem mudar a face do mundo.",
"author": "Provérbio africano"
}
},
"common": {
"post": "Postagem ::: Postagens",
"comment": "Comentário ::: Comentários",
"letsTalk": "Vamos Conversar",
"versus": "Contra",
"moreInfo": "Mais informações",
"takeAction": "Tomar uma ação",
"shout": "Aclamação ::: Aclamações",
"user": "Usuário ::: Usuários",
"category": "Categoria ::: Categorias",
"organization": "Organização ::: Organizações",
"project": "Projeto ::: Projetos",
"tag": "Etiqueta ::: Etiquetas",
"name": "Nome",
"loadMore": "Carregar mais",
"loading": "Carregando",
"reportContent": "Denunciar"
},
"actions": {
"loading": "Carregando",
"loadMore": "Carregar mais",
"create": "Criar",
"save": "Salvar",
"edit": "Editar",
"delete": "Apagar",
"cancel": "Cancelar"
},
"moderation": {
"name": "Moderação",
"reports": {
"empty": "Parabéns, nada a moderar.",
"name": "Denúncias",
"reporter": "Denunciado por"
}
},
"disable": {
"user": {
"title": "Desativar usuário",
"type": "Usuário",
"message": "Você realmente deseja desativar o usuário \" <b> {name} </b> \"?"
},
"contribution": {
"title": "Desativar Contribuição",
"type": "Contribuição",
"message": "Você realmente deseja desativar a contribuição \" <b> {name} </b> \"?"
},
"comment": {
"title": "Desativar comentário",
"type": "Comentário",
"message": "Você realmente deseja desativar o comentário de \" <b> {name} </b> \"?"
}
},
"report": {
"submit": "Enviar denúncia",
"cancel": "Cancelar",
"user": {
"title": "Denunciar usuário",
"type": "Usuário",
"message": "Você realmente deseja denunciar o usuário \" <b> {name} </b> \"?"
},
"contribution": {
"title": "Denunciar Contribuição",
"type": "Contribuição",
"message": "Você realmente deseja denunciar a contribuição \" <b> {name} </b> \"?"
},
"comment": {
"title": "Denunciar Comentário",
"type": "Comentário",
"message": "Você realmente deseja denunciar o comentário de \"<b>{name}</b>\"?"
}
},
"contribution": {
"edit": "Editar Contribuição",
"delete": "Apagar Contribuição"
},
"comment": {
"edit": "Editar Comentário",
"delete": "Apagar Comentário"
},
"followButton": {
"follow": "Seguir",
"following": "Seguindo"
},
"shoutButton": {
"shouted": "Aclamou"
}
}

BIN
webapp/lokalise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,8 @@
# MIDDLEWARE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your application middleware.
The middleware lets you define custom function to be ran before rendering a page or a group of pages (layouts).
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).

View File

@ -0,0 +1,26 @@
import isEmpty from 'lodash/isEmpty'
export default async ({ store, env, route, redirect }) => {
let publicPages = env.publicPages
// only affect non public pages
if (publicPages.indexOf(route.name) >= 0) {
return true
}
// await store.dispatch('auth/refreshJWT', 'authenticated middleware')
const isAuthenticated = await store.dispatch('auth/check')
if (isAuthenticated === true) {
return true
}
// try to logout user
// await store.dispatch('auth/logout', null, { root: true })
// set the redirect path for after the login
let params = {}
if (!isEmpty(route.path) && route.path !== '/') {
params.path = route.path
}
return redirect('/login', params)
}

View File

@ -0,0 +1,5 @@
export default ({ store, error }) => {
if (!store.getters['auth/isAdmin']) {
return error({ statusCode: 403 })
}
}

View File

@ -0,0 +1,5 @@
export default ({ store, error }) => {
if (!store.getters['auth/isModerator']) {
return error({ statusCode: 403 })
}
}

223
webapp/nuxt.config.js Normal file
View File

@ -0,0 +1,223 @@
const pkg = require('./package')
const envWhitelist = ['NODE_ENV', 'MAINTENANCE', 'MAPBOX_TOKEN']
const dev = process.env.NODE_ENV !== 'production'
const styleguidePath = '../Nitro-Styleguide'
const styleguideStyles = process.env.STYLEGUIDE_DEV
? [
`${styleguidePath}/src/system/styles/main.scss`,
`${styleguidePath}/src/system/styles/shared.scss`
]
: '@human-connection/styleguide/dist/shared.scss'
module.exports = {
mode: 'universal',
dev: dev,
debug: dev ? 'nuxt:*,app' : null,
modern: !dev ? 'server' : false,
transition: {
name: 'slide-up',
mode: 'out-in'
},
env: {
// pages which do NOT require a login
publicPages: [
'login',
'logout',
'register',
'signup',
'reset',
'reset-token',
'pages-slug'
],
// pages to keep alive
keepAlivePages: ['index'],
// active locales
locales: require('./locales')
},
/*
** Headers of the page
*/
head: {
title: 'Human Connection',
titleTemplate: '%s - Human Connection',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: pkg.description }
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
},
/*
** Customize the progress-bar color
*/
loading: {
color: '#86b31e',
height: '2px',
duration: 20000
},
/*
** Global CSS
*/
css: ['~assets/styles/main.scss'],
/*
** Global processed styles
*/
styleResources: {
scss: styleguideStyles
},
/*
** Plugins to load before mounting the App
*/
plugins: [
{
src: `~/plugins/styleguide${process.env.STYLEGUIDE_DEV ? '-dev' : ''}.js`,
ssr: true
},
{ src: '~/plugins/i18n.js', ssr: true },
{ src: '~/plugins/axios.js', ssr: false },
{ src: '~/plugins/keep-alive.js', ssr: false },
{ src: '~/plugins/vue-directives.js', ssr: false },
{ src: '~/plugins/v-tooltip.js', ssr: false },
{ src: '~/plugins/izi-toast.js', ssr: false },
{ src: '~/plugins/vue-filters.js' }
],
router: {
middleware: ['authenticated'],
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active',
scrollBehavior: () => {
return { x: 0, y: 0 }
}
},
/*
** Nuxt.js modules
*/
modules: [
['@nuxtjs/dotenv', { only: envWhitelist }],
['nuxt-env', { keys: envWhitelist }],
'cookie-universal-nuxt',
'@nuxtjs/apollo',
'@nuxtjs/axios',
'@nuxtjs/style-resources'
],
/*
** Axios module configuration
*/
axios: {
// See https://github.com/nuxt-community/axios-module#options
debug: dev,
proxy: true
},
proxy: {
'/api': {
// make this configurable (nuxt-dotenv)
target: process.env.BACKEND_URL || 'http://localhost:4000',
pathRewrite: { '^/api': '' },
toProxy: true, // cloudflare needs that
changeOrigin: true,
headers: {
Accept: 'application/json',
'X-UI-Request': true,
'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL'
}
}
},
// Give apollo module options
apollo: {
tokenName: 'human-connection-token', // optional, default: apollo-token
tokenExpires: 3, // optional, default: 7 (days)
// includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder)
// optional
errorHandler(error) {
console.log(
'%cError',
'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;',
error.message
)
},
// Watch loading state for all queries
// See 'Smart Query > options > watchLoading' for detail
// TODO: find a way to get this working
// watchLoading(isLoading) {
// console.log('Global loading', countModifier)
// this.$nuxt.$loading.start()
// },
// required
clientConfigs: {
default: '~/plugins/apollo-config.js'
}
},
manifest: {
name: 'Human-Connection.org',
description: 'Human-Connection.org',
theme_color: '#ffffff',
lang: 'de'
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {
// Run ESLint on save
if (ctx.isDev && ctx.isClient) {
config.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /(node_modules)/
})
}
if (process.env.STYLEGUIDE_DEV) {
const path = require('path')
config.resolve.alias['@@'] = path.resolve(
__dirname,
`${styleguidePath}/src/system`
)
config.module.rules.push({
resourceQuery: /blockType=docs/,
loader: require.resolve(
`${styleguidePath}/src/loader/docs-trim-loader.js`
)
})
}
const svgRule = config.module.rules.find(rule => rule.test.test('.svg'))
svgRule.test = /\.(png|jpe?g|gif|webp)$/
config.module.rules.push({
test: /\.svg$/,
loader: 'vue-svg-loader',
options: {
svgo: {
plugins: [
{
removeViewBox: false
},
{
removeDimensions: true
}
]
}
}
})
}
}
}

90
webapp/package.json Normal file
View File

@ -0,0 +1,90 @@
{
"name": "hc-webapp-next",
"version": "1.0.0",
"description": "Human Connection GraphQL UI Prototype",
"author": "Grzegorz Leoniec",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
"dev:styleguide": "cross-env STYLEGUIDE_DEV=true yarn dev",
"build": "nuxt build",
"start": "cross-env node server/index.js",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue .",
"test": "jest",
"precommit": "yarn lint",
"e2e:local": "cypress run --headed",
"e2e:ci": "npm-run-all --parallel --race start:ci 'cypress:ci --config baseUrl=http://localhost:3000'",
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand"
},
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true
},
"jest": {
"verbose": true,
"moduleFileExtensions": [
"js",
"json",
"vue"
],
"transform": {
".*\\.(vue)$": "vue-jest",
"^.+\\.js$": "<rootDir>/node_modules/babel-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"^~/(.*)$": "<rootDir>/$1"
}
},
"dependencies": {
"@human-connection/styleguide": "0.5.15",
"@nuxtjs/apollo": "4.0.0-rc4",
"@nuxtjs/axios": "~5.4.1",
"@nuxtjs/dotenv": "~1.3.0",
"@nuxtjs/style-resources": "~0.1.2",
"accounting": "~0.4.1",
"apollo-cache-inmemory": "~1.5.1",
"apollo-client": "~2.5.1",
"cookie-universal-nuxt": "~2.0.14",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-alpha.27",
"express": "~4.16.4",
"graphql": "~14.1.1",
"jsonwebtoken": "~8.5.1",
"linkify-it": "~2.1.0",
"nuxt": "~2.4.5",
"nuxt-env": "~0.1.0",
"string-hash": "^1.1.3",
"tiptap": "^1.14.0",
"tiptap-extensions": "^1.14.0",
"v-tooltip": "~2.0.0-rc.33",
"vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2",
"vue-sweetalert-icons": "~3.2.0",
"vuex-i18n": "~1.11.0"
},
"devDependencies": {
"@babel/core": "~7.3.4",
"@babel/preset-env": "~7.3.4",
"@vue/cli-shared-utils": "~3.4.1",
"@vue/eslint-config-prettier": "~4.0.1",
"@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29",
"babel-core": "~7.0.0-bridge.0",
"babel-eslint": "~10.0.1",
"babel-jest": "~24.5.0",
"cypress-cucumber-preprocessor": "~1.11.0",
"eslint": "~5.15.1",
"eslint-config-prettier": "~3.6.0",
"eslint-loader": "~2.1.2",
"eslint-plugin-prettier": "~3.0.1",
"eslint-plugin-vue": "~5.2.2",
"jest": "~24.5.0",
"node-sass": "~4.11.0",
"nodemon": "~1.18.10",
"prettier": "~1.14.3",
"sass-loader": "~7.1.0",
"vue-jest": "~3.0.4",
"vue-svg-loader": "~0.11.0"
}
}

6
webapp/pages/README.md Normal file
View File

@ -0,0 +1,6 @@
# PAGES
This directory contains your Application Views and Routes.
The framework reads all the `*.vue` files inside this directory and create the router of your application.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing).

67
webapp/pages/admin.vue Normal file
View File

@ -0,0 +1,67 @@
<template>
<div>
<ds-heading tag="h1">
{{ $t('admin.name') }}
</ds-heading>
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', md: '200px' }">
<ds-menu
:routes="routes"
:is-exact="() => true"
/>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">
<transition
name="slide-up"
appear
>
<nuxt-child />
</transition>
</ds-flex-item>
</ds-flex>
</div>
</template>
<script>
export default {
middleware: ['isAdmin'],
computed: {
routes() {
return [
{
name: this.$t('admin.dashboard.name'),
path: `/admin`
},
{
name: this.$t('admin.users.name'),
path: `/admin/users`
},
{
name: this.$t('admin.organizations.name'),
path: `/admin/organizations`
},
{
name: this.$t('admin.pages.name'),
path: `/admin/pages`
},
{
name: this.$t('admin.notifications.name'),
path: `/admin/notifications`
},
{
name: this.$t('admin.categories.name'),
path: `/admin/categories`
},
{
name: this.$t('admin.tags.name'),
path: `/admin/tags`
},
{
name: this.$t('admin.settings.name'),
path: `/admin/settings`
}
]
}
}
}
</script>

View File

@ -0,0 +1,55 @@
<template>
<ds-card :header="$t('admin.categories.name')">
<ds-table
:data="Category"
:fields="fields"
condensed
>
<template
slot="icon"
slot-scope="scope"
>
<ds-icon :name="scope.row.icon" />
</template>
</ds-table>
</ds-card>
</template>
<script>
import gql from 'graphql-tag'
export default {
data() {
return {
Category: []
}
},
computed: {
fields() {
return {
icon: ' ',
name: this.$t('admin.categories.categoryName'),
postCount: {
label: this.$t('admin.categories.postCount'),
align: 'right'
}
}
}
},
apollo: {
Category: {
query: gql(`
query {
Category(orderBy: postCount_desc) {
id
name
slug
icon
postCount
}
}
`)
}
}
}
</script>

View File

@ -0,0 +1,206 @@
<template>
<ds-card>
<no-ssr>
<ds-space margin="large">
<ds-flex>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.users')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countUsers || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.posts')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countPosts || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.comments')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countComments || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.notifications')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countNotifications || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.organizations')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countOrganizations || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.projects')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countProjects || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.invites')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countInvites || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.follows')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countFollows || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.shouts')"
size="x-large"
uppercase
>
<no-ssr slot="count">
<hc-count-to
:start-val="0"
:end-val="statistics.countShouts || 0"
/>
</no-ssr>
</ds-number>
</ds-space>
</ds-flex-item>
</ds-flex>
</ds-space>
</no-ssr>
</ds-card>
</template>
<script>
import gql from 'graphql-tag'
import HcCountTo from '~/components/CountTo.vue'
export default {
components: {
HcCountTo
},
data() {
return {
statistics: {}
}
},
computed: {
isClient() {
return process.client
}
},
mounted() {
this.$apollo.queries.statistics.startPolling(5000)
},
apollo: {
statistics: {
query: gql(`
query {
statistics {
countUsers
countPosts
countComments
countNotifications
countOrganizations
countProjects
countInvites
countFollows
countShouts
}
}
`)
}
}
}
</script>

View File

@ -0,0 +1,18 @@
<template>
<ds-card :header="$t('admin.notifications.name')">
<hc-empty
icon="tasks"
message="Coming Soon…"
/>
</ds-card>
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
export default {
components: {
HcEmpty
}
}
</script>

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