Compare commits

..

15 Commits

Author SHA1 Message Date
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
18 changed files with 658 additions and 95 deletions

View File

@ -20,3 +20,6 @@ steps:
from_secret: "docker_username"
password:
from_secret: "docker_password"
when:
branch:
- main

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.

View File

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

@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
:class="{ show: showMobileNav }"
id="navbarSupportedContent"
>
<ul class="navbar-nav mb-2 mb-lg-0">
<ul class="navbar-nav mb-2 mb-lg-0 me-auto">
<li class="nav-item">
<router-link
class="nav-link"
@ -56,21 +56,6 @@ SPDX-License-Identifier: AGPL-3.0-or-later
>
</li>
</ul>
<form class="flex-grow-1 d-flex ms-lg-3 me-lg-4 mb-3 mb-lg-0" @submit.prevent="searchRedirect()">
<input
class="form-control bg-white me-2"
v-model="searchText"
type="search"
placeholder="Profile durchsuchen"
aria-label="Search"
/>
<button
class="btn btn-primary"
type="submit"
>
<i class="bi bi-search"></i>
</button>
</form>
<ul class="navbar-nav">
<li class="nav-item">
<button class="btn btn-danger w-100" @click="logout()">
@ -95,7 +80,6 @@ export default {
mixins: [RequestMixin],
data() {
return {
searchText: '',
showMobileNav: false
}
},
@ -110,9 +94,6 @@ export default {
this.$store.dispatch('clear')
this.$router.push({ path: '/' });
},
searchRedirect() {
this.$router.push({ path: `/s/search?text`, query: { query: this.searchText } } );
},
}
}

View File

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
:value="key"
:key="key"
>
{{ value }}
{{ value.long || value }}
</option>
</select>
</div>
@ -90,11 +90,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</ul>
</template>
<script>
import levelJson from "@/assets/skill_level.json";
import languagesJson from "@/assets/language_level.json";
import levelJson from '@/assets/skill_level.json';
import languagesJson from '@/assets/language_level.json';
export default {
name: "ProfileList",
name: 'ProfileList',
props: {
type: {
type: String,

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 px-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,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,71 @@
<!--
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="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,26 @@
<!--
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">
<div class="card-body">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
title: String
}
}
</script>

View File

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

View File

@ -4,6 +4,7 @@
import { createStore } from 'vuex'
import profile from './profile'
import search from './search'
const localStorageKeys = {
@ -13,13 +14,13 @@ const localStorageKeys = {
export default createStore({
modules: {
profile,
search,
},
state() {
return {
currentUserId: JSON.parse(localStorage.getItem(localStorageKeys.currentUserId)),
token: JSON.parse(localStorage.getItem(localStorageKeys.token)),
currentProfile: null
}
},
mutations: {
@ -42,9 +43,6 @@ export default createStore({
state.token = token
localStorage.setItem(localStorageKeys.token, JSON.stringify(token))
},
setCurrentProfile(state, profile) {
state.currentProfile = profile
}
},
actions: {
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')
}
}
}

View File

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
id="exampleInputusername1"
v-model="username"
required
autofocus
/>
</div>
<div class="mb-3">

View File

@ -100,16 +100,22 @@ export default {
},
watch: {
searching(value) {
if (value) {
if (!value) {
if (this.$refs.searchTextInput) {
this.$refs.searchTextInput.focus()
this.focusSearchText()
}
}
}
},
methods: {
handleSubmit() {
this.$router.push({ query: { query: this.searchText }})
this.$store.dispatch('search/search')
},
focusSearchText() {
this.$nextTick(() => {
this.$refs.searchTextInput.focus()
})
}
},
created() {

View File

@ -98,17 +98,36 @@ SPDX-License-Identifier: AGPL-3.0-or-later
@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
>
<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"
class="form-control">
</div>
<label for="availability" class="form-label">
Anmerkungen
</label>
<textarea
class="form-control"
id="availability"
rows="3"
v-model="profile.availability"
v-model="profile.availability_text"
></textarea>
</div>
</Section>
<auto-complete
type="contacttype"
@ -171,12 +190,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
import RequestMixin from "@/mixins/request.mixin"
import AutoComplete from "@/components/AutoComplete";
import Section from '@/components/profile/Section'
export default {
name: "profileEdit",
mixins: [RequestMixin],
components: {
AutoComplete,
Section,
},
data() {
return {
@ -188,7 +209,9 @@ export default {
pronouns: "",
volunteerwork: "",
freetext: "",
availability: "",
availability_status: false,
availability_hours_per_week: null,
availability_text: "",
address: {
postcode: "",
city: "",

View File

@ -5,60 +5,153 @@ SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div v-if="profile" class="container">
<h1>
{{profile.nickname}}
<span v-if="profile.pronouns">({{profile.pronouns}})</span>
</h1>
<p><label class="fw-bold">Vorstellung: </label> {{profile.freetext}}</p>
<p><label class="fw-bold">Ehrentamtliche Arbeit: </label> {{profile.volunteerwork}}</p>
<p><label class="fw-bold">Verfügbarkeit: </label> {{profile.availability}}</p>
<h3>Das kann ich:</h3>
<profile-list
:values="profile.skills"
type="skill"
></profile-list>
<h3>Das suche ich:</h3>
<profile-list
:values="profile.searchtopics"
type="skill"
></profile-list>
<h3>Meine Kontaktmöglichkeiten:</h3>
<profile-list
:values="profile.contacts"
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>
<template v-if="error">
<ViewError :isOwnProfile="isOwnProfile" :notFound="notFound" />
</template>
<template
v-else-if="profile"
class="container">
<ProfileHeader
class="mb-4"
:profile="profile" />
<Section
v-if="profile.skills && profile.skills.length > 0"
title="Das kann ich">
<div style="margin-bottom: -.5rem;">
<Skill
class="me-2 mb-2"
v-for="skill in profile.skills"
:key="skill.skill.id"
:profileSkill="skill"
:showLevel="true" />
</div>
</Section>
<Section
v-if="profile.searchtopics && profile.searchtopics.length > 0"
title="Das suche ich">
<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>{{ 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>
{{ profile.freetext }}
</div>
<div v-if="profile.volunteerwork" class="lh-base">
<h5>Ehrentamtliche Arbeit</h5>
{{ profile.volunteerwork }}
</div>
</Section>
</template>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { mapState, mapActions } from 'vuex'
import RequestMixin from '@/mixins/request.mixin'
import ProfileList from '@/components/ProfileList';
import ViewError from '@/components/ViewError'
import ProfileHeader from '@/components/profile/Header'
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 {
name: "profileView",
mixins: [RequestMixin],
name: 'profileView',
components: {
ProfileList,
Contact,
Language,
ProfileHeader,
Section,
Skill,
ViewError,
},
methods: {
...mapActions({
loadProfile: 'profile/load',
clearStore: 'profile/clear',
})
},
computed: {
...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() {
await this.initViewPage();
}
const id = parseInt(this.$route.params.memberId, 10)
this.loadProfile(id)
},
unmounted() {
this.clearStore()
},
};
</script>
<style>
</style>