388 lines
18 KiB
Vue
388 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
|
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
|
|
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
|
import DeleteUser from '@/components/DeleteUser.vue';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
|
import { edit } from '@/routes/profile/index';
|
|
import { send } from '@/routes/verification';
|
|
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
|
import type { UserModel } from '@/types/models/user-model';
|
|
|
|
defineProps<SettingsProfilePageProps>();
|
|
|
|
const { t } = useLocalization();
|
|
const page = usePage();
|
|
const user = computed(() => page.props.auth.user);
|
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
|
{
|
|
title: t('navigation.profile'),
|
|
href: edit(),
|
|
},
|
|
]);
|
|
const profileForm = useForm('SettingsProfile', {
|
|
name: user.value.name,
|
|
email: user.value.email,
|
|
avatar: null as File | null,
|
|
});
|
|
const profileProtection = useFormProtection(profileForm);
|
|
const avatarDeleteForm = useForm({});
|
|
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
|
|
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
|
|
const avatarPreview = ref<string | null>(null);
|
|
const displayedAvatar = computed(
|
|
() => avatarPreview.value ?? user.value.avatar,
|
|
);
|
|
const displayedUser = computed<UserModel>(() => ({
|
|
...user.value,
|
|
avatar: displayedAvatar.value,
|
|
}));
|
|
const canRemoveAvatar = computed(
|
|
() =>
|
|
avatarPreview.value === null &&
|
|
user.value.avatar !== null &&
|
|
!user.value.avatar_is_generated,
|
|
);
|
|
const profileUpdateForm = ProfileController.update.form();
|
|
const profileUpdateTarget = {
|
|
url: profileUpdateForm.action,
|
|
method: profileUpdateForm.method,
|
|
};
|
|
|
|
function clearAvatarPreview(): void {
|
|
if (avatarPreview.value) {
|
|
URL.revokeObjectURL(avatarPreview.value);
|
|
avatarPreview.value = null;
|
|
}
|
|
}
|
|
|
|
function clearAvatarSelection(): void {
|
|
clearAvatarPreview();
|
|
profileForm.avatar = null;
|
|
profileForm.clearErrors('avatar');
|
|
profileProtection.clearFeedback();
|
|
if (avatarInput.value) {
|
|
avatarInput.value.value = '';
|
|
}
|
|
}
|
|
|
|
function handleAvatarChange(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0] ?? null;
|
|
|
|
clearAvatarPreview();
|
|
profileForm.avatar = file;
|
|
profileForm.clearErrors('avatar');
|
|
profileProtection.clearFeedback();
|
|
|
|
if (file) {
|
|
avatarPreview.value = URL.createObjectURL(file);
|
|
}
|
|
}
|
|
|
|
function submitProfile(): void {
|
|
profileProtection.submit(profileUpdateTarget, {
|
|
forceFormData: true,
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
profileForm.defaults({
|
|
name: profileForm.name,
|
|
email: profileForm.email,
|
|
avatar: null,
|
|
});
|
|
clearAvatarSelection();
|
|
},
|
|
});
|
|
}
|
|
|
|
function removeAvatar(): void {
|
|
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
|
|
preserveScroll: true,
|
|
onSuccess: clearAvatarSelection,
|
|
});
|
|
}
|
|
|
|
onBeforeUnmount(clearAvatarPreview);
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
|
<Head :title="t('profile.title')" />
|
|
|
|
<SettingsLayout>
|
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
|
<h2 class="text-title">{{ t('profile.title') }}</h2>
|
|
<p class="text-muted-foreground">
|
|
{{ t('profile.description') }}
|
|
</p>
|
|
</CardHeader>
|
|
|
|
<CardContent class="p-5 sm:p-6">
|
|
<form
|
|
class="gap-column grid min-w-0"
|
|
:aria-busy="profileForm.processing"
|
|
@submit.prevent="submitProfile"
|
|
>
|
|
<FormFeedback
|
|
:state="profileProtection.state.value"
|
|
:title="
|
|
profileForm.processing
|
|
? t('profile.saving')
|
|
: undefined
|
|
"
|
|
@retry="submitProfile"
|
|
/>
|
|
|
|
<fieldset
|
|
class="gap-column grid min-w-0"
|
|
:disabled="profileForm.processing"
|
|
>
|
|
<legend class="sr-only">
|
|
{{ t('profile.information') }}
|
|
</legend>
|
|
|
|
<section
|
|
aria-labelledby="profile-picture-title"
|
|
class="gap-column grid min-w-0 border-b pb-6"
|
|
>
|
|
<div class="gap-stack grid">
|
|
<h3
|
|
id="profile-picture-title"
|
|
class="font-semibold"
|
|
>
|
|
{{ t('profile.picture') }}
|
|
</h3>
|
|
<p
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('profile.pictureDescription') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
|
>
|
|
<UserAvatar
|
|
:user="displayedUser"
|
|
size="xl"
|
|
class="ring-border shrink-0 ring-1"
|
|
:label="t('profile.picturePreview')"
|
|
/>
|
|
<div class="gap-stack grid min-w-0 flex-1">
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button as-child variant="outline">
|
|
<label
|
|
for="avatar"
|
|
class="cursor-pointer"
|
|
>
|
|
<Camera
|
|
aria-hidden="true"
|
|
/>
|
|
{{
|
|
avatarPreview
|
|
? t(
|
|
'profile.changePicture',
|
|
)
|
|
: t(
|
|
'profile.choosePicture',
|
|
)
|
|
}}
|
|
</label>
|
|
</Button>
|
|
<Button
|
|
v-if="avatarPreview"
|
|
type="button"
|
|
variant="ghost"
|
|
@click="clearAvatarSelection"
|
|
>
|
|
<RotateCcw aria-hidden="true" />
|
|
{{
|
|
t('profile.discardPicture')
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-else-if="canRemoveAvatar"
|
|
type="button"
|
|
variant="ghost"
|
|
:disabled="
|
|
avatarDeleteForm.processing
|
|
"
|
|
data-test="delete-profile-picture-button"
|
|
@click="removeAvatar"
|
|
>
|
|
<Trash2 aria-hidden="true" />
|
|
{{ t('profile.removePicture') }}
|
|
</Button>
|
|
</div>
|
|
<input
|
|
id="avatar"
|
|
ref="avatarInput"
|
|
name="avatar"
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
class="sr-only"
|
|
aria-describedby="avatar-help avatar-error"
|
|
:aria-invalid="
|
|
!!profileForm.errors.avatar
|
|
"
|
|
@change="handleAvatarChange"
|
|
/>
|
|
<p
|
|
v-if="profileForm.avatar"
|
|
class="text-caption min-w-0 break-all font-medium"
|
|
>
|
|
{{
|
|
t('profile.selectedPicture', {
|
|
name: profileForm.avatar
|
|
.name,
|
|
})
|
|
}}
|
|
</p>
|
|
<p
|
|
id="avatar-help"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('profile.pictureHelp') }}
|
|
</p>
|
|
<progress
|
|
v-if="profileForm.progress"
|
|
class="h-1 w-full overflow-hidden rounded-full"
|
|
:value="
|
|
profileForm.progress.percentage
|
|
"
|
|
max="100"
|
|
>
|
|
{{
|
|
profileForm.progress.percentage
|
|
}}%
|
|
</progress>
|
|
<InputError
|
|
id="avatar-error"
|
|
:message="profileForm.errors.avatar"
|
|
/>
|
|
<FormFeedback
|
|
:state="
|
|
avatarDeleteProtection.state
|
|
.value
|
|
"
|
|
:title="
|
|
avatarDeleteForm.processing
|
|
? t(
|
|
'profile.removingPicture',
|
|
)
|
|
: undefined
|
|
"
|
|
@retry="removeAvatar"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid gap-2">
|
|
<Label for="name">{{
|
|
t('profile.name')
|
|
}}</Label>
|
|
<Input
|
|
id="name"
|
|
v-model="profileForm.name"
|
|
name="name"
|
|
required
|
|
autocomplete="name"
|
|
:placeholder="t('profile.namePlaceholder')"
|
|
:aria-invalid="!!profileForm.errors.name"
|
|
aria-describedby="name-error"
|
|
/>
|
|
<InputError
|
|
id="name-error"
|
|
:message="profileForm.errors.name"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<Label for="email">{{
|
|
t('profile.email')
|
|
}}</Label>
|
|
<Input
|
|
id="email"
|
|
v-model="profileForm.email"
|
|
type="email"
|
|
name="email"
|
|
required
|
|
autocomplete="username"
|
|
:placeholder="t('profile.emailPlaceholder')"
|
|
:aria-invalid="!!profileForm.errors.email"
|
|
aria-describedby="email-error"
|
|
/>
|
|
<InputError
|
|
id="email-error"
|
|
:message="profileForm.errors.email"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
mustVerifyEmail && !user.email_verified_at
|
|
"
|
|
class="bg-muted/55 rounded-lg border p-4"
|
|
>
|
|
<p class="text-body text-muted-foreground">
|
|
{{ t('profile.emailUnverified') }}
|
|
<Link
|
|
:href="send()"
|
|
as="button"
|
|
class="text-primary font-medium underline underline-offset-4"
|
|
>
|
|
{{ t('profile.resendVerification') }}
|
|
</Link>
|
|
</p>
|
|
|
|
<p
|
|
v-if="status === 'verification-link-sent'"
|
|
role="status"
|
|
class="text-success-foreground mt-2 text-sm font-medium"
|
|
>
|
|
{{ t('profile.verificationSent') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
type="submit"
|
|
:disabled="profileForm.processing"
|
|
data-test="update-profile-button"
|
|
>
|
|
{{
|
|
profileForm.processing
|
|
? t('profile.saving')
|
|
: t('profile.save')
|
|
}}
|
|
</Button>
|
|
<p
|
|
v-if="profileForm.isDirty"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('profile.unsaved') }}
|
|
</p>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<DeleteUser />
|
|
</SettingsLayout>
|
|
</AppLayout>
|
|
</template>
|