505 lines
15 KiB
Vue
505 lines
15 KiB
Vue
<script setup>
|
|
import { computed, reactive, ref, watch } 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';
|
|
import config from '@/config.js';
|
|
|
|
const userProfileStore = useUserProfileStore();
|
|
const { t } = useI18n();
|
|
const isPortraitDialogOpen = ref(false);
|
|
const isSavingPortrait = ref(false);
|
|
const settingsError = ref(null);
|
|
const settingsStatus = ref(null);
|
|
const calendarFeedStatus = ref(null);
|
|
const calendarFeedError = ref(null);
|
|
const form = reactive({
|
|
firstname: '',
|
|
lastname: '',
|
|
alias: '',
|
|
email: '',
|
|
});
|
|
|
|
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
|
|
const alias = computed(() => userProfileStore.alias);
|
|
const fullname = computed(() => userProfileStore.fullname);
|
|
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
|
|
const calendarFeedUrl = computed(() => {
|
|
const feedUrl = userProfileStore.calendarExportFeed?.feedUrl;
|
|
|
|
if (!feedUrl) {
|
|
return '';
|
|
}
|
|
|
|
return feedUrl.startsWith('http')
|
|
? feedUrl
|
|
: `${config.apiUrl.replace(/\/$/, '')}${feedUrl}`;
|
|
});
|
|
|
|
function syncFormFromUser(user) {
|
|
form.firstname = user?.firstname ?? '';
|
|
form.lastname = user?.lastname ?? '';
|
|
form.alias = user?.alias ?? '';
|
|
form.email = user?.email ?? '';
|
|
}
|
|
|
|
async function submitSettings() {
|
|
if (!form.email.trim()) {
|
|
settingsError.value = t('userSettings.errors.emailRequired');
|
|
settingsStatus.value = null;
|
|
return;
|
|
}
|
|
|
|
const user = userProfileStore.user ?? {};
|
|
const nextFirstname = form.firstname.trim();
|
|
const nextLastname = form.lastname.trim();
|
|
const nextAlias = form.alias.trim();
|
|
const nextEmail = form.email.trim();
|
|
|
|
settingsError.value = null;
|
|
settingsStatus.value = null;
|
|
|
|
try {
|
|
if (nextFirstname !== (user.firstname ?? '') || nextLastname !== (user.lastname ?? '')) {
|
|
await userProfileStore.changeFullname(nextFirstname, nextLastname);
|
|
}
|
|
|
|
if (nextAlias !== (user.alias ?? '')) {
|
|
await userProfileStore.changeAlias(nextAlias || null);
|
|
}
|
|
|
|
let emailChangeRequested = false;
|
|
if (nextEmail !== (user.email ?? '')) {
|
|
await userProfileStore.changeEmail(nextEmail);
|
|
emailChangeRequested = true;
|
|
}
|
|
|
|
settingsStatus.value = emailChangeRequested
|
|
? t('userSettings.emailConfirmationSent')
|
|
: t('userSettings.saved');
|
|
|
|
if (!emailChangeRequested) {
|
|
syncFormFromUser(userProfileStore.user);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update user settings:', error);
|
|
settingsError.value = t('userSettings.errors.saveFailed');
|
|
}
|
|
}
|
|
|
|
async function savePortrait(result) {
|
|
isSavingPortrait.value = true;
|
|
settingsError.value = null;
|
|
settingsStatus.value = null;
|
|
|
|
try {
|
|
await userProfileStore.changePortrait(result.file);
|
|
isPortraitDialogOpen.value = false;
|
|
settingsStatus.value = t('userSettings.portraitSaved');
|
|
} catch (error) {
|
|
console.error('Failed to update user portrait:', error);
|
|
settingsError.value = t('userSettings.errors.portraitFailed');
|
|
} finally {
|
|
isSavingPortrait.value = false;
|
|
}
|
|
}
|
|
|
|
async function enableCalendarFeed() {
|
|
await updateCalendarFeed(() => userProfileStore.enableCalendarExportFeed(), t('userSettings.calendarFeed.enabled'));
|
|
}
|
|
|
|
async function regenerateCalendarFeed() {
|
|
await updateCalendarFeed(() => userProfileStore.regenerateCalendarExportFeed(), t('userSettings.calendarFeed.regenerated'));
|
|
}
|
|
|
|
async function revokeCalendarFeed() {
|
|
await updateCalendarFeed(() => userProfileStore.revokeCalendarExportFeed(), t('userSettings.calendarFeed.revoked'));
|
|
}
|
|
|
|
async function copyCalendarFeedUrl() {
|
|
if (!calendarFeedUrl.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(calendarFeedUrl.value);
|
|
calendarFeedStatus.value = t('userSettings.calendarFeed.copied');
|
|
calendarFeedError.value = null;
|
|
} catch (error) {
|
|
console.error('Failed to copy calendar feed URL:', error);
|
|
calendarFeedStatus.value = null;
|
|
calendarFeedError.value = t('userSettings.calendarFeed.errors.copyFailed');
|
|
}
|
|
}
|
|
|
|
async function updateCalendarFeed(action, successMessage) {
|
|
calendarFeedStatus.value = null;
|
|
calendarFeedError.value = null;
|
|
|
|
try {
|
|
await action();
|
|
calendarFeedStatus.value = successMessage;
|
|
} catch (error) {
|
|
console.error('Failed to update calendar feed:', error);
|
|
calendarFeedError.value = t('userSettings.calendarFeed.errors.updateFailed');
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => userProfileStore.user,
|
|
syncFormFromUser,
|
|
{ immediate: true, deep: true }
|
|
);
|
|
|
|
userProfileStore.fetchCalendarExportFeed();
|
|
</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"
|
|
type="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
|
|
v-if="settingsError"
|
|
class="page-message error"
|
|
>
|
|
{{ settingsError }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="settingsStatus"
|
|
class="page-message success"
|
|
>
|
|
{{ settingsStatus }}
|
|
</div>
|
|
|
|
<form
|
|
class="form-stack"
|
|
@submit.prevent="submitSettings"
|
|
>
|
|
<div class="details-grid">
|
|
<label class="field">
|
|
<span>{{ t('userSettings.firstname') }}</span>
|
|
<input
|
|
v-model="form.firstname"
|
|
type="text"
|
|
autocomplete="given-name"
|
|
:disabled="userProfileStore.isUpdating"
|
|
/>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ t('userSettings.lastname') }}</span>
|
|
<input
|
|
v-model="form.lastname"
|
|
type="text"
|
|
autocomplete="family-name"
|
|
:disabled="userProfileStore.isUpdating"
|
|
/>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ t('userSettings.alias') }}</span>
|
|
<input
|
|
v-model="form.alias"
|
|
type="text"
|
|
autocomplete="nickname"
|
|
:placeholder="fullname"
|
|
:disabled="userProfileStore.isUpdating"
|
|
/>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ t('userSettings.email') }}</span>
|
|
<input
|
|
v-model="form.email"
|
|
type="email"
|
|
autocomplete="email"
|
|
:disabled="userProfileStore.isUpdating"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button
|
|
class="primary-button"
|
|
type="submit"
|
|
:disabled="!canSave"
|
|
>
|
|
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-heading">
|
|
<strong>{{ t('userSettings.calendarFeed.title') }}</strong>
|
|
<span>{{ t('userSettings.calendarFeed.description') }}</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="calendarFeedError"
|
|
class="page-message error"
|
|
>
|
|
{{ calendarFeedError }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="calendarFeedStatus"
|
|
class="page-message success"
|
|
>
|
|
{{ calendarFeedStatus }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="userProfileStore.calendarExportFeed?.isEnabled && calendarFeedUrl"
|
|
class="calendar-feed-box"
|
|
>
|
|
<span>{{ t('userSettings.calendarFeed.feedUrl') }}</span>
|
|
<code>{{ calendarFeedUrl }}</code>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="calendar-feed-empty"
|
|
>
|
|
{{ t('userSettings.calendarFeed.empty') }}
|
|
</div>
|
|
|
|
<div class="calendar-feed-actions">
|
|
<button
|
|
v-if="!userProfileStore.calendarExportFeed?.isEnabled"
|
|
class="primary-button"
|
|
type="button"
|
|
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
|
@click="enableCalendarFeed"
|
|
>
|
|
{{ t('userSettings.calendarFeed.enable') }}
|
|
</button>
|
|
|
|
<template v-else>
|
|
<button
|
|
class="secondary-button"
|
|
type="button"
|
|
:disabled="!calendarFeedUrl"
|
|
@click="copyCalendarFeedUrl"
|
|
>
|
|
{{ t('userSettings.calendarFeed.copy') }}
|
|
</button>
|
|
<button
|
|
class="secondary-button"
|
|
type="button"
|
|
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
|
@click="regenerateCalendarFeed"
|
|
>
|
|
{{ t('userSettings.calendarFeed.regenerate') }}
|
|
</button>
|
|
<button
|
|
class="danger-button"
|
|
type="button"
|
|
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
|
@click="revokeCalendarFeed"
|
|
>
|
|
{{ t('userSettings.calendarFeed.revoke') }}
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<ImageCropperDialog
|
|
v-model="isPortraitDialogOpen"
|
|
:title="t('userSettings.cropperTitle')"
|
|
:confirm-label="t('userSettings.savePortrait')"
|
|
:upload-label="t('userSettings.choosePortrait')"
|
|
:is-saving="isSavingPortrait"
|
|
:initial-url="userProfileStore.portraitUrl"
|
|
@save="savePortrait"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.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 {
|
|
@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 {
|
|
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;
|
|
}
|
|
|
|
.form-stack {
|
|
@apply flex flex-col gap-4;
|
|
}
|
|
|
|
.field {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.field span {
|
|
@apply text-sm font-semibold;
|
|
color: #172033;
|
|
}
|
|
|
|
.field input {
|
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
|
background: #fffaf2;
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
color: #172033;
|
|
}
|
|
|
|
.field input:disabled {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.form-actions {
|
|
@apply flex justify-end;
|
|
}
|
|
|
|
.page-message {
|
|
@apply rounded-[1rem] border px-4 py-3 text-sm font-semibold;
|
|
background: rgba(15, 118, 110, 0.08);
|
|
border-color: rgba(15, 118, 110, 0.18);
|
|
color: #0f766e;
|
|
}
|
|
|
|
.page-message.error {
|
|
background: rgba(185, 28, 28, 0.08);
|
|
border-color: rgba(185, 28, 28, 0.16);
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.secondary-button,
|
|
.danger-button {
|
|
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
|
}
|
|
|
|
.secondary-button {
|
|
background: rgba(23, 32, 51, 0.06);
|
|
color: #172033;
|
|
}
|
|
|
|
.danger-button {
|
|
background: rgba(185, 28, 28, 0.08);
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.primary-button:disabled,
|
|
.secondary-button:disabled,
|
|
.danger-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.calendar-feed-box {
|
|
@apply flex flex-col gap-2 rounded-[1rem] border p-4;
|
|
background: #fffaf2;
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.calendar-feed-box span,
|
|
.calendar-feed-empty {
|
|
@apply text-sm leading-6;
|
|
color: #526178;
|
|
}
|
|
|
|
.calendar-feed-box code {
|
|
@apply overflow-x-auto rounded-[0.75rem] px-3 py-2 text-sm;
|
|
background: rgba(23, 32, 51, 0.06);
|
|
color: #172033;
|
|
}
|
|
|
|
.calendar-feed-actions {
|
|
@apply flex flex-wrap gap-3;
|
|
}
|
|
</style>
|