mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
Merging webapp to master
This commit is contained in:
commit
c91a61af89
24
webapp/.babelrc
Normal file
24
webapp/.babelrc
Normal 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
18
webapp/.dockerignore
Normal 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
13
webapp/.editorconfig
Normal 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
1
webapp/.env.template
Normal file
@ -0,0 +1 @@
|
||||
MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"
|
||||
5
webapp/.eslintignore
Normal file
5
webapp/.eslintignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
build
|
||||
.nuxt
|
||||
styleguide/
|
||||
**/*.min.js
|
||||
25
webapp/.eslintrc.js
Normal file
25
webapp/.eslintrc.js
Normal 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']
|
||||
}
|
||||
}
|
||||
35
webapp/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
webapp/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
17
webapp/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
webapp/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
86
webapp/.gitignore
vendored
Normal 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
6
webapp/.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
79
webapp/.travis.yml
Normal file
79
webapp/.travis.yml
Normal 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
46
webapp/CODE_OF_CONDUCT.md
Normal 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
27
webapp/Dockerfile
Normal 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
21
webapp/LICENSE.md
Normal 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
68
webapp/README.md
Normal 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
|
||||
[](https://travis-ci.com/Human-Connection/Nitro-Web)
|
||||
[](https://github.com/Human-Connection/Nitro-Web/blob/master/LICENSE.md)
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web?ref=badge_shield)
|
||||
[](https://discord.gg/6ub73U3)
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Web?ref=badge_large)
|
||||
7
webapp/assets/README.md
Normal file
7
webapp/assets/README.md
Normal 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).
|
||||
32
webapp/assets/styles/imports/_toast.scss
Normal file
32
webapp/assets/styles/imports/_toast.scss
Normal 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;
|
||||
}
|
||||
127
webapp/assets/styles/imports/_tooltip.scss
Normal file
127
webapp/assets/styles/imports/_tooltip.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
164
webapp/assets/styles/main.scss
Normal file
164
webapp/assets/styles/main.scss
Normal 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;
|
||||
}
|
||||
21
webapp/components/Badges.spec.js
Normal file
21
webapp/components/Badges.spec.js
Normal 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
|
||||
})
|
||||
81
webapp/components/Badges.vue
Normal file
81
webapp/components/Badges.vue
Normal 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>
|
||||
95
webapp/components/Comment.spec.js
Normal file
95
webapp/components/Comment.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
76
webapp/components/Comment.vue
Normal file
76
webapp/components/Comment.vue
Normal 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>
|
||||
156
webapp/components/ContentMenu.vue
Normal file
156
webapp/components/ContentMenu.vue
Normal 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>
|
||||
145
webapp/components/ContributionForm.vue
Normal file
145
webapp/components/ContributionForm.vue
Normal 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>
|
||||
50
webapp/components/CountTo.vue
Normal file
50
webapp/components/CountTo.vue
Normal 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>
|
||||
134
webapp/components/Dropdown.vue
Normal file
134
webapp/components/Dropdown.vue
Normal 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>
|
||||
366
webapp/components/Editor/Editor.vue
Normal file
366
webapp/components/Editor/Editor.vue
Normal 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>
|
||||
83
webapp/components/Editor/plugins/eventHandler.js
Normal file
83
webapp/components/Editor/plugins/eventHandler.js
Normal 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
|
||||
// }
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
68
webapp/components/Empty.vue
Normal file
68
webapp/components/Empty.vue
Normal 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>
|
||||
91
webapp/components/FollowButton.vue
Normal file
91
webapp/components/FollowButton.vue
Normal 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>
|
||||
23
webapp/components/LoadMore.vue
Normal file
23
webapp/components/LoadMore.vue
Normal 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>
|
||||
108
webapp/components/LocaleSwitch.vue
Normal file
108
webapp/components/LocaleSwitch.vue
Normal 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>
|
||||
79
webapp/components/Logo.vue
Normal file
79
webapp/components/Logo.vue
Normal 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>
|
||||
124
webapp/components/Modal.spec.js
Normal file
124
webapp/components/Modal.spec.js
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
59
webapp/components/Modal.vue
Normal file
59
webapp/components/Modal.vue
Normal 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>
|
||||
150
webapp/components/Modal/DisableModal.spec.js
Normal file
150
webapp/components/Modal/DisableModal.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
83
webapp/components/Modal/DisableModal.vue
Normal file
83
webapp/components/Modal/DisableModal.vue
Normal 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>
|
||||
168
webapp/components/Modal/ReportModal.spec.js
Normal file
168
webapp/components/Modal/ReportModal.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
127
webapp/components/Modal/ReportModal.vue
Normal file
127
webapp/components/Modal/ReportModal.vue
Normal 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>
|
||||
144
webapp/components/PostCard.vue
Normal file
144
webapp/components/PostCard.vue
Normal 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"
|
||||
/>
|
||||
</div>
|
||||
<div style="display: inline-block; float: right">
|
||||
<span :style="{ opacity: post.shoutedCount ? 1 : .5 }">
|
||||
<ds-icon name="bullhorn" /> <small>{{ post.shoutedCount }}</small>
|
||||
</span>
|
||||
|
||||
<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>
|
||||
7
webapp/components/README.md
Normal file
7
webapp/components/README.md
Normal 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._
|
||||
144
webapp/components/SearchInput.spec.js
Normal file
144
webapp/components/SearchInput.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
289
webapp/components/SearchInput.vue
Normal file
289
webapp/components/SearchInput.vue
Normal 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"
|
||||
>
|
||||
|
||||
</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>
|
||||
101
webapp/components/ShoutButton.vue
Normal file
101
webapp/components/ShoutButton.vue
Normal 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>
|
||||
102
webapp/components/User.spec.js
Normal file
102
webapp/components/User.spec.js
Normal 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
182
webapp/components/User.vue
Normal 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>
|
||||
12
webapp/components/mixins/seo.js
Normal file
12
webapp/components/mixins/seo.js
Normal file
@ -0,0 +1,12 @@
|
||||
export default {
|
||||
head() {
|
||||
return {
|
||||
htmlAttrs: {
|
||||
lang: this.$i18n.locale()
|
||||
},
|
||||
bodyAttrs: {
|
||||
class: `page-name-${this.$route.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
webapp/cypress.env.template.json
Normal file
6
webapp/cypress.env.template.json
Normal 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
5
webapp/cypress.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectId": "qa7fe2",
|
||||
"ignoreTestFiles": "*.js",
|
||||
"baseUrl": "http://localhost:3000"
|
||||
}
|
||||
5
webapp/cypress/fixtures/example.json
Normal file
5
webapp/cypress/fixtures/example.json
Normal 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"
|
||||
}
|
||||
17
webapp/cypress/fixtures/users.json
Normal file
17
webapp/cypress/fixtures/users.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
webapp/cypress/integration/01.Login.feature
Normal file
23
webapp/cypress/integration/01.Login.feature
Normal 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
|
||||
23
webapp/cypress/integration/02.Internationalization.feature
Normal file
23
webapp/cypress/integration/02.Internationalization.feature
Normal 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"
|
||||
40
webapp/cypress/integration/03.TagsAndCategories.feature
Normal file
40
webapp/cypress/integration/03.TagsAndCategories.feature
Normal 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 |
|
||||
|
||||
|
||||
|
||||
37
webapp/cypress/integration/04.AboutMeAndLocation.feature
Normal file
37
webapp/cypress/integration/04.AboutMeAndLocation.feature
Normal 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
|
||||
41
webapp/cypress/integration/06.Search.feature
Normal file
41
webapp/cypress/integration/06.Search.feature
Normal 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
|
||||
25
webapp/cypress/integration/06.WritePost.feature
Normal file
25
webapp/cypress/integration/06.WritePost.feature
Normal 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
|
||||
46
webapp/cypress/integration/common/admin.js
Normal file
46
webapp/cypress/integration/common/admin.js
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
145
webapp/cypress/integration/common/report.js
Normal file
145
webapp/cypress/integration/common/report.js
Normal 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')
|
||||
})
|
||||
69
webapp/cypress/integration/common/search.js
Normal file
69
webapp/cypress/integration/common/search.js
Normal 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'
|
||||
)
|
||||
}
|
||||
)
|
||||
63
webapp/cypress/integration/common/settings.js
Normal file
63
webapp/cypress/integration/common/settings.js
Normal 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)
|
||||
)
|
||||
246
webapp/cypress/integration/common/steps.js
Normal file
246
webapp/cypress/integration/common/steps.js
Normal 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)
|
||||
}
|
||||
)
|
||||
26
webapp/cypress/integration/moderation/HidePosts.feature
Normal file
26
webapp/cypress/integration/moderation/HidePosts.feature
Normal 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
|
||||
"""
|
||||
59
webapp/cypress/integration/moderation/ReportContent.feature
Normal file
59
webapp/cypress/integration/moderation/ReportContent.feature
Normal 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
|
||||
20
webapp/cypress/plugins/index.js
Normal file
20
webapp/cypress/plugins/index.js
Normal 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())
|
||||
}
|
||||
75
webapp/cypress/support/commands.js
Normal file
75
webapp/cypress/support/commands.js
Normal 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) => { ... })
|
||||
51
webapp/cypress/support/factories.js
Normal file
51
webapp/cypress/support/factories.js
Normal 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)
|
||||
}
|
||||
)
|
||||
10
webapp/cypress/support/helpers.js
Normal file
10
webapp/cypress/support/helpers.js
Normal 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
|
||||
21
webapp/cypress/support/index.js
Normal file
21
webapp/cypress/support/index.js
Normal 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')
|
||||
16
webapp/docker-compose.override.yml
Normal file
16
webapp/docker-compose.override.yml
Normal 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:
|
||||
9
webapp/docker-compose.travis.yml
Normal file
9
webapp/docker-compose.travis.yml
Normal 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
24
webapp/docker-compose.yml
Normal 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:
|
||||
69
webapp/graphql/ModerationListQuery.js
Normal file
69
webapp/graphql/ModerationListQuery.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
}
|
||||
28
webapp/graphql/PostMutations.js
Normal file
28
webapp/graphql/PostMutations.js
Normal 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
|
||||
}
|
||||
}
|
||||
`)
|
||||
}
|
||||
}
|
||||
102
webapp/graphql/UserProfileQuery.js
Normal file
102
webapp/graphql/UserProfileQuery.js
Normal 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
7
webapp/layouts/README.md
Normal 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
18
webapp/layouts/blank.vue
Normal 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
285
webapp/layouts/default.vue
Normal 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
201
webapp/locales/de.json
Normal 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
201
webapp/locales/en.json
Normal 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
116
webapp/locales/es.json
Normal 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
169
webapp/locales/fr.json
Normal 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
50
webapp/locales/index.js
Normal 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
127
webapp/locales/it.json
Normal 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
159
webapp/locales/nl.json
Normal 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
188
webapp/locales/pl.json
Normal 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
188
webapp/locales/pt.json
Normal 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
BIN
webapp/lokalise.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
8
webapp/middleware/README.md
Normal file
8
webapp/middleware/README.md
Normal 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).
|
||||
26
webapp/middleware/authenticated.js
Normal file
26
webapp/middleware/authenticated.js
Normal 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)
|
||||
}
|
||||
5
webapp/middleware/isAdmin.js
Normal file
5
webapp/middleware/isAdmin.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default ({ store, error }) => {
|
||||
if (!store.getters['auth/isAdmin']) {
|
||||
return error({ statusCode: 403 })
|
||||
}
|
||||
}
|
||||
5
webapp/middleware/isModerator.js
Normal file
5
webapp/middleware/isModerator.js
Normal 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
223
webapp/nuxt.config.js
Normal 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
90
webapp/package.json
Normal 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
6
webapp/pages/README.md
Normal 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
67
webapp/pages/admin.vue
Normal 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>
|
||||
55
webapp/pages/admin/categories.vue
Normal file
55
webapp/pages/admin/categories.vue
Normal 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>
|
||||
206
webapp/pages/admin/index.vue
Normal file
206
webapp/pages/admin/index.vue
Normal 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>
|
||||
18
webapp/pages/admin/notifications.vue
Normal file
18
webapp/pages/admin/notifications.vue
Normal 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
Loading…
x
Reference in New Issue
Block a user