Files
KanbanGit/resources/js/pages/settings/Profile.vue
T
Twyco 1377ee8510
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Failing after 3m17s
CI/CD / Publish immutable image (pull_request) Skipped
feat(KAN-36): rebuild profile and appearance settings
2026-09-14 17:36:41 +02:00

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>