diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 92ddba90763cd13f837f83c43f9de39beb18f589..a029f519eb6a156d40f1557eb649d336bb6991c2 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -14,7 +14,8 @@ "sessions": "Sessions", "studies": "Études", "tasks": "Tâches" - } + }, + "profile": "Mon profil" }, "chatbox": { "placeholder": "Écrivez votre message ici...", @@ -429,6 +430,10 @@ "startTask": "Commencer cette tâche", "achieveTask": "Tâche achevée" }, + "profile": { + "title": "Mon profile", + "save": "Sauvegarder les modifications" + }, "button": { "create": "Créer", "submit": "Envoyer", diff --git a/frontend/src/lib/types/user.ts b/frontend/src/lib/types/user.ts index 906d98667918f45fd8a268b76446186c84d30baf..0acb93b5a9e65569a4c4e977b52b2a5cc074b9cc 100644 --- a/frontend/src/lib/types/user.ts +++ b/frontend/src/lib/types/user.ts @@ -28,7 +28,7 @@ export default class User { private _ui_language: string | null; private _home_language: string | null; private _target_language: string | null; - private _birthdate: Date | null; + private _birthdate: string | null; private _gender: string | null; private _bio: string | null; private _calcom_link: string | null; @@ -51,7 +51,7 @@ export default class User { ui_language: string | null, home_language: string | null, target_language: string | null, - birthdate: Date | null, + birthdate: string | null, gender: string | null, calcom_link: string | null, studies_id: number[] | null, @@ -130,10 +130,17 @@ export default class User { return this._target_language; } - get birthdate(): Date | null { + get birthdate(): string | null { return this._birthdate; } + get birthdateAsDay(): string | null { + if (this._birthdate) { + return this._birthdate.slice(0, 10); // Format as YYYY-MM-DD + } + return null; + } + get gender(): string | null { return this._gender; } diff --git a/frontend/src/routes/Header.svelte b/frontend/src/routes/Header.svelte index dbb172773b765caaeaec429c1998c2ce5b2e6bfa..79f04b7c1005aa12df5040f350acfadd393f1304 100644 --- a/frontend/src/routes/Header.svelte +++ b/frontend/src/routes/Header.svelte @@ -50,7 +50,7 @@ </h1> </div> <div class="navbar-end hidden sm:flex"> - <ul class="menu menu-horizontal p-0 flex items-center"> + <ul class="menu menu-horizontal p-0 flex items-center z-10"> {#if user} <li> <details> @@ -63,6 +63,19 @@ {user.nickname} </summary> <ul class="menu menu-sm dropdown-content absolute right-0"> + {#if user?.type < 2} + <li> + <a + class="btn btn-sm my-auto" + data-sveltekit-reload + href="/tutor/profile?redirect={encodeURIComponent( + $page.url.pathname + $page.url.search + )}" + > + {$t('header.profile')} + </a> + </li> + {/if} <li> <a data-sveltekit-reload href="/logout" class="whitespace-nowrap"> {$t('header.signout')} diff --git a/frontend/src/routes/tutor/+layout.server.ts b/frontend/src/routes/tutor/+layout.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddf2023916330cb19354b8333ee001f0a6f1c306 --- /dev/null +++ b/frontend/src/routes/tutor/+layout.server.ts @@ -0,0 +1,11 @@ +import { type ServerLoad, error, redirect } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals }) => { + if (locals.user == null || locals.user == undefined) { + redirect(303, '/login'); + } + + if (locals.user == null || locals.user == undefined || locals.user.type > 1) { + error(403, 'Forbidden'); + } +}; diff --git a/frontend/src/routes/tutor/profile/+page.server.ts b/frontend/src/routes/tutor/profile/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..821322e9427656ab239af47a78d5644d0ef72a8a --- /dev/null +++ b/frontend/src/routes/tutor/profile/+page.server.ts @@ -0,0 +1,57 @@ +import { fail, redirect, type Actions } from '@sveltejs/kit'; +import { patchUserAPI } from '$lib/api/users'; +import { safeRedirectAuto } from '$lib/utils/security'; + +export const actions: Actions = { + default: async ({ request, fetch, locals, url }) => { + if (!locals.user) { + return fail(401, { message: 'Unauthorized' }); + } + const formData = await request.formData(); + const nickname = formData.get('nickname') as string; + const email = formData.get('email') as string; + const gender = formData.get('gender') as string; + const birthdate = formData.get('birthdate') as string; + const bio = formData.get('bio') as string; + const availabilitiesRaw = formData.getAll('availability[]') as string[]; + + let availabilities: { day: string; start: string; end: string }[] = []; + for (const availability of availabilitiesRaw) { + const [day, start, end] = availability.split('-', 3); + + if (!day || !start || !end) continue; + + availabilities.push({ + day: day.trim(), + start: start.trim(), + end: end.trim() + }); + } + + console.log('Updating profile with data:', { + nickname, + email, + gender, + birthdate, + bio, + availabilities + }); + + const data: any = { + nickname, + email, + gender, + birthdate, + bio, + availabilities + }; + + const ok = await patchUserAPI(fetch, locals.user.id, data); + + if (!ok) { + return fail(400, { message: 'Failed to update profile.' }); + } + + return safeRedirectAuto(url); + } +}; diff --git a/frontend/src/routes/tutor/profile/+page.svelte b/frontend/src/routes/tutor/profile/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..f586cba06724f91893319c5c5a7f7c74850ab468 --- /dev/null +++ b/frontend/src/routes/tutor/profile/+page.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import type { PageData } from './$types'; + import autosize from 'svelte-autosize'; + + let { data, form }: { data: PageData; form: FormData } = $props(); + let user = $state(data.user); + + let isLoading = false; + let message = $state(form?.message || ''); + + const MAX_BIO_LENGTH = 100; + + let bio = $state(user?.bio || ''); + + let remainingCharacters = $derived(MAX_BIO_LENGTH - bio.length); + + type Availability = { + day: string; + start: string; + end: string; + }; + + const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + + let availabilities: Availability[] = $state( + user?.availabilities ? [...user?.availabilities] : [] + ); + let selectedWeekday = $state(''); + let selectedTimeStart = $state(''); + let selectedTimeEnd = $state(''); + + function addAvailability() { + if (!selectedWeekday || !selectedTimeStart || !selectedTimeEnd) { + message = 'All fields must be selected.'; + return; + } + const startHour = parseInt(selectedTimeStart.split(':')[0]); + const endHour = parseInt(selectedTimeEnd.split(':')[0]); + if (startHour >= endHour) { + message = 'End time must be later than start time.'; + return; + } + availabilities = [ + ...availabilities, + { day: selectedWeekday, start: selectedTimeStart, end: selectedTimeEnd } + ]; + selectedWeekday = ''; + selectedTimeStart = ''; + selectedTimeEnd = ''; + message = ''; + } + + function removeAvailability(index: number) { + availabilities = availabilities.filter((_, i) => i !== index); + } +</script> + +<div class="w-full max-w-4xl mx-auto p-6 bg-white rounded-lg shadow mt-8"> + <h1 class="text-2xl font-bold mb-6">{$t('profile.title')}</h1> + {#if message} + <div class="alert alert-error mb-4">{message}</div> + {/if} + <form class="space-y-6" method="POST"> + <div> + <label class="block text-sm font-medium mb-1" for="nickname"> + {$t('register.nickname')} + </label> + <input + id="nickname" + name="nickname" + type="text" + class="input input-bordered w-full" + value={user?.nickname} + required + /> + </div> + <div> + <label class="block text-sm font-medium mb-1" for="email"> + {$t('register.email')} + </label> + <input + id="email" + name="email" + type="email" + class="input input-bordered w-full" + value={user?.email} + required + /> + </div> + <div> + <label class="block text-sm font-medium mb-1" for="gender"> + {$t('register.gender')} + </label> + <select + id="gender" + name="gender" + class="select select-bordered w-full" + value={user?.gender} + required + > + <option value="" disabled>{$t('register.gender')}</option> + <option value="male">{$t('register.genders.male')}</option> + <option value="female">{$t('register.genders.female')}</option> + <option value="other">{$t('register.genders.other')}</option> + <option value="na">{$t('register.genders.na')}</option> + </select> + </div> + <div> + <label class="block text-sm font-medium mb-1" for="birthdate"> + {$t('register.birthyear')} + </label> + <input + id="birthdate" + name="birthdate" + type="date" + class="input input-bordered w-full" + value={user?.birthdateAsDay} + /> + required /> + </div> + <div> + <label class="block text-sm font-medium mb-1" for="bio"> + {$t('register.bio')} + <span class="text-xs text-gray-400 ml-2">{remainingCharacters} / {MAX_BIO_LENGTH}</span> + </label> + <textarea + use:autosize + id="bio" + name="bio" + class="textarea textarea-bordered w-full" + bind:value={bio} + maxlength={MAX_BIO_LENGTH} + required + ></textarea> + </div> + <div> + <h2 class="text-lg font-semibold"> + {$t('register.availabilities')} + </h2> + <div class="flex flex-row gap-2 mb-2 w-full"> + <select class="select select-bordered flex-grow" bind:value={selectedWeekday}> + <option value="" disabled>{$t('register.weekday')}</option> + {#each days as dayKey} + <option value={dayKey}>{$t(`utils.days.${dayKey}`)}</option> + {/each} + </select> + <select class="select select-bordered flex-grow" bind:value={selectedTimeStart}> + <option value="" disabled>{$t('register.startTime')}</option> + {#each Array.from({ length: 24 }, (_, i) => `${i}:00`) as time} + <option value={time}>{time}</option> + {/each} + </select> + <select class="select select-bordered flex-grow" bind:value={selectedTimeEnd}> + <option value="" disabled>{$t('register.endTime')}</option> + {#each Array.from({ length: 24 }, (_, i) => `${i}:00`) as time} + <option value={time}>{time}</option> + {/each} + </select> + <button type="button" class="btn btn-primary flex-grow" onclick={addAvailability}> + {$t('register.addAvailability')} + </button> + </div> + {#if availabilities.length > 0} + <ul class="list-disc pl-6 py-2"> + {#each availabilities as { day, start, end }, index} + <li class="flex justify-between items-center"> + <input type="hidden" name="availability[]" value={`${day}-${start}-${end}`} /> + + <span>{$t(`utils.days.${day}`)}: {start} - {end}</span> + <button + type="button" + class="text-red-500 ml-4" + onclick={() => removeAvailability(index)} + > + {$t('register.remove')} + </button> + </li> + {/each} + </ul> + {:else} + <p class="text-gray-500 text-sm py-2"> + {$t('register.noAvailabilities')} + </p> + {/if} + </div> + <div> + <button type="submit" class="btn btn-primary w-full mt-4" disabled={isLoading}> + {isLoading ? $t('profile.saving') : $t('profile.save')} + </button> + </div> + </form> +</div>