refactor: organize frontend by feature
This commit is contained in:
192
frontend/src/features/user-profile/stores/userProfileStore.js
Normal file
192
frontend/src/features/user-profile/stores/userProfileStore.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import {computed, watch} from 'vue'
|
||||
import {defineStore} from 'pinia'
|
||||
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
||||
import {useClient} from "@/plugins/api.js";
|
||||
import {useSessionStorage} from "@vueuse/core";
|
||||
|
||||
export const useUserProfileStore = defineStore(
|
||||
'user-profile',
|
||||
() => {
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const authWatcher = watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchCurrentUserProfile()
|
||||
} else if (!authStore.isRefreshing) {
|
||||
value.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const value = useSessionStorage(
|
||||
'user-profile',
|
||||
{},
|
||||
{writeDefaults: false})
|
||||
|
||||
const fullname = computed(() => {
|
||||
if (value.value) {
|
||||
const {firstname, lastname} = value.value;
|
||||
|
||||
if (firstname && lastname) {
|
||||
return `${lastname}, ${firstname}`;
|
||||
} else if (firstname) {
|
||||
return firstname;
|
||||
} else if (lastname) {
|
||||
return lastname;
|
||||
}
|
||||
}
|
||||
return 'n/a';
|
||||
})
|
||||
|
||||
const alias = computed(() => {
|
||||
if (value.value) {
|
||||
return value.value.alias || `${value.value.firstname || ''} ${value.value.lastname || ''}`.trim() || 'Anonyme'
|
||||
}
|
||||
return 'Anonyme';
|
||||
})
|
||||
|
||||
const portraitUrl = computed(() => {
|
||||
return value.value && value.value.portraitUrl
|
||||
? value.value.portraitUrl
|
||||
: null
|
||||
})
|
||||
|
||||
const roles = computed(() => value.value?.userRoles ?? [])
|
||||
const persona = computed(() => value.value?.persona ?? null)
|
||||
const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? [])
|
||||
const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? [])
|
||||
const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? [])
|
||||
|
||||
async function fetchCurrentUserProfile() {
|
||||
try {
|
||||
const client = useClient()
|
||||
const userResponse = await client.get("/api/users/profile");
|
||||
value.value = userResponse.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFullname(firstname, lastname) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/fullname`,
|
||||
{
|
||||
firstname: firstname,
|
||||
lastname: lastname
|
||||
})
|
||||
value.value.firstname = firstname;
|
||||
value.value.lastname = lastname;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAlias(alias) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/alias`,
|
||||
{
|
||||
alias: alias
|
||||
})
|
||||
value.value.alias = alias;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeBirthday(birthdate) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/birthdate`,
|
||||
{
|
||||
birthdate: birthdate
|
||||
})
|
||||
value.value.birthDate = birthdate;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changePhone(phoneNumber) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/phone`,
|
||||
{
|
||||
phoneNumber: phoneNumber
|
||||
})
|
||||
value.value.phoneNumber = phoneNumber;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEmail(email) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/email`,
|
||||
{
|
||||
email: email
|
||||
})
|
||||
value.value.email = email;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAddress(address) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/address`,
|
||||
{
|
||||
address: address
|
||||
})
|
||||
value.value.address = address;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changePortrait(selectedFile) {
|
||||
try {
|
||||
const client = useClient()
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile, selectedFile.name || 'portrait.png')
|
||||
|
||||
const response = await client.post(
|
||||
`/api/users/portrait`,
|
||||
formData)
|
||||
|
||||
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: value,
|
||||
alias,
|
||||
fullname,
|
||||
portraitUrl,
|
||||
roles,
|
||||
persona,
|
||||
authorizedWorkspaceIds,
|
||||
authorizedClientIds,
|
||||
authorizedProjectIds,
|
||||
changeFullname,
|
||||
changeAlias,
|
||||
changeBirthday,
|
||||
changePhone,
|
||||
changeEmail,
|
||||
changeAddress,
|
||||
changePortrait
|
||||
}
|
||||
})
|
||||
163
frontend/src/features/user-profile/views/UserSettingsView.vue
Normal file
163
frontend/src/features/user-profile/views/UserSettingsView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const { t } = useI18n();
|
||||
const isPortraitDialogOpen = ref(false);
|
||||
const isSavingPortrait = ref(false);
|
||||
|
||||
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
|
||||
const alias = computed(() => userProfileStore.alias);
|
||||
const fullname = computed(() => userProfileStore.fullname);
|
||||
|
||||
async function savePortrait(result) {
|
||||
isSavingPortrait.value = true;
|
||||
|
||||
try {
|
||||
await userProfileStore.changePortrait(result.file);
|
||||
isPortraitDialogOpen.value = false;
|
||||
} finally {
|
||||
isSavingPortrait.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="eyebrow">{{ t('userSettings.eyebrow') }}</div>
|
||||
<h1>{{ t('userSettings.title') }}</h1>
|
||||
<p>{{ t('userSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel hero-panel">
|
||||
<div class="hero-identity">
|
||||
<AppAvatar
|
||||
:name="alias"
|
||||
:src="userProfileStore.portraitUrl"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ alias }}</strong>
|
||||
<span>{{ fullname }}</span>
|
||||
<small>{{ email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
@click="isPortraitDialogOpen = true"
|
||||
>
|
||||
{{ t('userSettings.updatePortrait') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('userSettings.accountDetails') }}</strong>
|
||||
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.alias') }}</span>
|
||||
<strong>{{ alias }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.fullName') }}</span>
|
||||
<strong>{{ fullname }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.email') }}</span>
|
||||
<strong>{{ email }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isPortraitDialogOpen"
|
||||
:title="t('userSettings.cropperTitle')"
|
||||
:confirm-label="t('userSettings.savePortrait')"
|
||||
:upload-label="t('userSettings.choosePortrait')"
|
||||
:is-saving="isSavingPortrait"
|
||||
@save="savePortrait"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.panel-heading span,
|
||||
.hero-identity span,
|
||||
.hero-identity small,
|
||||
.detail-row span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
@apply flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between;
|
||||
}
|
||||
|
||||
.hero-identity {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.hero-identity strong,
|
||||
.panel-heading strong,
|
||||
.detail-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-identity strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.panel-heading strong {
|
||||
@apply text-lg font-black;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user