Compare commits

..

51 Commits

Author SHA1 Message Date
000946c52d 📄 Add license for new script
All checks were successful
continuous-integration/drone/pr Build is passing
2024-01-12 19:16:23 +01:00
7bba73ceae 🔧 Configure KI API URL from environment
Some checks failed
continuous-integration/drone/pr Build is failing
With an entrypoint script, fill the configuration template from an
environment variable containing the KI API URL.
2024-01-12 19:12:18 +01:00
c2552f3c3a Merge pull request 'Push image to Gitea registry' (!84) from gitea-registry into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #84
2023-12-10 18:47:54 +01:00
687454afdb Push image to Gitea registry
All checks were successful
continuous-integration/drone/pr Build is passing
2023-12-04 20:09:52 +01:00
a7c8774cc4 Merge pull request 'Add labels to Docker images' (!83) from docker-labels into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #83
2023-11-16 11:18:44 +01:00
b63e5a6c2d Merge pull request 'Rewrite Drone config' (!82) from drone-config into main
Some checks are pending
continuous-integration/drone/push Build is pending
Reviewed-on: #82
2023-11-16 11:18:35 +01:00
1b221ab180 Merge pull request 'Improve SPA cacheing' (!81) from nginx-cacheing into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #81
2023-11-13 16:51:10 +01:00
e2b101eb89 Add labels to Docker images
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-07 14:54:40 +01:00
654169c383 Rewrite Drone config
All checks were successful
continuous-integration/drone/pr Build is passing
- Split PR and "push to main" actions
- Deploy latest main to dev system
- Build image for Git tags
2023-11-06 23:33:33 +01:00
26edf1d4b2 Improve SPA cacheing
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-06 18:35:05 +01:00
e7ff487aeb Merge pull request '"Problem melden"-Link korrigiert' (!78) from brain-patch-1 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #78
2023-10-27 15:12:55 +02:00
c5bda80f11 Merge pull request 'Klarnamen in der Suche berücksichtigen' (!80) from zeitschlag/ki-frontend:feature/48-search-address-name into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #80
2023-10-27 15:12:06 +02:00
379ddaf5b9 Change placeholder (kompetenzinventar/ki-doku#48)
All checks were successful
continuous-integration/drone/pr Build is passing
2023-04-20 18:14:07 +02:00
13fb15e033 Fix link for new issues 2022-07-20 18:26:39 +02:00
aaa0883692 Merge pull request 'add tag trigger' (!77) from feature/tag-trigger into main
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #77
2022-02-28 19:32:33 +01:00
e404bef2a9 add tag trigger
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-28 19:32:04 +01:00
cbbcfd0f37 Merge pull request 'add address.name to frontend' (!76) from feature/realname into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #76
2022-01-26 22:03:08 +01:00
5c5f157a77 add address.name to frontend
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-25 21:04:17 +01:00
3017c001b2 Merge pull request 'Paginierung' (!74) from feature/pagination into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #74
2022-01-24 19:46:59 +01:00
fefe9a034d Merge pull request 'Aktualisierung NPM Pakete' (!75) from fix/npm-update into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #75
2022-01-24 19:11:50 +01:00
bac8731e17 add pagination
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-01-23 20:14:52 +01:00
dc883ac302 update npm packages
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-23 19:56:14 +01:00
16feb41f8a kompetenzinventar/ki-doku#18 line-break new text (!72)
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: scammo <samuel.brinkmann@googlemail.com>
Reviewed-on: #72
Reviewed-by: weeman <weeman@noreply.git.wtf-eg.de>
Co-authored-by: scammo <scammo@noreply.git.wtf-eg.de>
Co-committed-by: scammo <scammo@noreply.git.wtf-eg.de>
2022-01-17 14:44:13 +01:00
04cb8a7217 Merge pull request '#64 "Ich Suche" besser definiert' (#71) from profile-page into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #71
2022-01-14 13:10:10 +01:00
e3115f9944 Merge branch 'main' into profile-page
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-01-14 13:09:46 +01:00
122b13b6a2 #64 ich besser definiert
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-14 12:53:23 +01:00
872d544075 Merge pull request 'Fix typo for language levels' (#67) from zeitschlag/ki-frontend:typo into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #67
2021-12-17 20:41:45 +01:00
fba33d20a7 Fix typo for language levels 2021-12-17 18:29:54 +01:00
e035fa3289 extend skill length to 50
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-22 20:14:45 +01:00
324203216a fix typo 2021-11-22 18:48:13 +01:00
b270a5d56a Merge pull request 'Weitere Arbeiten Profilseite' (#53) from profile-page into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #53
2021-10-25 14:15:38 +02:00
6ecf80f34c improve profile page #36
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-24 18:16:19 +02:00
016a1bd959 Merge pull request 'Profile bearbeiten Ansicht' (#52) from feature/profile-view into main
Reviewed-on: #52
2021-10-18 20:47:23 +02:00
4e8390cf96 update profile edit view 2021-10-18 20:45:18 +02:00
04d59d5520 Merge pull request 'Verfügbarkeit: Status, Stunden pro Woche und Text' (#50) from availability into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #50
2021-10-11 18:57:38 +02:00
0f0d3cd861 implement advanced availability logic
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-11 18:55:19 +02:00
2b63603957 Merge pull request 'fix profile display' (#49) from fix/48-profile into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #49
2021-10-11 09:43:07 +02:00
9a51b416e5 run drone only for main branch
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-10 19:59:07 +02:00
3ea7eb48b4 fix profile display
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-10-10 19:41:45 +02:00
8c8021bedc Merge pull request 'Verschiedenes seit Freitag' (#47) from freitag into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #47
2021-10-04 17:42:09 +02:00
46fcaa2db6 implement profile view
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-10-03 20:05:08 +02:00
2d700c77dc redirect users with token #38 2021-10-03 20:05:07 +02:00
c1d78fa8c1 implement seach view autofocus #41 2021-10-03 20:05:07 +02:00
7c8a1bb423 add login page name field autofocus #42 2021-10-03 20:05:06 +02:00
8e42c7fdbe Merge pull request 'Suchparameter in URL' (#46) from feature/44-suchparameter-in-url into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #46
2021-09-27 18:02:23 +02:00
c25639b40c Suchparameter in URL
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-09-27 17:50:01 +02:00
a2048d0eb9 Merge pull request 'Überarbeitung Suchansicht' (#37) from feature-search into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #37
2021-09-23 16:57:31 +02:00
b19a770d61 no more search in navbar
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-09-23 16:57:09 +02:00
8098c54c06 Merge branch 'feature-search' of git.wtf-eg.de:kompetenzinventar/ki-frontend into feature-search 2021-09-22 00:09:27 +02:00
73847022e2 implement search layout #32 #33 #35
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-09-22 00:01:17 +02:00
93cb302ca7 implement search layout #32 #33 #35
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2021-09-21 23:58:42 +02:00
47 changed files with 9676 additions and 7277 deletions

View File

@ -4,16 +4,100 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
kind: pipeline kind: pipeline
type: docker type: docker
name: default name: qa
trigger:
event:
- push
- pull_request
branch:
- main
steps: steps:
- name: reuse - name: reuse
image: fsfe/reuse:latest image: fsfe/reuse
- name: docker-publish - name: lint
image: node:20
commands:
- npm ci
- npm run lint
---
kind: pipeline
type: docker
name: build
trigger:
event:
- push
branch:
- main
depends_on:
- qa
steps:
- name: docker-publish
image: plugins/docker image: plugins/docker
settings: settings:
registry: registry.wtf-eg.net registry: git.wtf-eg.de
repo: registry.wtf-eg.net/ki-frontend repo: git.wtf-eg.de/kompetenzinventar/frontend
target: ki-frontend
auto_tag: true
username:
from_secret: "docker_username"
password:
from_secret: "docker_password"
---
kind: pipeline
type: docker
name: deploy
trigger:
event:
- push
branch:
- main
depends_on:
- build
steps:
- name: deploy-dev
image: appleboy/drone-ssh
settings:
host:
- dev01.wtf-eg.net
username: drone_deployment
key:
from_secret: "dev01_deployment_key"
command_timeout: 2m
script:
- echo "Executing forced command..."
---
kind: pipeline
type: docker
name: tag-release
trigger:
event:
- tag
steps:
- name: reuse
image: fsfe/reuse
- name: lint
image: node:20
commands:
- npm ci
- npm run lint
- name: docker-publish
image: plugins/docker
settings:
registry: git.wtf-eg.de
repo: git.wtf-eg.de/kompetenzinventar/frontend
target: ki-frontend target: ki-frontend
auto_tag: true auto_tag: true
username: username:

View File

@ -11,7 +11,7 @@ Files: .browserslistrc .dockerignore .eslintrc.js .gitignore
Copyright: WTF Kooperative eG <https://wtf-eg.de/> Copyright: WTF Kooperative eG <https://wtf-eg.de/>
License: AGPL-3.0-or-later License: AGPL-3.0-or-later
Files: src/assets/img/wtf_logo* Files: src/assets/img/wtf*
Copyright: WTF Kooperative eG <https://wtf-eg.de/> Copyright: WTF Kooperative eG <https://wtf-eg.de/>
License: LicenseRef-WTF License: LicenseRef-WTF

View File

@ -13,3 +13,16 @@ FROM nginx as ki-frontend
COPY --from=builder /dist/ /usr/share/nginx/html/ COPY --from=builder /dist/ /usr/share/nginx/html/
COPY etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf COPY etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
LABEL org.opencontainers.image.source=https://git.wtf-eg.de/kompetenzinventar/ki-frontend.git
LABEL org.opencontainers.image.url=https://git.wtf-eg.de/kompetenzinventar/ki-frontend
LABEL org.opencontainers.image.documentation=https://git.wtf-eg.de/kompetenzinventar/ki-frontend#docker
LABEL org.opencontainers.image.vendor="WTF Kooperative eG"
ENV KI_API_URL http://ki-backend:5000
WORKDIR /usr/share/nginx/html
COPY etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
COPY configure.sh /docker-entrypoint.d/
COPY --from=builder /dist .

View File

@ -1,9 +0,0 @@
MIT License
Copyright (c) <year> <copyright holders>
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.

13
configure.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
set -o errexit
set +x
: ${KI_API_URL:=http://ki-backend:5000}
sed -e "s%\$KI_API_URL%$KI_API_URL%g" /usr/share/nginx/html/config.js.in > /usr/share/nginx/html/config.js
exec "$@"

View File

@ -9,10 +9,24 @@ server {
#access_log /var/log/nginx/host.access.log main; #access_log /var/log/nginx/host.access.log main;
location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html; # routes without dots serve the index.html without caching
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/index.html /index.html;
}
# static js and css files that get replaced instead of updated
location ~ \.(js|css) {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# cache other static files for 30 days
location ~ \.(?!html) {
add_header Cache-Control "public, max-age=2592000";
try_files $uri =404;
} }
#error_page 404 /404.html; #error_page 404 /404.html;

14743
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,18 +7,20 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "^4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-plugin-eslint": "^4.5.0",
"@vue/cli-plugin-router": "~4.5.0", "@vue/cli-plugin-router": "^4.5.0",
"@vue/cli-service": "~4.5.0", "@vue/cli-service": "^4.5.0",
"@vue/compiler-sfc": "^3.0.0", "@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"bootstrap": "^5.0.1", "bootstrap": "^5.0.1",
"bootstrap-icons": "^1.5.0",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0", "eslint-plugin-vue": "^7.0.0",
"sass": "^1.37.5", "sass": "^1.37.5",
"sass-loader": "^10.2.0", "sass-loader": "^10.2.0",
"v-tooltip": "^4.0.0-alpha.1",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-router": "^4.0.0-0", "vue-router": "^4.0.0-0",
"vuex": "^4.0.2" "vuex": "^4.0.2"

7
public/config.js.in Normal file
View File

@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
window.ki = {
apiUrl: "$KI_API_URL"
}

View File

@ -1 +0,0 @@
SPDX-License-Identifier: MIT

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path d="M8 0a1 1 0 0 1 1 1v6h6a1 1 0 1 1 0 2H9v6a1 1 0 1 1-2 0V9H1a1 1 0 0 1 0-2h6V1a1 1 0 0 1 1-1z"/>
</svg>

Before

Width:  |  Height:  |  Size: 238 B

View File

@ -1 +0,0 @@
SPDX-License-Identifier: MIT

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 331 B

View File

@ -1 +0,0 @@
SPDX-License-Identifier: MIT

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>

Before

Width:  |  Height:  |  Size: 573 B

View File

@ -1 +0,0 @@
SPDX-License-Identifier: MIT

View File

@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<Footer /> <Footer />
</template> </template>
<script> <script>
import Footer from '@/components/Footer.vue' import Footer from '@/components/Footer'
import Navbar from '@/components/Navbar.vue' import Navbar from '@/components/Navbar'
export default { export default {
name: 'App', name: 'App',

View File

@ -7,6 +7,13 @@
@import "variables"; @import "variables";
@import "bootstrap/scss/bootstrap"; @import "bootstrap/scss/bootstrap";
.bg-wtf {
background-image: url(../assets/img/wtf-header-bg.jpg);
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
}
.btn-primary { .btn-primary {
color: #fff !important; color: #fff !important;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -1,6 +1,6 @@
{ {
"1": "Keine Angabe", "1": "Keine Angabe",
"2": "Grundkentnisse", "2": "Grundkenntnisse",
"3": "Gut", "3": "Gut",
"4": "Fließend", "4": "Fließend",
"5": "Muttersprache" "5": "Muttersprache"

View File

@ -1,7 +1,22 @@
{ {
"1": "bis 6 Monate", "1": {
"2": "bis 1 Jahr", "short": "≤ 6M",
"3": "bis 3 Jahre", "long": "bis 6 Monate"
"4": "bis 5 Jahre", },
"5": "mehr als 5 Jahre" "2":{
"short": "≤ 1J",
"long": "bis 1 Jahr"
},
"3": {
"short": "≤ 3J",
"long": "bis 3 Jahre"
},
"4": {
"short": "≤ 5J",
"long": "bis 5 Jahre"
},
"5": {
"short": "> 5J",
"long": "mehr als 5 Jahre"
}
} }

View File

@ -14,3 +14,5 @@ $body-bg: $gray-100;
$link-decoration: none; $link-decoration: none;
$link-hover-decoration: underline; $link-hover-decoration: underline;
$spinner-animation-speed: 1s;

View File

@ -5,37 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<div> <profile-list
<label for="searchText" class="form-label fw-bold">{{ label }}</label> :values="values"
<div class="row mb-2"> :type="type"
<div class="col"> :editable="true"
:show-secondary="showSecondary"
@remove-value="removeValue($event)"
@update-values="this.$emit('update-values', this.values)"
>
</profile-list>
<div v-bind="$attrs" class="card-body">
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2">
<div class="form-control-plaintext form-control-sm">Eintrag hinzufügen:</div>
</div>
<div class="col-12 col-md-6">
<input <input
autocomplete="off" autocomplete="off"
type="text" type="text"
class="form-control" class="form-control form-control-sm"
id="searchText" id="searchText"
:maxlength="maxlength"
:placeholder="placeholder"
v-model="searchText" v-model="searchText"
@keyup="search()" @input="search()"
@keyup.enter="addResult()" @keyup.enter="addResult()"
/> />
</div> <div v-if="searchResults">
<div class="col">
<button
v-if="searchText != ''"
type="button"
class="btn btn-outline-success"
aria-label="Hinzufügen"
@click="addResult()"
>
<img
src="/img/bootstrap-icons-1.5.0/plus-lg.svg"
alt="Hinzufügen Icon"
/>
Hinzufügen
</button>
</div>
</div>
<div class="" v-if="searchResults">
<ul class="list-group"> <ul class="list-group">
<li <li
class="list-group-item" class="list-group-item"
@ -47,24 +43,30 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</li> </li>
</ul> </ul>
</div> </div>
<profile-list </div>
:values="values" <div class="col-md-2">
:type="type" <button
:editable="true" v-if="searchText != ''"
@remove-value="removeValue($event)" type="button"
@update-values="this.$emit('update-values', this.values)" class="btn btn-outline-success"
aria-label="Hinzufügen"
@click="addResult()"
> >
</profile-list> <i clas="bi-plus-lg"></i>
Hinzufügen
</button>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from 'vuex' import { mapState } from 'vuex'
import RequestMixin from "@/mixins/request.mixin" import RequestMixin from '@/mixins/request.mixin'
import ProfileList from "@/components/ProfileList"; import ProfileList from '@/components/ProfileList';
export default { export default {
name: "AutoComplete", name: 'AutoComplete',
mixins: [RequestMixin], mixins: [RequestMixin],
components: { components: {
ProfileList, ProfileList,
@ -79,21 +81,33 @@ export default {
values: { values: {
type: Array, type: Array,
}, },
showSecondary: {
type: Boolean,
default: true,
},
placeholder: {
type: String,
default: "",
},
}, },
data() { data() {
return { return {
iconUrl: this.apiUrl, iconUrl: this.apiUrl,
searchText: "", searchText: '',
searchResults: [], searchResults: [],
showErrorMessage: false, showErrorMessage: false,
}; };
}, },
computed: { computed: {
...mapState(['currentUserId']) ...mapState(['currentUserId']),
maxlength() {
return this.type === 'skill' ? 50 : 25
}
}, },
methods: { methods: {
addResult(result = false) { addResult(result = false) {
if (!result) result = this.searchResults[0]; if (!result) result = this.searchResults[0];
if ( if (
this.values.map((item) => item[this.type].name).includes(result.name) this.values.map((item) => item[this.type].name).includes(result.name)
) { ) {
@ -104,16 +118,19 @@ export default {
let newValue = { let newValue = {
profile_id: this.currentUserId, profile_id: this.currentUserId,
}; };
if (this.type != "contacttype") {
if (this.type != 'contacttype') {
newValue.level = 1; newValue.level = 1;
} else { } else {
newValue.content = ""; newValue.content = '';
} }
newValue[this.type] = result; newValue[this.type] = result;
changeValues.unshift(newValue); changeValues.unshift(newValue);
this.searchText = "";
this.searchText = '';
this.searchResults = []; this.searchResults = [];
this.$emit("update-values", changeValues); this.$emit('update-values', changeValues);
}, },
removeValue(valueName) { removeValue(valueName) {
const newValues = this.values.filter((value) => { const newValues = this.values.filter((value) => {
@ -123,7 +140,7 @@ export default {
return true; return true;
} }
}); });
this.$emit("update-values", newValues); this.$emit('update-values', newValues);
}, },
}, },
}; };

37
src/components/Avatar.vue Normal file
View File

@ -0,0 +1,37 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="avatar bg-dark">
{{ avatarLetters }}
</div>
</template>
<script>
export default {
props: {
name: String
},
computed: {
avatarLetters() {
return this.name.substr(0, 2)
}
}
}
</script>
<style scoped>
.avatar {
align-items: center;
border-radius: .25rem;
color: white;
display: flex;
font-weight: bold;
height: 2.5rem;
justify-content: center;
width: 2.5rem;
}
</style>

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<div class="fw-bold text-white mb-2">Kompetenzinventar</div> <div class="fw-bold text-white mb-2">Kompetenzinventar</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a href="https://git.wtf-eg.de/kompetenzinventar">Quellcode</a></li> <li><a href="https://git.wtf-eg.de/kompetenzinventar">Quellcode</a></li>
<li><a href="https://git.wtf-eg.de/kompetenzinventar/ki-frontend/issues/new">Problem melden</a></li> <li><a href="https://git.wtf-eg.de/kompetenzinventar/ki-doku/issues/new/choose">Problem melden</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -26,11 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div <div
class="collapse navbar-collapse" class="collapse navbar-collapse d-lg-flex"
:class="{ show: showMobileNav }" :class="{ show: showMobileNav }"
id="navbarSupportedContent" id="navbarSupportedContent"
> >
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav mb-2 mb-lg-0 me-auto">
<li class="nav-item"> <li class="nav-item">
<router-link <router-link
class="nav-link" class="nav-link"
@ -55,30 +55,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
>Bearbeiten</router-link >Bearbeiten</router-link
> >
</li> </li>
</ul>
<ul class="navbar-nav">
<li class="nav-item"> <li class="nav-item">
<button class="btn btn-outline-danger" @click="logout()"> <button class="btn btn-danger w-100" @click="logout()">
<i class="bi bi-box-arrow-right"></i>
Logout Logout
</button> </button>
</li> </li>
</ul> </ul>
<form class="d-flex" @submit.prevent="searchRedirect()">
<input
class="form-control me-2"
v-model="searchText"
type="search"
placeholder="Profile durchsuchen"
aria-label="Search"
/>
<button
class="btn btn-outline-primary"
type="submit"
>
<img
src="/img/bootstrap-icons-1.5.0/search.svg"
alt="Suche Icon"
/>
</button>
</form>
</div> </div>
</div> </div>
</nav> </nav>
@ -95,7 +80,6 @@ export default {
mixins: [RequestMixin], mixins: [RequestMixin],
data() { data() {
return { return {
searchText: '',
showMobileNav: false showMobileNav: false
} }
}, },
@ -110,9 +94,6 @@ export default {
this.$store.dispatch('clear') this.$store.dispatch('clear')
this.$router.push({ path: '/' }); this.$router.push({ path: '/' });
}, },
searchRedirect() {
this.$router.push({ path: `/s/search?text`, query: { query: this.searchText } } );
},
} }
} }

View File

@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<ul class="pagination">
<li
class="page-item"
:class="{ active: page === current }"
v-for="page in pages"
:key="page"
>
<span
class="page-link pointer"
@click="onPageClicked(page)"
>
{{ page }}
</span>
</li>
</ul>
</template>
<script>
export default {
name: 'Paginator',
props: {
page: Number,
current: Number,
pages: Number
},
methods: {
onPageClicked(page) {
if (page == this.current) {
return
}
this.$emit('page', page)
}
}
}
</script>
<style>
.pointer {
cursor: pointer;
}
</style>

View File

@ -5,24 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<ul> <ul class="list-group list-group-flush">
<li v-for="(value, valueKey) in values" :key="value.id"> <li
<div class="row m-1"> class="list-group-item"
<div class="col"> v-for="(value, valueKey) in values"
<img :key="value.id"
style="max-width: 64px" >
<div class="row">
<div class="col-12 col-md-5 d-flex align-items-center">
<div
class="list-icon me-1"
:style="{ backgroundImage: `url('${iconBaseUrl + value[type].icon_url}'` }"
v-if="value[type].icon_url" v-if="value[type].icon_url"
:src="iconUrl + value[type].icon_url"
:alt="`${value[type].name} Logo`"
/> />
<div>
{{ value[type].name }} {{ value[type].name }}
</div> </div>
<div class="col"> </div>
<div v-if="type == 'skill'"> <div class="col-10 col-md-5">
<div v-if="editable"> <div v-if="type == 'skill' && showSecondary">
<select <select
v-if="editableValues[valueKey]" class="form-select form-select-sm"
class="form-select"
aria-label="Selektiere dein Level" aria-label="Selektiere dein Level"
v-model="editableValues[valueKey].level" v-model="editableValues[valueKey].level"
@input="$emit('update-values', editableValues)" @input="$emit('update-values', editableValues)"
@ -32,18 +35,13 @@ SPDX-License-Identifier: AGPL-3.0-or-later
:value="key" :value="key"
:key="key" :key="key"
> >
{{ value }} {{ value.long || value }}
</option> </option>
</select> </select>
</div> </div>
<div v-else><span v-if="value.level">({{ levelSelection[value.level] }})</span></div>
</div>
<div v-else-if="type == 'language'"> <div v-else-if="type == 'language'">
<div v-if="editable">
<div v-if="editable">
<select <select
v-if="editableValues[valueKey]" class="form-select form-select-sm"
class="form-select"
aria-label="Selektiere dein Level" aria-label="Selektiere dein Level"
v-model="editableValues[valueKey].level" v-model="editableValues[valueKey].level"
@input="$emit('update-values', editableValues)" @input="$emit('update-values', editableValues)"
@ -57,35 +55,22 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</option> </option>
</select> </select>
</div> </div>
</div>
<div v-else><span v-if="value.level">({{languagesSelection[value.level]}})</span></div>
</div>
<div v-else-if="type == 'contacttype'"> <div v-else-if="type == 'contacttype'">
<div v-if="editable"> <input
<input class="form-control" v-model="editableValues[valueKey].content"/> class="form-control form-control-sm"
</div> maxlength="200"
<div v-else> v-model="editableValues[valueKey].content"
<span v-if="value[type].name === 'E-Mail'"> />
<a :href="`mailto:${value.content}`">{{ value.content }}</a>
</span>
<span v-else>
{{ value.content }}
</span>
</div> </div>
</div> </div>
</div> <div class="col-2 text-end">
<div class="col">
<button <button
v-if="editable"
type="button" type="button"
class="btn btn-outline-danger" class="btn btn-sm btn-light"
aria-label="Löschen" aria-label="Löschen"
@click="$emit('removeValue', value[type].name)" @click="$emit('removeValue', value[type].name)"
> >
<img <i class="text-danger bi bi-x-circle"></i>
src="/img/bootstrap-icons-1.5.0/trash.svg"
alt="Löschen Icon"
/>
</button> </button>
</div> </div>
</div> </div>
@ -93,11 +78,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</ul> </ul>
</template> </template>
<script> <script>
import levelJson from "@/assets/skill_level.json"; import levelJson from '@/assets/skill_level.json';
import languagesJson from "@/assets/language_level.json"; import languagesJson from '@/assets/language_level.json';
export default { export default {
name: "ProfileList", name: 'ProfileList',
props: { props: {
type: { type: {
type: String, type: String,
@ -106,14 +91,14 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
editable: { showSecondary: {
type: Boolean, type: Boolean,
default: false, default: true
}, }
}, },
data() { data() {
return { return {
iconUrl: this.apiUrl, iconBaseUrl: this.apiUrl,
levelSelection: levelJson, levelSelection: levelJson,
languagesSelection: languagesJson, languagesSelection: languagesJson,
editableValues: this.values, editableValues: this.values,
@ -126,3 +111,13 @@ export default {
}, },
}; };
</script> </script>
<style scope>
.list-icon {
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
height: 32px;
width: 32px;
}
</style>

View File

@ -0,0 +1,54 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<router-link
class="text-decoration-none d-flex"
:to="{ path: `/s/profile/${profile.user_id}` }"
>
<div class="card w-100">
<div class="card-body d-flex">
<div class="d-flex align-items-center justify-content-center me-3">
<Avatar :name="profile.nickname"/>
</div>
<div class="text-body">
<h5 class="card-title mb-1 lh-1">
{{ profile.nickname}}
<span v-if="profile.pronouns"> ({{ profile.pronouns }})</span>
</h5>
<small
class="card-text lh-1 text-dark"
v-if="profile.skills && profile.skills.length > 0"
>
Top-Fähigkeiten: {{ topSkills }}
</small>
</div>
</div>
</div>
</router-link>
</template>
<script>
import Avatar from '@/components/Avatar'
export default {
components: {
Avatar,
},
props: {
profile: Object
},
computed: {
topSkills() {
return this.profile.skills.slice(0, 5).map(s => s.skill.name).join(', ')
}
}
}
</script>
<style scoped>
.card:hover {
background-color: #f8f9fa;
}
</style>

81
src/components/Skill.vue Normal file
View File

@ -0,0 +1,81 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="skill rounded me-2">
<div class="skill__left p-2">
<div class="skill__icon" :style="{ backgroundImage: iconUrl }"></div>
</div>
<div class="skill__right d-flex align-items-center rounded-end px-2">
<div>
<div class="skill__name fw-bold me-1">
{{ profileSkill.skill.name }}
</div>
<small class="skill__level" v-if="showLevel" :title="levelTitle">
{{ level }}
</small>
</div>
</div>
</div>
</template>
<script>
import levels from '@/assets/skill_level.json';
export default {
props: {
profileSkill: Object,
showLevel: {
type: Boolean,
default: false
}
},
data() {
return {
levels
}
},
computed: {
iconUrl() {
return `url("${window.ki.apiUrl}/${this.profileSkill.skill.icon_url}")`
},
level() {
return levels[this.profileSkill.level].short
},
levelTitle() {
return levels[this.profileSkill.level].long
}
}
}
</script>
<style>
.skill {
align-items: stretch;
border: 1px solid #acacac;
display: inline-flex;
line-height: 1;
}
.skill__icon {
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
height: 32px;
width: 32px;
}
.skill__right {
background-color: #edefeb;
color: #202020;
}
.skill__name,
.skill__level {
display: inline-block;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,9 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="spinner-grow" role="status"></div>
</template>

View File

@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="container text-center py-5">
<template v-if="notFound">
<div v-if="isOwnProfile">
<div class="fs-1 lh-1">nullptr :/</div>
<div class="fs-3 mb-4">Du hast noch kein Profil</div>
<router-link :to="{ name: 'ProfileEdit' }" class="btn btn-primary" >
Jetzt Profil erstellen
</router-link>
</div>
<div v-else>
<div class="fs-1 mb-3">nullptr :/</div>
<div class="mb-3">
Profil nicht gefunden
</div>
</div>
</template>
<template v-else>
<div class="fs-1 mb-3">Kernel panic :/</div>
Das Profil konnte nicht geladen werden
</template>
</div>
</template>
<script>
export default {
props: {
isOwnProfile: Boolean,
notFound: Boolean
}
}
</script>

View File

@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="contact rounded d-inline-flex align-items-center">
<div class="contact__left px-2">
{{ profileContact.contacttype.name }}
</div>
<div class="contact__right d-flex align-items-center rounded-end px-2">
{{ profileContact.content }}
</div>
</div>
</template>
<script>
export default {
props: {
profileContact: Object,
}
}
</script>
<style>
.contact {
align-items: stretch;
border: 1px solid #acacac;
display: inline-flex;
}
.contact__right {
background-color: #edefeb;
color: #202020;
font-weight: bold;
height: 32px;
}
</style>

View File

@ -0,0 +1,79 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="bg-wtf py-3">
<div class="container">
<div class="d-flex align-items-center mb-3">
<Avatar class="me-3" :name="profile.nickname" />
<div class="text-white fs-3">
<span class="fs-3">{{ profile.nickname }}</span>
<span v-if="profile.pronouns" class="fs-5">
({{ profile.pronouns }})
</span>
</div>
</div>
<div v-if="profile?.address?.name">
<div class="d-flex align-items-center">
<i class="fs-4 bi bi-person-fill text-dark mx-2"></i>
<div class="text-white">
a.k.a. {{ profile.address.name }}
</div>
</div>
</div>
<div v-if="location">
<div class="d-flex align-items-center">
<i class="fs-4 bi bi-geo-alt-fill text-dark mx-2"></i>
<div class="text-white">
{{ location }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Avatar from '@/components/Avatar'
export default {
name: 'ProfileHeader',
components: {
Avatar
},
props: {
profile: Object
},
computed: {
location() {
if (!this.profile.address) {
return
}
const parts = []
if (this.profile.address.postcode) {
parts.push(this.profile.address.postcode)
}
if (this.profile.address.city) {
parts.push(this.profile.address.city)
}
if (this.profile.address.country) {
parts.push(this.profile.address.country)
}
return parts.join(', ')
}
}
}
</script>
<style>
.content {
min-height: calc(100vh - 60px);
}
</style>

View File

@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="language rounded me-2">
<div class="language__left px-2">
<div class="language__icon" :style="{ backgroundImage: iconUrl }"></div>
</div>
<div class="language__right d-flex align-items-center rounded-end px-2">
<div>
<div class="language__name me-1">{{ profileLanguage.language.name }}</div>
<small class="language__level">{{ level }}</small>
</div>
</div>
</div>
</template>
<script>
import levels from '@/assets/language_level.json';
export default {
props: {
profileLanguage: Object,
},
data() {
return {
levels
}
},
computed: {
iconUrl() {
return `url("${window.ki.apiUrl}/${this.profileLanguage.language.icon_url}")`
},
level() {
return levels[this.profileLanguage.level]
}
}
}
</script>
<style>
.language {
align-items: stretch;
border: 1px solid #acacac;
display: inline-flex;
}
.language__icon {
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
height: 32px;
width: 32px;
}
.language__right {
background-color: #edefeb;
color: #202020;
}
.language__name {
display: inline-block;
font-weight: bold;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,28 @@
<!--
SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="container mb-5">
<h3 class="text-center">
{{ title }}
</h3>
<div class="card w-100">
<slot name="card-body">
<div class="card-body">
<slot></slot>
</div>
</slot>
</div>
</div>
</template>
<script>
export default {
props: {
title: String
}
}
</script>

View File

@ -6,11 +6,15 @@ import { createApp } from 'vue/dist/vue.esm-bundler'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import store from '@/store' import store from '@/store'
import VTooltipPlugin from 'v-tooltip'
import 'v-tooltip/dist/v-tooltip.css'
import 'bootstrap-icons/font/bootstrap-icons.css'
import './assets/global.scss' import './assets/global.scss'
const app = createApp(App) const app = createApp(App)
app.use(VTooltipPlugin)
app.use(router) app.use(router)
app.use(store) app.use(store)

View File

@ -37,6 +37,11 @@ export default {
} }
}, },
async search() { async search() {
if (!this.searchText) {
this.searchResults = []
return
}
try { try {
const response = await fetch(`${this.apiUrl}/${this.type}s?search=${this.searchText}`, { const response = await fetch(`${this.apiUrl}/${this.type}s?search=${this.searchText}`, {
headers: { headers: {
@ -52,15 +57,16 @@ export default {
} }
const responseData = await response.json() const responseData = await response.json()
this.searchResults = responseData[`${this.type}s`]; const searchResults = responseData[`${this.type}s`];
if ( if (
!this.values !searchResults.map((item) => item.name.toLowerCase())
.map((item) => item[this.type].name.toLowerCase())
.includes(this.searchText.toLowerCase()) .includes(this.searchText.toLowerCase())
) { ) {
this.searchResults.unshift({ name: this.searchText }); searchResults.unshift({ name: this.searchText });
} }
this.searchResults = searchResults
} catch (error) { } catch (error) {
console.error(); console.error();
this.showErrorMessage = true; this.showErrorMessage = true;

View File

@ -46,7 +46,14 @@ const routes = [
{ {
path: '/', path: '/',
name: 'Index', name: 'Index',
component: Index component: Index,
beforeEnter: (_to, _from, next) => {
if (store.state.token) {
next({name: 'Search'})
} else {
next()
}
}
}, },
] ]

View File

@ -4,17 +4,23 @@
import { createStore } from 'vuex' import { createStore } from 'vuex'
import profile from './profile'
import search from './search'
const localStorageKeys = { const localStorageKeys = {
currentUserId: 'ki_current_user_id', currentUserId: 'ki_current_user_id',
token: 'ki_token', token: 'ki_token',
} }
export default createStore({ export default createStore({
modules: {
profile,
search,
},
state() { state() {
return { return {
currentUserId: JSON.parse(localStorage.getItem(localStorageKeys.currentUserId)), currentUserId: JSON.parse(localStorage.getItem(localStorageKeys.currentUserId)),
token: JSON.parse(localStorage.getItem(localStorageKeys.token)), token: JSON.parse(localStorage.getItem(localStorageKeys.token)),
currentProfile: null
} }
}, },
mutations: { mutations: {
@ -37,9 +43,6 @@ export default createStore({
state.token = token state.token = token
localStorage.setItem(localStorageKeys.token, JSON.stringify(token)) localStorage.setItem(localStorageKeys.token, JSON.stringify(token))
}, },
setCurrentProfile(state, profile) {
state.currentProfile = profile
}
}, },
actions: { actions: {
clear(context) { clear(context) {

120
src/store/profile.js Normal file
View File

@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
export default {
namespaced: true,
state() {
return {
loading: false,
showSpinner: false,
profileId: null,
profile: null,
isOwnProfile: false,
error: false,
notFound: false
}
},
mutations: {
setProfileId(state, profileId) {
state.profileId = profileId
},
clearProfileId(state) {
state.profileId = null
},
setProfile(state, profile) {
state.profile = profile
},
clearProfile(state) {
state.profile = null
},
setLoading(state) {
state.loading = true
},
setNotLoading(state) {
state.loading = false
},
setError(state) {
state.error = true
},
clearError(state) {
state.error = false
},
showSpinner(state) {
state.showSpinner = true
},
hideSpinner(state) {
state.showSpinner = false
},
setNotFound(state, notFound) {
state.notFound = notFound
},
setIsOwnProfile(state, isOwnProfile) {
state.isOwnProfile = isOwnProfile
}
},
actions: {
onError({commit, dispatch}) {
commit('setError')
dispatch('clear')
},
onNotFound({commit, dispatch}) {
dispatch('onError')
commit('setNotFound', true)
},
clear({commit}) {
commit('clearProfileId')
commit('clearProfile')
commit('hideSpinner')
commit('setNotLoading')
},
async load({state, commit, dispatch, rootState}, profileId) {
if (state.loading) {
return
}
commit('setProfileId', profileId)
commit('setIsOwnProfile', rootState.currentUserId === profileId)
commit('setLoading')
const timeoutId = setTimeout(() => {
commit('showSpinner')
commit('clearProfile')
}, 0)
commit('clearError')
commit('setNotFound', false)
const url = new URL(`${window.ki.apiUrl}/users/${profileId}/profile`)
const headers = {
Authorization: `Bearer ${rootState.token}`
}
let response
try {
response = await fetch(url, {headers})
} catch {
dispatch('onError')
return
}
clearTimeout(timeoutId)
if (response.status === 404) {
dispatch('onNotFound')
return
}
if (!response.ok) {
dispatch('onError')
return
}
const responseData = await response.json()
commit('setProfile', responseData.profile)
commit('hideSpinner')
commit('setNotLoading')
}
}
}

122
src/store/search.js Normal file
View File

@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
export default {
namespaced: true,
state() {
return {
searching: false,
showSpinner: false,
searched: false,
profiles: [],
error: false,
errorMessage: '',
pages: 1,
query: {
search: '',
page: 1
}
}
},
mutations: {
setSearching(state, searching) {
state.searching = searching
},
showSpinner(state) {
state.showSpinner = true
},
hideSpinner(state) {
state.showSpinner = false
},
clearProfiles(state) {
state.profiles = []
},
setProfiles(state, profiles) {
state.profiles = profiles
},
setError(state, error) {
state.error = error
},
setErrorMessage(state, errorMessage) {
state.errorMessage = errorMessage
},
setQuerySearch(state, search) {
state.query = {...state.query, search}
},
setPages(state, pages) {
state.pages = pages
},
setQueryPage(state, page) {
state.query = {...state.query, page}
}
},
actions: {
async search({state, commit, rootState, dispatch}) {
if (state.searching) {
return
}
commit('setSearching', true)
const timeoutId = setTimeout(() => {
commit('showSpinner')
commit('clearProfiles')
}, 100)
commit('setError', false)
commit('setErrorMessage', '')
const url = new URL(`${window.ki.apiUrl}/users/profiles`)
if (state.query.search) {
url.searchParams.append('search', state.query.search)
}
url.searchParams.append('page', state.query.page)
const headers = {
Authorization: `Bearer ${rootState.token}`,
}
let response
try {
response = await fetch(url, {headers})
} catch {
commit('setError', true)
commit('clearProfiles')
commit('setSearching', false)
commit('hideSpinner')
return
}
console.log(response.ok)
console.log(response.status)
console.log(state.query.page)
clearTimeout(timeoutId)
if (!response.ok && response.status === 404 && state.query.page != 1) {
commit('setQueryPage', 1)
commit('setSearching', false)
await dispatch('search')
return
}
if (!response.ok) {
commit('setError', true)
commit('clearProfiles')
commit('setSearching', false)
commit('hideSpinner')
return
}
const responseData = await response.json()
commit('setProfiles', responseData.profiles)
commit('setPages', responseData.pages)
commit('setSearching', false)
commit('hideSpinner')
}
}
}

View File

@ -5,12 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<div class="bg-wtf">
<div class="container pt-5"> <div class="container pt-5">
<div class="text-center mb-5"> <div class="text-center mb-5">
<img class="wtf-logo wtf-logo--index" src="@/assets/img/wtf_logo.svg"> <img class="wtf-logo wtf-logo--index" src="@/assets/img/wtf_logo.svg">
<h1>Kompetenzinventar</h1> <h1 class="text-white">Kompetenzinventar</h1>
</div> </div>
<form @submit.prevent="submitLogin()" class="bg-white p-3 login-form"> <form @submit.prevent="submitLogin()" class="card bg-white login-form">
<div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label for="exampleInputusername1" class="form-label" > <label for="exampleInputusername1" class="form-label" >
WTF-Benutzername: WTF-Benutzername:
@ -21,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
id="exampleInputusername1" id="exampleInputusername1"
v-model="username" v-model="username"
required required
autofocus
/> />
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -42,9 +45,12 @@ SPDX-License-Identifier: AGPL-3.0-or-later
> >
Dein Benutzername oder Passwort ist falsch.<br>Versuche es noch einmal. Dein Benutzername oder Passwort ist falsch.<br>Versuche es noch einmal.
</div> </div>
</div>
</form> </form>
</div> </div>
</div>
</template> </template>
<script> <script>
import RequestMixin from "@/mixins/request.mixin" import RequestMixin from "@/mixins/request.mixin"
@ -62,6 +68,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
.container { .container {
min-height: 100vh; min-height: 100vh;
} }

View File

@ -5,88 +5,182 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<div class="content">
<div class="bg-wtf text-white pt-3 pb-4">
<div class="container"> <div class="container">
<h1>Suche</h1> <div class="fs-3 text-center lh-1 mb-3">Finde WTF Member</div>
<form @submit.prevent="submitSearch()"> <div class="card mx-auto bg-white">
<div class="row"> <div class="card-body">
<div class="col"> <form @submit.prevent="handleSubmit">
<fieldset class="d-flex" :disabled="searching">
<div class="flex-grow-1 me-3">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="searchText" id="searchText"
v-model="searchText" v-model="searchText"
placeholder="Nick, Name, Fähigkeit, Sprache"
ref="searchTextInput"
/> />
</div> </div>
<div class="col"> <div class="">
<button type="submit" class="btn btn-primary mb-4"> <button type="submit" class="btn btn-primary">
Suche starten <i class="bi-search"></i>
<span class="d-none d-md-inline"> Suchen</span>
</button> </button>
</div> </div>
</div> </fieldset>
</form> </form>
<div
class="alert alert-danger mb-4 mt-4"
role="alert"
v-if="showErrorMessage"
>
Bei der Suche ist ein Fehler aufgetreten
</div> </div>
<div v-if="searchTotal == 0"> </div>
</div>
</div>
<div class="container pt-4 pb-3">
<div class="text-center" v-if="showSpinner">
<Spinner />
</div>
<div
class="fs-2 text-danger text-center"
role="alert"
v-if="error"
>
<div class="fs-1 mb-3">Kernel panic :/</div>
Bei der Suche ist ein Fehler aufgetreten.
</div>
<div v-else-if="showNoResults" class="fs-2 text-black-50 text-center">
<div class="fs-1 mb-3">nullptr :/</div>
Es wurde kein Suchergebnis gefunden. Es wurde kein Suchergebnis gefunden.
<p v-if="searchText !== ''">Probiere eine andere Suche.</p> <p v-if="searchText !== ''">Probiere eine andere Suche.</p>
</div> </div>
<div v-else> <div v-else-if="showResults">
<div class="row"> <div class="d-flex justify-content-around">
<div <Paginator
class="col-4 p-2" :pages="pages"
v-for="result in searchResults" :current="currentPage"
:key="result.user_id" @page="handlePageSelected"
> />
<router-link
class="text-decoration-none"
:to="{ path: `/s/profile/${result.user_id}` }"
>
<div class="card search-card">
<div class="card-body">
<h5 class="card-title">
{{ result.nickname}}
<span v-if="result.pronouns"> ({{ result.pronouns }})</span>
</h5>
<p
class="card-text"
v-if="result.skills && result.skills.length > 0"
>
Fähigkeiten:
<span v-for="(skill, index) in result.skills" :key="index"
>
{{ skill.skill.name}}<span v-if="index != result.skills.length - 1">, </span>
</span>
</p>
</div> </div>
</div> <SearchResult
</router-link> v-for="profile in profiles"
:key="profile.user_id"
class="mb-3"
:profile="profile"
/>
<div class="d-flex justify-content-around">
<Paginator
:pages="pages"
:current="currentPage"
@page="handlePageSelected"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import RequestMixin from "@/mixins/request.mixin"; import { mapState } from 'vuex'
import Paginator from '@/components/Paginator'
import SearchResult from '@/components/SearchResult'
import Spinner from '@/components/Spinner'
export default { export default {
name: "Search", name: 'Search',
mixins: [RequestMixin], components: {
Paginator,
SearchResult,
Spinner,
},
data() { data() {
return { return {
showErrorMessage: false, textChanged: false
searchText: "", }
searchResults: null, },
searchTotal: 0, computed: {
}; ...mapState({
searching: state => state.search.searching,
profiles: state => state.search.profiles,
error: state => state.search.error,
showSpinner: state => state.search.showSpinner,
pages: state => state.search.pages,
}),
searchText: {
get() {
return this.$store.state.search.query.search
},
set(text) {
this.$store.commit('search/setQuerySearch', text)
this.textChanged = true
}
},
currentPage: {
get() {
return this.$store.state.search.query.page
},
set(page) {
this.$store.commit('search/setQueryPage', page)
}
},
showNoResults() {
return !this.searching && (!this.profiles || this.profiles.length === 0)
},
showResults() {
return !this.error && this.profiles && this.profiles.length > 0
}
},
watch: {
searching(value) {
if (!value) {
if (this.$refs.searchTextInput) {
this.focusSearchText()
}
}
}
},
methods: {
handleSubmit() {
if (this.textChanged === true) {
this.$store.commit('search/setQueryPage', 1)
}
this.pushState()
this.$store.dispatch('search/search')
},
focusSearchText() {
this.$nextTick(() => {
this.$refs.searchTextInput.focus()
})
},
handlePageSelected(page) {
this.currentPage = page
this.pushState()
this.$store.dispatch('search/search')
},
pushState() {
this.$router.push({ query: { query: this.searchText, page: this.currentPage }})
}
}, },
created() { created() {
if (this.$route.query.query) this.searchText = this.$route.query.query; if (this.$route.query.query) {
this.submitSearch(); this.searchText = this.$route.query.query
}, this.$store.commit('search/clearProfiles')
}
if (this.$route.query.page) {
this.currentPage = parseInt(this.$route.query.page, 10)
this.$store.commit('search/clearProfiles')
}
this.$store.dispatch('search/search')
}
}; };
</script> </script>
<style scoped>
.container {
max-width: 768px;
}
.content {
min-height: calc(100vh - 60px);
}
</style>

View File

@ -5,190 +5,260 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<div>
<div class="bg-wtf py-3 mb-4">
<div class="container">
<h3 class="text-white text-center mb-0">Profil bearbeiten</h3>
</div>
</div>
<div class="container"> <div class="container">
<h1>Profil bearbeiten</h1>
<form @submit.prevent="submitFormEdit()"> <form @submit.prevent="submitFormEdit()">
<div class="row"> <Section title="Grunddaten">
<div class="col"> <div class="mb-4">
<div class="form-check form-switch">
<input <input
type="radio" class="form-check-input"
id="false" type="checkbox"
:value="false" role="switch"
v-model="profile.visible" v-model="profile.visible"
class="mr-2" id="visibility"
/> >
<label for="false" class="m-2 fw-bold"> Nicht öffentlich</label> <label
</div> class="form-check-label"
<div class="col"> for="visibility">
<input Profil für angemeldete Benutzer sichtbar
type="radio" </label>
id="true"
:value="true"
v-model="profile.visible"
/>
<label for="true" class="m-2 fw-bold"> Öffentlich</label>
</div> </div>
</div> </div>
<div id="visibilityHelp" class="form-text">
Erst wenn du dein Profil Öffentlich stellst, können andere Genoss:innen <div class="row mb-4">
darauf zugreifen oder es in der Suche finden. <div class="col-12 col-md-4 mb-3 mb-md-0">
</div> <label class="form-label">Nickname</label>
<div class="row">
<div class="col-6 col-xs-12">
<label for="nickname" class="form-label fw-bold">Nickname:</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="nickname" id="nickname"
maxlength="25"
v-model="profile.nickname" v-model="profile.nickname"
required required
/> />
</div> </div>
<div class="col-6 col-xs-12"> <div class="col-12 col-md-4 mb-3 mb-md-0">
<label for="pronouns" class="form-label fw-bold">Pronomen:</label> <label class="form-label">Klarname (optional)</label>
<input
type="text"
class="form-control"
id="realname"
maxlength="25"
v-model="profile.address.name"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label">
Pronomen
<i class="bi bi-info-circle" v-tooltip="pronounsTooltip"></i>
</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="pronouns" id="pronouns"
maxlength="25"
v-model="profile.pronouns" v-model="profile.pronouns"
/> />
<div for="pronouns" class="form-text">
Z.B.: Er/Ihn, Sie/Ihr, Es etc..
</div>
</div> </div>
</div> </div>
<label class="form-label">Anschrift</label>
<div class="row"> <div class="row">
<div class="col-12 col-xs-12"> <div class="col-12 col-md-4 mb-3 mb-md-0">
<label for="freetext" class="form-label fw-bold">Vorstellung:</label>
<textarea
class="form-control"
id="freetext"
rows="3"
v-model="profile.freetext"
></textarea>
</div>
<div class="col-12 col-xs-12">
<label for="volunteerwork" class="form-label fw-bold"
>Ehrenamtliche Arbeit:</label
>
<textarea
class="form-control"
id="volunteerwork"
rows="3"
v-model="profile.volunteerwork"
></textarea>
</div>
</div>
<auto-complete
type="skill"
label="Deine Fähigkeiten"
:values="profile.skills"
@update-values="profile.skills = $event"
></auto-complete>
<auto-complete
type="language"
label="Deine Sprachen"
:values="profile.languages"
@update-values="profile.languages = $event"
></auto-complete>
<auto-complete
type="skill"
label="Ich suche"
:values="profile.searchtopics"
@update-values="profile.searchtopics = $event"
></auto-complete>
<div class="col-12 col-xs-12">
<label for="availability" class="form-label fw-bold"
>Ich bin für Anfragen verfügbar:</label
>
<textarea
class="form-control"
id="availability"
rows="3"
v-model="profile.availability"
></textarea>
</div>
<auto-complete
type="contacttype"
label="Kontaktmöglichkeiten"
:values="profile.contacts"
@update-values="profile.contacts = $event"
></auto-complete>
<div class="row">
<div class="col">
<label for="pzl" class="form-label fw-bold">PLZ</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="pzl" id="postcode"
maxlength="10"
placeholder="Postleitzahl"
v-model="profile.address.postcode" v-model="profile.address.postcode"
/> />
</div> </div>
<div class="col"> <div class="col-12 col-md-4 mb-3 mb-md-0">
<label for="city" class="form-label fw-bold">Stadt</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="city" id="city"
maxlength="25"
placeholder="Ort"
v-model="profile.address.city" v-model="profile.address.city"
/> />
</div> </div>
<div class="col"> <div class="col-12 col-md-4">
<label for="country" class="form-label fw-bold">Land</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="country" id="country"
maxlength="25"
placeholder="Land"
v-model="profile.address.country" v-model="profile.address.country"
/> />
</div> </div>
</div> </div>
</Section>
<button type="submit" class="btn btn-outline-success mb-4 mt-4 col-12"> <Section title="Meine Fähigkeiten">
Speichern <template v-slot:card-body>
</button> <auto-complete
type="skill"
:values="profile.skills"
placeholder="z.B. Python, JavaScript, Linux"
@update-values="profile.skills = $event"
></auto-complete>
</template>
</Section>
<Section title="Meine Sprachkenntnisse">
<template v-slot:card-body>
<auto-complete
type="language"
:values="profile.languages"
placeholder="z.B. Deutsch, Englisch, Französisch"
@update-values="profile.languages = $event"
></auto-complete>
</template>
</Section>
<Section title="Ich suche für mich Projekte/Aufträge in folgenden Bereichen">
<template v-slot:card-body>
<auto-complete
type="skill"
label="Ich suche für mich Projekte/Aufträge in folgenden Bereichen"
:values="profile.searchtopics"
:showSecondary="false"
placeholder="z.B. Python, JavaScript, Linux"
@update-values="profile.searchtopics = $event"
></auto-complete>
</template>
</Section>
<Section title="Verfügbarkeit">
<div class="form-check mb-3">
<input
v-model="profile.availability_status"
class="form-check-input me-2"
type="checkbox"
id="availability_status">
<label class="form-check-label" for="availability_status">
Ich bin aktuell verfügbar
</label>
</div>
<div class="mb-3" v-if="profile.availability_status">
<label class="form-label">
Stunden pro Woche
</label>
<input
v-model="profile.availability_hours_per_week"
type="number"
min="0"
max="168"
class="form-control">
</div>
<label for="availability" class="form-label">
Anmerkungen
</label>
<textarea
class="form-control"
id="availability"
rows="3"
maxlength="4000"
v-model="profile.availability_text"
></textarea>
</Section>
<Section title="Meine Kontaktmöglichkeiten">
<template v-slot:card-body>
<auto-complete
type="contacttype"
:values="profile.contacts"
placeholder="z.B. E-Mail, Mobiltelefon, Matrix, Web"
@update-values="profile.contacts = $event"
></auto-complete>
</template>
</Section>
<Section title="Sonstiges">
<div class="mb-3">
<label class="form-label">Über mich</label>
<textarea
class="form-control"
rows="3"
maxlength="4000"
v-model="profile.freetext"
/>
</div>
<div>
<label class="form-label">Ehrenamtliche Arbeit</label>
<textarea
class="form-control"
rows="3"
maxlength="4000"
v-model="profile.volunteerwork"
/>
</div>
</Section>
<input type="submit" class="d-none">
</form>
</div>
<div class="savebar bg-white border-top py-3">
<div class="container d-flex align-items-center justify-content-end">
<div <div
class="alert alert-danger mb-4 mt-4" class="text-danger"
role="alert"
v-if="showErrorMessage" v-if="showErrorMessage"
> >
Es ist Fehler aufgetreten <i class="bi bi-bug"></i>
Beim Speichern ist ein Fehler aufgetreten.
</div> </div>
<div <div
class="alert alert-success mb-4 mt-4" class="text-success"
role="alert"
v-if="showSuccessMessage" v-if="showSuccessMessage"
> >
Deine Änderungen wurden erfolgreich gespeichert <i class="bi bi-check-lg"></i>
Gespeichert
</div>
<button
class="btn btn-primary ms-3"
@click="submitFormEdit()"
>
Speichern
</button>
</div>
</div> </div>
</form>
</div> </div>
</template> </template>
<script> <script>
import RequestMixin from "@/mixins/request.mixin" import RequestMixin from "@/mixins/request.mixin"
import AutoComplete from "@/components/AutoComplete"; import AutoComplete from "@/components/AutoComplete";
import Section from '@/components/profile/Section'
export default { export default {
name: "profileEdit", name: "profileEdit",
mixins: [RequestMixin], mixins: [RequestMixin],
components: { components: {
AutoComplete, AutoComplete,
Section,
}, },
data() { data() {
return { return {
showErrorMessage: false, showErrorMessage: false,
showSuccessMessage: false, showSuccessMessage: false,
clearMessagesHandle: null,
profile: { profile: {
visible: false, visible: false,
nickname: "", nickname: "",
pronouns: "", pronouns: "",
volunteerwork: "", volunteerwork: "",
freetext: "", freetext: "",
availability: "", availability_status: false,
availability_hours_per_week: null,
availability_text: "",
address: { address: {
postcode: "", postcode: "",
city: "", city: "",
@ -199,10 +269,49 @@ export default {
searchtopics: [], searchtopics: [],
contacts: [], contacts: [],
}, },
pronounsTooltip: {
content: 'Wie möchtest du angesprochen werden?<br>Zum Beispiel "er/ihn" oder "sie/ihre".',
html: true
}
}; };
}, },
async created() { async created() {
await this.initEditPage(); await this.initEditPage();
}, },
unmounted() {
this.cancelClearMessages()
},
methods: {
cancelClearMessages() {
if (this.clearMessagesHandle) {
window.clearTimeout(this.clearMessagesHandle)
}
},
scheduleClearMessages() {
this.cancelClearMessages()
this.clearMessagesHandle = window.setTimeout(() => {
this.showErrorMessage = false
this.showSuccessMessage = false
}, 2500)
}
},
watch: {
showErrorMessage(curr) {
if (curr) {
this.scheduleClearMessages()
}
},
showSuccessMessage(curr) {
if (curr) {
this.scheduleClearMessages()
}
}
}
}; };
</script> </script>
<style scoped>
.savebar {
bottom: 0;
position: sticky;
}
</style>

View File

@ -5,60 +5,156 @@ SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<template> <template>
<div v-if="profile" class="container"> <div>
<h1> <template v-if="error">
{{profile.nickname}} <ViewError :isOwnProfile="isOwnProfile" :notFound="notFound" />
<span v-if="profile.pronouns">({{profile.pronouns}})</span> </template>
</h1> <template
<p><label class="fw-bold">Vorstellung: </label> {{profile.freetext}}</p> v-else-if="profile"
<p><label class="fw-bold">Ehrentamtliche Arbeit: </label> {{profile.volunteerwork}}</p> class="container">
<p><label class="fw-bold">Verfügbarkeit: </label> {{profile.availability}}</p> <ProfileHeader
<h3>Das kann ich:</h3> class="mb-4"
<profile-list :profile="profile" />
:values="profile.skills"
type="skill" <Section
></profile-list> v-if="profile.skills && profile.skills.length > 0"
<h3>Das suche ich:</h3> title="Das kann ich">
<profile-list <div style="margin-bottom: -.5rem;">
:values="profile.searchtopics" <Skill
type="skill" class="me-2 mb-2"
></profile-list> v-for="skill in profile.skills"
<h3>Meine Kontaktmöglichkeiten:</h3> :key="skill.skill.id"
<profile-list :profileSkill="skill"
:values="profile.contacts" :showLevel="true" />
type="contacttype"
></profile-list>
<h3>Ich Spreche Folgende Sprachen:</h3>
<profile-list
:values="profile.languages"
type="language"
></profile-list>
<div v-if="profile.address">
<h3>Meine Location:</h3>
{{profile.address.city}}<span v-if="profile.address && profile.address.postcode"> ({{profile.address.postcode}})</span>, {{profile.address.country}}
</div> </div>
</Section>
<Section
v-if="profile.searchtopics && profile.searchtopics.length > 0"
title="Ich suche für mich Projekte/Aufträge in folgenden Bereichen">
<div style="margin-bottom: -.5rem;">
<Skill
class="me-2 mb-2"
v-for="skill in profile.searchtopics"
:key="skill.skill.id"
:profileSkill="skill"
:showLevel="false" />
</div>
</Section>
<Section
v-if="profile.languages && profile.languages.length > 0"
title="Ich spreche diese Sprachen">
<div style="margin-bottom: -.5rem;">
<Language
class="me-2 mb-2"
v-for="language in profile.languages"
:key="language.language.id"
:profileLanguage="language"
/>
</div>
</Section>
<Section title="Verfügbarkeit">
<div class="d-flex align-items-center">
<div v-if="profile.availability_status">
<i class="bi bi-check-square me-1"></i>
ja
</div>
<div v-else>
<i class="bi bi-x-square me-1"></i>
nein
</div>
<span
class="ms-3"
v-if="profile.availability_status && profile.availability_hours_per_week">
({{ profile.availability_hours_per_week }} Stunden pro Woche)
</span>
</div>
<div v-if="profile.availability_text" class="mt-3">
<label class="form-label fw-bold">
Anmerkungen
</label>
<div class="line-break-text">{{ profile.availability_text }}</div>
</div>
</Section>
<Section
v-if="profile.contacts && profile.contacts.length > 0"
title="Meine Kontaktmöglichkeiten"
>
<div style="margin-bottom: -.5rem;">
<Contact
class="me-2 mb-2"
v-for="profileContact in profile.contacts"
:key="profileContact.id"
:profileContact="profileContact"
/>
</div>
</Section>
<Section
v-if="profile.volunteerwork || profile.freetext"
title="Sonstiges">
<div v-if="profile.freetext" :class="{ 'lh-base': true, 'mb-4': profile.volunteerwork }">
<h5>Über mich</h5>
<div class="line-break-text">{{ profile.freetext }}</div>
</div>
<div v-if="profile.volunteerwork" class="lh-base">
<h5>Ehrenamtliche Arbeit</h5>
<div class="line-break-text">{{ profile.volunteerwork }}</div>
</div>
</Section>
</template>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from 'vuex' import { mapState, mapActions } from 'vuex'
import RequestMixin from '@/mixins/request.mixin' import ViewError from '@/components/ViewError'
import ProfileHeader from '@/components/profile/Header'
import ProfileList from '@/components/ProfileList'; import Section from '@/components/profile/Section'
import Contact from '@/components/profile/Contact'
import Language from '@/components/profile/Language'
import Skill from '@/components/Skill'
export default { export default {
name: "profileView", name: 'profileView',
mixins: [RequestMixin],
components: { components: {
ProfileList, Contact,
Language,
ProfileHeader,
Section,
Skill,
ViewError,
},
methods: {
...mapActions({
loadProfile: 'profile/load',
clearStore: 'profile/clear',
})
}, },
computed: { computed: {
...mapState({ ...mapState({
profile: 'currentProfile' profile: state => state.profile.profile,
error: state => state.profile.error,
notFound: state => state.profile.notFound,
isOwnProfile: state => state.profile.isOwnProfile,
showSpinner: state => state.profile.showSpinner
}) })
}, },
async created() { async created() {
await this.initViewPage(); const id = parseInt(this.$route.params.memberId, 10)
} this.loadProfile(id)
},
unmounted() {
this.clearStore()
},
}; };
</script> </script>
<style>
.line-break-text{
white-space: pre-line;
}
</style>