diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index d3f6b7a090b33a383d22e42ae50f6000cf3a1a7a..036600d33fb3e23951b4a643af4723e8f3ca7722 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -237,7 +237,7 @@ "birthyear": "Year of birth", "birthyear.note": "In what year were you born?", "confirmPassword": "Confirm password", - "confirmTutor": "Do you confirm selecting “{NAME}†as your guardian?", + "confirmTutor": "Do you confirm selecting \"{NAME}\" as your guardian?", "availabilities": "Availabilities", "noAvailabilities": "No availabilities provided", "notAvailable": "Not available", @@ -381,6 +381,17 @@ "typing": "Error sending input indicator" }, "feedbackInline": "Feedback was added to", + "feedback": { + "title": "Comments", + "empty": "No comments for this session", + "toggle": "Show comments", + "hide": "Hide comments", + "show": "Show comments", + "comment": "Comment", + "noComment": "No comment", + "viewMessage": "View message", + "reply": "Reply" + }, "modal": { "satisfy": { "q1": "How useful is this app?", diff --git a/frontend/src/lang/es.json b/frontend/src/lang/es.json index 916c0db198141036c8d6545bfa28ba17a39b5be3..d14526404d7648f0c2e3c2e125c1de06f745c55c 100644 --- a/frontend/src/lang/es.json +++ b/frontend/src/lang/es.json @@ -92,7 +92,7 @@ }, "register": { "confirmPassword": "Confirmar Contraseña", - "confirmTutor": "¿Confirmas haber seleccionado “{NAME}†como tu tutor?", + "confirmTutor": "¿Confirmas haber seleccionado \"{NAME}\" como tu tutor?", "consent": { "intro": "Estás invitado a participar en un estudio cientÃfico. \nEl objetivo de este estudio es comprender cómo interactúan los tutores y los estudiantes de lenguas extranjeras durante las sesiones de tutorÃa en lÃnea. \nLos datos recopilados se utilizarán para mejorar las herramientas de tutorÃa en lÃnea y comprender mejor los procesos cognitivos en ambos lados.", "ok": "Acepto participar en el estudio como se describe anteriormente.", @@ -187,7 +187,6 @@ "addAvailability": "Agregar disponibilidad", "confirm": "Confirmar", "endTime": "Tiempo de finalización", - "noAvailabilities": "Sin disponibilidad proporcionada", "noTutorsAvailable": "No hay tutor disponible actualmente", "notAvailable": "No disponible", @@ -221,6 +220,17 @@ "typing": "Error al enviar el indicador de entrada" }, "feedbackInline": "Se agregó retroalimentación a", + "feedback": { + "title": "Comentarios", + "empty": "No hay comentarios para esta sesión", + "toggle": "Mostrar comentarios", + "hide": "Ocultar comentarios", + "show": "Mostrar comentarios", + "comment": "Comentario", + "noComment": "Sin comentario", + "viewMessage": "Ver mensaje", + "reply": "Responder" + }, "modal": { "satisfy": { "q1": "¿Qué tan útil es esta aplicación?", diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 331a6e4a8c466a18790bc85c0111d8f0f23c61b0..92ddba90763cd13f837f83c43f9de39beb18f589 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -62,6 +62,17 @@ }, "session": { "feedbackInline": "Un feedback a été ajouté à ", + "feedback": { + "title": "Commentaires", + "empty": "Aucun commentaire pour cette session", + "toggle": "Voir les commentaires", + "hide": "Masquer les commentaires", + "show": "Afficher les commentaires", + "comment": "Commentaire", + "noComment": "Pas de commentaire", + "viewMessage": "Voir le message", + "reply": "Répondre" + }, "modal": { "satisfy": { "title": "Questionnaire de satisfaction", @@ -180,7 +191,7 @@ "tutor": "Erreur lors de la sélection du tuteur. Veuillez réessayer." }, "consent": { - "title": "Document d’information et consentement éclairé", + "title": "Document d'information et consentement éclairé", "intro": "Vous êtes invité·e à participer à une étude scientifique. L'objectif de cette étude est de comprendre comment les tuteurs et les apprenants de langue étrangère interagissent lors de sessions de tutorat en ligne. Les données collectées seront utilisées pour améliorer les outils de tutorat en ligne et pour mieux comprendre les processus cognitifs de part et d'autre.", "participation": "Qu'implique votre participation ?", "participationD": "Si vous acceptez de participer, vous serez invité·e à participer à des sessions de tutorat en ligne avec un tuteur de langue étrangère. Vous serez également invité à remplir des questionnaires avant et après les sessions de tutorat. Les sessions de tutorat seront enregistrées pour analyse ultérieure.</p><p>Nous vous demandons de prévoir de réaliser un minimum de <strong>8 sessions d'une heure</strong> de tutorat (donc 8 heures au total), au cours d'une période de 1 à 3 mois. Vous pouvez bien sûr en réaliser plus si vous le souhaitez. Vous pouvez cependant arrêter de participer à l'étude à tout moment.", @@ -206,7 +217,7 @@ "ok": "J'accepte de participer à l'étude telle que décrite ci-dessus." }, "consentTutor": { - "title": "Document d’information et consentement éclairé", + "title": "Document d'information et consentement éclairé", "intro": "Vous êtes invité·e à participer à une étude scientifique. L'objectif de cette étude est de comprendre comment les tuteurs et les apprenants de langue étrangère interagissent lors de sessions de tutorat en ligne. Les données collectées seront utilisées pour améliorer les outils de tutorat en ligne et pour mieux comprendre les processus cognitifs de part et d'autre.", "participation": "Qu'implique votre participation ?", "participationD": "Si vous acceptez de participer, vous serez invité·e à participer à des sessions de tutorat en ligne avec un tuteur de langue étrangère. Vous serez également invité à remplir des questionnaires avant et après les sessions de tutorat. Les sessions de tutorat seront enregistrées pour analyse ultérieure.</p><p>Nous vous demandons de prévoir de réaliser un minimum de <strong>8 sessions d'une heure</strong> de tutorat (donc 8 heures au total), au cours d'une période de 1 à 3 mois. Vous pouvez bien sûr en réaliser plus si vous le souhaitez. Vous pouvez cependant arrêter de participer à l'étude à tout moment.", @@ -294,7 +305,7 @@ "ok": "J'accepte de participer à l'étude telle que décrite ci-dessus." }, "birthYear": "Quelle est votre année de naissance ?", - "gender": "À quel genre vous identifiez-vous ?", + "gender": "À quel genre vous identifiez-vous ?", "genders": { "male": "Un homme", "female": "Une femme", diff --git a/frontend/src/lang/nl.json b/frontend/src/lang/nl.json index 32c76106a45aa61440c066795fb1f4063f24731a..08262c74be6b2867eb808d8e26ea4daaa2e1a9a7 100644 --- a/frontend/src/lang/nl.json +++ b/frontend/src/lang/nl.json @@ -206,6 +206,17 @@ "typing": "Fout bij het verzenden van de invoerindicator" }, "feedbackInline": "Feedback is toegevoegd aan", + "feedback": { + "title": "Opmerkingen", + "empty": "Geen opmerkingen voor deze sessie", + "toggle": "Toon opmerkingen", + "hide": "Verberg opmerkingen", + "show": "Toon opmerkingen", + "comment": "Opmerking", + "noComment": "Geen opmerking", + "viewMessage": "Bekijk bericht", + "reply": "Antwoorden" + }, "modal": { "satisfy": { "q1": "Hoe nuttig is deze toepassing?", diff --git a/frontend/src/lib/stores/messageHighlight.ts b/frontend/src/lib/stores/messageHighlight.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7e518b04823c70d642533652ec5b965b693256e --- /dev/null +++ b/frontend/src/lib/stores/messageHighlight.ts @@ -0,0 +1,4 @@ +import { writable } from 'svelte/store'; + +// Store for highlighted message ID +export const highlightedMessageId = writable<string | null>(null); diff --git a/frontend/src/routes/sessions/[id]/+page.svelte b/frontend/src/routes/sessions/[id]/+page.svelte index 84a9de893c0588ef56ee4f5b4b7ec73c948e7f27..97eef8754bfa9c3296eb7fff7de73f74a667e2ee 100644 --- a/frontend/src/routes/sessions/[id]/+page.svelte +++ b/frontend/src/routes/sessions/[id]/+page.svelte @@ -3,9 +3,11 @@ import type { PageData } from './$types.js'; import WeeklySurvey from './WeeklySurvey.svelte'; import Chatbox from './Chatbox.svelte'; + import FeedbackSidebar from './FeedbackSidebar.svelte'; import type Task from '$lib/types/tasks'; import { toastAlert, toastSuccess } from '$lib/utils/toasts'; import { sendTaskStatusAPI } from '$lib/api/tasks'; + import { Icon, ChatBubbleLeft } from 'svelte-hero-icons'; let { data }: { data: PageData } = $props(); let user = data.user!; @@ -15,9 +17,28 @@ let level = $state('all'); let currentTask: Task | null = $state(data.currentTask); let taskInProgress: boolean = $state(data.currentTask !== null); + let sidebarOpen = $state(true); // Track sidebar state let availableLevels = new Set(tasks.map((task: Task) => task.level)); + // Function to toggle the sidebar + function toggleSidebar() { + sidebarOpen = !sidebarOpen; + } + + // Function to handle message scrolling from feedback sidebar + function handleScrollToMessage(messageId: string) { + // Find the message element and scroll to it smoothly + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); + if (messageElement) { + messageElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest' + }); + } + } + async function startTask() { const student = session.student; if (!student || !currentTask) return; @@ -78,7 +99,7 @@ } </script> -<div class="h-full flex flex-col lg:flex-row pt-2 lg:pt-0 bg-gray-50 relative"> +<div class="h-full flex flex-row bg-gray-50 relative"> <input type="checkbox" id="toggleParticipants" class="hidden peer" /> <label @@ -91,7 +112,7 @@ <div class="group w-full max-w-md bg-white border rounded-lg shadow-md p-6 mx-4 my-2 h-fit text-base - lg:text-lg transition-all duration-300 ease-in-out hidden lg:block peer-checked:block" + lg:text-lg transition-all duration-300 ease-in-out hidden lg:block peer-checked:block flex-shrink-0" > <h2 class="text-xl truncate font-semibold text-gray-700 text-center mb-4"> {$t('utils.words.participants')} @@ -187,8 +208,40 @@ {/if} </div> - <div class="flex flex-row flex-grow col-span-5"> - <Chatbox {session} {jwt} {user} /> + <div class="flex flex-grow relative overflow-hidden"> + <!-- Chat area --> + <div + class="flex-grow transition-all duration-300 ease-in-out relative min-w-0" + style={`margin-right: ${sidebarOpen ? '350px' : '0px'}`} + > + {#if !sidebarOpen} + <button + class="absolute top-4 right-4 z-20 btn btn-primary btn-sm shadow-lg hover:shadow-xl transition-all duration-200 flex items-center gap-2" + onclick={toggleSidebar} + aria-label={$t('session.feedback.show')} + title={$t('session.feedback.show')} + > + <Icon src={ChatBubbleLeft} size="16" /> + <span class="font-medium hidden sm:inline">{$t('session.feedback.show')}</span> + </button> + {/if} + <Chatbox {session} {jwt} {user} /> + </div> + + <!-- Feedback sidebar --> + <div + class="absolute top-0 right-0 h-full w-[350px] transition-transform duration-300 ease-in-out overflow-hidden border-l border-base-300 bg-base-100" + class:translate-x-0={sidebarOpen} + class:translate-x-full={!sidebarOpen} + > + <FeedbackSidebar + {session} + {user} + isOpen={sidebarOpen} + onToggle={toggleSidebar} + onScrollToMessage={handleScrollToMessage} + /> + </div> </div> </div> diff --git a/frontend/src/routes/sessions/[id]/Chatbox.svelte b/frontend/src/routes/sessions/[id]/Chatbox.svelte index 2373abfcce60012749d0ad780ab08b42ee45751b..b1dc748d9c3fecc009597bb5d932384e7988ab7f 100644 --- a/frontend/src/routes/sessions/[id]/Chatbox.svelte +++ b/frontend/src/routes/sessions/[id]/Chatbox.svelte @@ -132,7 +132,7 @@ new Date().getTime() < session.start_time.getTime() - 3600000; </script> -<div class="flex flex-col w-full max-w-5xl mx-auto h-full scroll-smooth"> +<div class="flex flex-col w-full h-full scroll-smooth relative"> <div class="flex-grow h-48 overflow-auto flex-col-reverse px-4 flex mb-2"> <div class:hidden={!isTyping}> <span class="loading loading-dots loading-md"></span> @@ -156,8 +156,21 @@ Real-time sync lost. You may need to refresh the page to see new messages. </div> {/if} - <div class="flex flex-row"> - <Writebox {user} {session} {chatClosed} bind:replyTo /> + <div class="flex flex-row items-end px-4 py-2"> + <div class="flex-grow"> + <Writebox {user} {session} {chatClosed} bind:replyTo /> + </div> + <!-- Satisfaction survey button --> + <button + onclick={() => { + satisfyModalElement.showModal(); + }} + class="btn btn-primary btn-circle flex-shrink-0 mb-2 ml-3 hover:scale-105 transition-transform shadow-lg" + title="Satisfaction Survey" + aria-label="Open satisfaction survey" + > + <Icon src={PencilSquare} size="20" /> + </button> </div> </div> @@ -212,14 +225,3 @@ </form> </div> </dialog> - -<div class="absolute bottom-4 right-4"> - <button - onclick={() => { - satisfyModalElement.showModal(); - }} - class="btn btn-primary btn-circle" - > - <Icon src={PencilSquare} class="icon" size="32" /> - </button> -</div> diff --git a/frontend/src/routes/sessions/[id]/FeedbackSidebar.svelte b/frontend/src/routes/sessions/[id]/FeedbackSidebar.svelte new file mode 100644 index 0000000000000000000000000000000000000000..313c8cb2b14413078db8b6f28f6c708d3f1e0627 --- /dev/null +++ b/frontend/src/routes/sessions/[id]/FeedbackSidebar.svelte @@ -0,0 +1,238 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import { get } from 'svelte/store'; + import type Session from '$lib/types/session'; + import type User from '$lib/types/user'; + import Message from '$lib/types/message'; + import type Feedback from '$lib/types/feedback'; + import { displayTime } from '$lib/utils/date'; + import { + Icon, + XMark, + ArrowUturnLeft, + ChatBubbleLeft, + ArrowTopRightOnSquare + } from 'svelte-hero-icons'; + import { highlightedMessageId } from '$lib/stores/messageHighlight'; + + let { + session, + user, + isOpen = $bindable(true), // Make isOpen bindable with default true + onToggle = $bindable(), // Optional callback for toggle events + onNewFeedback = $bindable(), // Optional callback for new feedback notifications + onScrollToMessage = $bindable() // Callback to handle message scrolling + }: { + session: Session; + user: User; + isOpen?: boolean; + onToggle?: () => void; + onNewFeedback?: () => void; + onScrollToMessage?: (messageId: string) => void; + } = $props(); + + let allFeedbacks: Feedback[] = []; + + // Group feedbacks by message and highlight range + function groupFeedbacksByHighlight(feedbacks: Feedback[]) { + const grouped = new Map(); + + feedbacks.forEach((feedback) => { + // Used message ID + highlight range as the key + const key = `${feedback.message.id}-${feedback.start}-${feedback.end}`; + + if (!grouped.has(key)) { + grouped.set(key, { + highlight: feedback.message.content.substring(feedback.start, feedback.end), + messageId: feedback.message.uuid, + comments: [] + }); + } + + grouped.get(key).comments.push(feedback); + }); + + return Array.from(grouped.values()); + } + + let groupedFeedbacks = $state([] as any[]); + + function toggleSidebar() { + if (onToggle) onToggle(); + } + + async function deleteFeedback(feedback: Feedback) { + if (!confirm($t('chatbox.deleteFeedback'))) return; + await feedback.message.deleteFeedback(feedback); + } + + function extractAllFeedbacks(messages: (Message | null)[]) { + const feedbacks: Feedback[] = []; + + messages.forEach((message) => { + if (message instanceof Message) { + const messageFeedbacks = get(message.feedbacks); + if (messageFeedbacks && messageFeedbacks.length > 0) { + feedbacks.push(...messageFeedbacks); + } + } + }); + + return feedbacks.sort((a, b) => b.date.getTime() - a.date.getTime()); + } + + //Handles all feedback management + $effect(() => { + const messages = get(session.messages) as (Message | null)[]; + if (messages) { + allFeedbacks = extractAllFeedbacks(messages); + groupedFeedbacks = groupFeedbacksByHighlight(allFeedbacks); + + // Set up subscriptions + messages.forEach((message) => { + if (message instanceof Message) { + message.feedbacks.subscribe(() => { + const currentMessages = get(session.messages) as (Message | null)[]; + allFeedbacks = extractAllFeedbacks(currentMessages); + groupedFeedbacks = groupFeedbacksByHighlight(allFeedbacks); + }); + } + }); + } + }); + + // Function to handle reply to a comment + function handleReply(feedbackGroup: any) { + // This is a placeholder - the actual implementation would depend on backend API + console.log('Reply to comment:', feedbackGroup); + } + + // Function to scroll to message + function scrollToMessage(messageId: string) { + // Set the highlighted message in the store + highlightedMessageId.set(messageId); + + // Call parent callback if provided + if (onScrollToMessage) { + onScrollToMessage(messageId); + } + + // Clear highlight after animation duration + setTimeout(() => { + highlightedMessageId.set(null); + }, 2000); + } +</script> + +<div + class="h-full w-full bg-white shadow-lg overflow-y-auto" + class:pointer-events-none={!isOpen} + aria-hidden={!isOpen} +> + <div class="p-4 border-b bg-base-200"> + <div class="flex items-center justify-between"> + <h2 class="text-lg font-semibold text-base-content flex items-center gap-2"> + <Icon src={ChatBubbleLeft} size="20" /> + {$t('session.feedback.title')} + </h2> + <button + class="btn btn-ghost btn-sm btn-circle" + onclick={toggleSidebar} + aria-label={$t('session.feedback.hide')} + title={$t('session.feedback.hide')} + > + <Icon src={XMark} size="18" /> + </button> + </div> + </div> + + {#if groupedFeedbacks.length === 0} + <div class="flex flex-col items-center justify-center h-48 text-center"> + <Icon src={ChatBubbleLeft} size="48" class="text-base-300 mb-3" /> + <p class="text-base-content/60 text-sm px-6"> + {$t('session.feedback.empty')} + </p> + </div> + {:else} + <div class="p-4 space-y-4"> + {#each groupedFeedbacks as feedbackGroup} + <div + class="card card-compact bg-base-100 shadow-sm border border-base-300 hover:shadow-md transition-shadow relative" + > + <div class="card-body"> + <div class="relative mb-3 p-3 bg-warning/10 rounded-lg break-words group"> + <button + onclick={() => scrollToMessage(feedbackGroup.messageId)} + class="absolute -top-6 left-1/2 -translate-x-1/2 btn btn-primary btn-xs opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-sm hover:shadow-md hover:scale-105 flex items-center gap-1 z-10" + > + <Icon src={ArrowTopRightOnSquare} size="12" class="text-black" /> + <span class="text-black text-xs font-normal" + >{$t('session.feedback.viewMessage')}</span + > + </button> + <div class="text-sm font-medium text-base-content leading-relaxed"> + {feedbackGroup.highlight} + </div> + </div> + + <!-- Comment thread --> + <div class="space-y-3"> + {#each feedbackGroup.comments as feedback} + <div class="flex gap-3 group"> + <div class="avatar"> + <div class="w-8 h-8 rounded-full border-2 border-base-300"> + <img + src={`https://gravatar.com/avatar/${feedback.message.user.emailHash}?d=identicon`} + alt="" + class="w-full h-full object-cover rounded-full" + /> + </div> + </div> + <div class="flex-grow relative min-w-0"> + {#if feedback.content} + <div + class="text-sm p-3 bg-base-200 rounded-lg break-words relative group/comment" + > + {feedback.content} + <button + class="absolute -top-1 -right-1 opacity-0 group-hover/comment:opacity-100 transition-opacity btn btn-xs btn-circle btn-error" + onclick={() => deleteFeedback(feedback)} + aria-label={$t('button.delete')} + > + <Icon src={XMark} class="w-3 h-3" /> + </button> + </div> + {:else} + <div + class="text-xs text-base-content/60 italic p-3 bg-base-200 rounded-lg relative group/comment" + > + {$t('session.feedback.noComment')} + <button + class="absolute -top-1 -right-1 opacity-0 group-hover/comment:opacity-100 transition-opacity btn btn-xs btn-circle btn-error" + onclick={() => deleteFeedback(feedback)} + aria-label={$t('button.delete')} + > + <Icon src={XMark} class="w-3 h-3" /> + </button> + </div> + {/if} + </div> + </div> + {/each} + </div> + + <!-- Reply button --> + <button + class="absolute bottom-3 right-3 btn btn-primary btn-xs btn-circle shadow-sm hover:shadow-md transition-all hover:scale-105 z-10" + onclick={() => handleReply(feedbackGroup)} + title={$t('session.feedback.reply')} + aria-label={$t('session.feedback.reply')} + > + <Icon src={ArrowUturnLeft} class="w-3 h-3 text-black" /> + </button> + </div> + </div> + {/each} + </div> + {/if} +</div> diff --git a/frontend/src/routes/sessions/[id]/Message.svelte b/frontend/src/routes/sessions/[id]/Message.svelte index 943515d2dee818b3418757db16fb8127b2bc38e6..567ac3b2ef3d4d1d87c8a7e89fe41d8d71a78206 100644 --- a/frontend/src/routes/sessions/[id]/Message.svelte +++ b/frontend/src/routes/sessions/[id]/Message.svelte @@ -12,6 +12,7 @@ import Message from '$lib/types/message'; import type User from '$lib/types/user'; import { get } from 'svelte/store'; + import { highlightedMessageId } from '$lib/stores/messageHighlight'; let { user, @@ -34,6 +35,32 @@ let historyModal: HTMLDialogElement; let messageVersions = $state(message.versions); + let activeFeedback: Feedback | null = $state(null); + let highlightPosition = $state({ top: 0, right: 0 }); + + function showHighlightConnection( + part: { text: string; feedback: Feedback | null }, + event: MouseEvent + ) { + if (!part.feedback) return; + + const highlightElement = event.currentTarget as HTMLElement; + if (highlightElement) { + activeFeedback = part.feedback; + + // Store position for the connection line + const rect = highlightElement.getBoundingClientRect(); + highlightPosition = { + top: rect.top + window.scrollY + rect.height / 2, + right: rect.right + window.scrollX + }; + } + } + + function hideHighlightConnection() { + activeFeedback = null; + } + function startEdit() { isEdit = true; setTimeout(() => { @@ -179,6 +206,17 @@ const isSender = message.user.id == user.id; + // Reactive variable for highlighting + let isHighlighted = $state(false); + + // Subscribe to highlighted message changes + $effect(() => { + const unsubscribe = highlightedMessageId.subscribe((highlightedId) => { + isHighlighted = highlightedId === message.uuid; + }); + return unsubscribe; + }); + async function deleteFeedback(feedback: Feedback | null) { if (!feedback) return; if (!confirm($t('chatbox.deleteFeedback'))) return; @@ -188,10 +226,13 @@ </script> <div - class="chat group scroll-smooth target:bg-gray-200 rounded-xl" - id={message.uuid} + class="chat group scroll-smooth rounded-xl transition-colors duration-300" + class:bg-gray-300={isHighlighted} + class:target:bg-gray-200={!isHighlighted} class:chat-start={!isSender} class:chat-end={isSender} + id={message.uuid} + data-message-id={message.uuid} > <div class="rounded-full mx-2 chat-image size-12" title={message.user.nickname}> <img @@ -243,31 +284,31 @@ {#if isEdit || !part.feedback} {@html linkifyHtml(sanitize(part.text), { className: 'underline', target: '_blank' })} {:else} - <!-- prettier-ignore --> - <span class="" - ><!-- - --><span - class="underline group/feedback relative decoration-wavy hover:cursor-help" - class:decoration-blue-500={part.feedback.content} - class:decoration-red-500={!part.feedback.content} - ><div - class="absolute group-hover/feedback:flex hidden bg-secondary h-6 items-center rounded left-1/2 transform -translate-x-1/2 -top-6 px-2 z-10" - ><!-- - -->{part.feedback.content}<button + <span + class="underline relative decoration-wavy hover:cursor-help group/feedback" + class:decoration-blue-500={part.feedback.content} + class:decoration-red-500={!part.feedback.content} + role="button" + tabindex="0" + onmouseenter={(e) => showHighlightConnection(part, e)} + onmouseleave={hideHighlightConnection} + > + <div + class="absolute group-hover/feedback:flex hidden bg-gray-800 text-white text-sm h-6 items-center rounded left-1/2 -translate-x-1/2 -top-8 px-2 z-20 whitespace-nowrap" + > + {part.feedback.content} + {#if part.feedback.content} + <button aria-label="close" - class:ml-1={part.feedback.content} - class="hover:border-inherit border border-transparent rounded" + class="ml-1 hover:bg-gray-700 border border-transparent rounded p-0.5" onclick={() => deleteFeedback(part.feedback)} > <CloseIcon /> </button> - </div - ><!-- - -->{part.text}<!-- - --></span - ><!-- - --></span - > + {/if} + </div> + {part.text} + </span> {/if} {/each} </div> @@ -301,6 +342,14 @@ {/if} </div> </div> + +{#if activeFeedback} + <div + class="fixed h-0.5 bg-gray-400/30 z-10 pointer-events-none animate-in fade-in duration-200" + style="top: {highlightPosition.top}px; left: {highlightPosition.right}px; width: calc(100vw - {highlightPosition.right}px - 350px);" + ></div> +{/if} + <div class="absolute invisible rounded-xl border border-gray-400 bg-white divide-x" bind:this={hightlight} diff --git a/frontend/src/routes/sessions/[id]/Writebox.svelte b/frontend/src/routes/sessions/[id]/Writebox.svelte index 0c582ac0682838ba7db44d7c6b04731cbbfd77fc..3d25940db02cdc9b3141470ad148508488088674 100644 --- a/frontend/src/routes/sessions/[id]/Writebox.svelte +++ b/frontend/src/routes/sessions/[id]/Writebox.svelte @@ -78,10 +78,10 @@ } </script> -<div class="flex flex-col w-full py-2 relative mb-2"> +<div class="flex flex-col w-full relative"> {#if replyTo} <div - class="flex items-center justify-between bg-gray-100 p-2 rounded-md mb-1 text-sm text-gray-600" + class="flex items-center justify-between bg-gray-100 p-2 rounded-md mb-2 text-sm text-gray-600" > <p class="text-xs text-gray-400"> Replying to: <span class="text-xs text-gray-400">{replyTo.content}</span> @@ -93,7 +93,7 @@ {/if} {#if showSpecials} - <ul class="flex justify-around divide-x-2 border-b-2 py-1 flex-wrap md:flex-nowrap"> + <ul class="flex justify-around divide-x-2 border-b-2 py-1 flex-wrap md:flex-nowrap mb-2"> {#each config.SPECIAL_CHARS as char (char)} <button class="border-none" @@ -109,9 +109,11 @@ {/each} </ul> {/if} - <div class="w-full flex items-center relative"> + + <div class="w-full flex items-center gap-3"> + <!-- Emoji picker button --> <div - class="text-2xl select-none cursor-pointer mx-4" + class="text-2xl select-none cursor-pointer flex-shrink-0" onclick={() => (showPicker = !showPicker)} data-tooltip-target="tooltip-emoji" data-tooltip-placement="right" @@ -119,9 +121,12 @@ aria-hidden={false} role="button" tabindex="0" + title="Add emoji" > 😀 </div> + + <!-- Emoji picker --> <div class="relative"> <div id="tooltip-emoji" @@ -132,7 +137,7 @@ > <emoji-picker class="light" - onemoji-click={(event) => { + onemoji-click={(event: any) => { message += event.detail.unicode; textearea.focus(); }} @@ -140,27 +145,40 @@ </emoji-picker> </div> </div> - <textarea - bind:this={textearea} - class="flex-grow p-2 resize-none overflow-hidden py-4 pr-12 border rounded-[32px]" - placeholder={chatClosed ? $t('chatbox.disabled') : $t('chatbox.placeholder')} - disabled={chatClosed} - bind:value={message} - use:autosize - rows={1} - onkeypress={keyPress} - ></textarea> - <div - class="absolute right-28 kbd text-sm select-none cursor-pointer" - onclick={() => (showSpecials = !showSpecials)} - aria-hidden={false} - role="button" - tabindex="0" - > - É + + <!-- Textarea container --> + <div class="flex-grow relative"> + <textarea + bind:this={textearea} + class="w-full p-3 resize-none overflow-hidden py-4 pr-12 border rounded-[32px] border-gray-300 focus:border-primary focus:outline-none transition-colors" + placeholder={chatClosed ? $t('chatbox.disabled') : $t('chatbox.placeholder')} + disabled={chatClosed} + bind:value={message} + use:autosize + rows={1} + onkeypress={keyPress} + ></textarea> + <!-- Special characters button --> + <div + class="absolute right-3 top-1/2 transform -translate-y-1/2 kbd text-sm select-none cursor-pointer hover:bg-gray-200 transition-colors" + onclick={() => (showSpecials = !showSpecials)} + aria-hidden={false} + role="button" + tabindex="0" + title="Special characters" + > + É + </div> </div> - <button class="btn btn-primary rounded-full size-14 mx-4" onclick={sendMessage}> - <Icon src={PaperAirplane} /> + + <!-- Send button --> + <button + class="btn btn-primary rounded-full size-14 flex-shrink-0 hover:scale-105 transition-transform" + onclick={sendMessage} + title="Send message" + aria-label="Send message" + > + <Icon src={PaperAirplane} size="20" /> </button> </div> </div>