Compare commits

...

4 Commits

Author SHA1 Message Date
fd07373d43
implement profile view
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-10-03 17:56:52 +02:00
e6e44d5de8
redirect users with token #38 2021-09-24 17:51:37 +02:00
c3cc51dbf9
implement seach view autofocus #41 2021-09-24 17:45:11 +02:00
9906e32f45
add login page name field autofocus #42 2021-09-24 17:39:24 +02:00
14 changed files with 597 additions and 58 deletions

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

@ -32,7 +32,7 @@ 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>
@ -90,11 +90,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,

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 lh-1">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
title: String
}
}
</script>

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,6 +4,7 @@
import { createStore } from 'vuex' import { createStore } from 'vuex'
import profile from './profile'
import search from './search' import search from './search'
const localStorageKeys = { const localStorageKeys = {
@ -13,13 +14,13 @@ const localStorageKeys = {
export default createStore({ export default createStore({
modules: { modules: {
profile,
search, 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: {
@ -42,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) {

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

@ -0,0 +1,117 @@
// 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}) {
commit('setError')
commit('clearProfileId')
commit('clearProfile')
commit('setNotLoading')
commit('hideSpinner')
},
onNotFound({commit, dispatch}) {
dispatch('onError')
commit('setNotFound', true)
},
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('setNotSearching')
}
}
}

View File

@ -23,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">

View File

@ -100,9 +100,9 @@ export default {
}, },
watch: { watch: {
searching(value) { searching(value) {
if (value) { if (!value) {
if (this.$refs.searchTextInput) { if (this.$refs.searchTextInput) {
this.$refs.searchTextInput.focus() this.focusSearchText()
} }
} }
} }
@ -110,6 +110,11 @@ export default {
methods: { methods: {
handleSubmit() { handleSubmit() {
this.$store.dispatch('search/search') this.$store.dispatch('search/search')
},
focusSearchText() {
this.$nextTick(() => {
this.$refs.searchTextInput.focus()
})
} }
}, },
created() { created() {

View File

@ -5,60 +5,131 @@ 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="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
v-if="profile.availability"
title="Verfügbarkeit">
<div class="lh-base">{{ profile.availability }}</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> </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({
load: 'profile/load'
})
}, },
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.load(id)
} }
}; };
</script> </script>
<style>
</style>