From 1f47a272535f45b9127ac8f6ae1c0eb60ecec4c6 Mon Sep 17 00:00:00 2001 From: Danial Ahmad <danial.ahmad@student.uclouvain.be> Date: Mon, 26 May 2025 18:48:20 +0000 Subject: [PATCH] Implement enhanced feedback sidebar with real-time grouping and improved UX --- frontend/src/lang/en.json | 13 +- frontend/src/lang/es.json | 14 +- frontend/src/lang/fr.json | 17 +- frontend/src/lang/nl.json | 11 + frontend/src/lib/stores/messageHighlight.ts | 4 + .../src/routes/sessions/[id]/+page.svelte | 61 ++++- .../src/routes/sessions/[id]/Chatbox.svelte | 30 +-- .../sessions/[id]/FeedbackSidebar.svelte | 238 ++++++++++++++++++ .../src/routes/sessions/[id]/Message.svelte | 93 +++++-- .../src/routes/sessions/[id]/Writebox.svelte | 70 ++++-- 10 files changed, 479 insertions(+), 72 deletions(-) create mode 100644 frontend/src/lib/stores/messageHighlight.ts create mode 100644 frontend/src/routes/sessions/[id]/FeedbackSidebar.svelte diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index d3f6b7a0..036600d3 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 916c0db1..d1452640 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 331a6e4a..92ddba90 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 32c76106..08262c74 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 00000000..c7e518b0 --- /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 84a9de89..97eef875 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 2373abfc..b1dc748d 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 00000000..313c8cb2 --- /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 943515d2..567ac3b2 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 0c582ac0..3d25940d 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> -- GitLab