feat(frontend): UI vragen & antwoorden verbeterd.

This commit is contained in:
Gerald Schmittinger 2025-05-18 14:40:37 +02:00
parent 10a329bed3
commit 97f6a603f5
9 changed files with 164 additions and 217 deletions

View file

@ -15,7 +15,6 @@
<div <div
v-for="question in questions" v-for="question in questions"
:key="(question.sequenceNumber, question.content)" :key="(question.sequenceNumber, question.content)"
class="border rounded-2xl p-4 shadow-sm bg-white"
> >
<SingleQuestion :question="question"></SingleQuestion> <SingleQuestion :question="question"></SingleQuestion>
</div> </div>

View file

@ -19,6 +19,7 @@
language: Language; language: Language;
learningObjectHruid?: string; learningObjectHruid?: string;
forGroup?: GroupDTOId | undefined; forGroup?: GroupDTOId | undefined;
withTitle?: boolean;
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@ -65,6 +66,8 @@
const createQuestionMutation = useCreateQuestionMutation(loID); const createQuestionMutation = useCreateQuestionMutation(loID);
const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username); const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username);
const showQuestionBox = computed(() => authService.authState.activeRole === AccountType.Student && pathIsAssignment.value);
function submitQuestion(): void { function submitQuestion(): void {
const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]; const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[];
const assignment = assignments.find( const assignment = assignments.find(
@ -93,24 +96,26 @@
</script> </script>
<template> <template>
<div <h3 v-if="props.withTitle && showQuestionBox">{{ t('askAQuestion') }}:</h3>
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment" <div class="question-box" v-if="showQuestionBox">
class="question-box" <v-textarea
> :label="t('question-input-placeholder')"
<div class="input-wrapper">
<input
type="text"
:placeholder="`${t(`question-input-placeholder`)}`"
class="question-input"
v-model="questionInput" v-model="questionInput"
/> class="question-field"
<button density="compact"
rows="1"
variant="outlined"
auto-grow>
<template v-slot:append-inner>
<v-btn
icon="mdi mdi-send"
size="small"
variant="plain"
class="question-button"
@click="submitQuestion" @click="submitQuestion"
class="send-button" />
> </template>
</v-textarea>
</button>
</div>
</div> </div>
</template> </template>
@ -119,40 +124,5 @@
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
margin: 20px auto; margin: 20px auto;
font-family: sans-serif;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 999px;
padding: 8px 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.question-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
background-color: transparent;
}
.question-input::placeholder {
color: #999;
}
.send-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #555;
transition: color 0.2s ease;
}
.send-button:hover {
color: #000;
} }
</style> </style>

View file

@ -5,9 +5,10 @@
import UsingQueryResult from "./UsingQueryResult.vue"; import UsingQueryResult from "./UsingQueryResult.vue";
import type { AnswersResponse } from "@/controllers/answers"; import type { AnswersResponse } from "@/controllers/answers";
import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer";
import type { UserDTO } from "@dwengo-1/common/interfaces/user";
import authService from "@/services/auth/auth-service"; import authService from "@/services/auth/auth-service";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types" import { AccountType } from "@dwengo-1/common/util/account-types";
const { t } = useI18n(); const { t } = useI18n();
@ -65,161 +66,119 @@
createAnswerMutation.mutate(answerData, { createAnswerMutation.mutate(answerData, {
onSuccess: async () => { onSuccess: async () => {
answer.value = ""; answer.value = "";
expanded.value = true;
await answersQuery.refetch(); await answersQuery.refetch();
}, },
}); });
} }
} }
function displayNameFor(user: UserDTO) {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
} else {
return user.username;
}
}
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div <v-card
class="flex justify-between items-center mb-2" class="question-card"
style="
margin-right: 5px;
margin-left: 5px;
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: space-between;
"
>
<span class="font-semibold text-lg text-gray-800">{{
question.author.firstName + " " + question.author.lastName
}}</span>
<span class="text-sm text-gray-500">{{ formatDate(question.timestamp) }}</span>
</div>
<div
class="text-gray-700 mb-3"
style="margin-left: 10px"
> >
<v-card-title class="author-title">{{ displayNameFor(question.author) }}</v-card-title>
<v-card-subtitle>{{ formatDate(question.timestamp) }}</v-card-subtitle>
<v-card-text>
{{ question.content }} {{ question.content }}
</div> </v-card-text>
<div <template v-slot:actions
v-if="authService.authState.activeRole === AccountType.Teacher || answersQuery.data?.value?.answers?.length > 0"
>
<div class="question-actions-container">
<v-textarea
v-if="authService.authState.activeRole === AccountType.Teacher" v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container" :label="t('answer-input-placeholder')"
>
<input
v-model="answer" v-model="answer"
type="text" class="answer-field"
:placeholder="t('answer-input-placeholder')" density="compact"
class="answer-input" rows="1"
/> variant="outlined"
<button auto-grow>
<template v-slot:append-inner>
<v-btn
icon="mdi mdi-send"
size="small"
variant="plain"
class="answer-button"
@click="submitAnswer" @click="submitAnswer"
class="submit-button" />
> </template>
</v-textarea>
</button>
</div>
<using-query-result <using-query-result
:query-result="answersQuery" :query-result="answersQuery"
v-slot="answersResponse: { data: AnswersResponse }" v-slot="answersResponse: { data: AnswersResponse }"
> >
<button <v-btn
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0" v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
@click="toggle()" @click="toggle()"
class="toggle-answers-btn"
> >
{{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }} {{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }}
</button> </v-btn>
<div <div
v-show="expanded" v-show="expanded"
ref="answersContainer" ref="answersContainer"
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2" class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
> >
<div <v-card
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]" v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
:key="answerIndex" :key="answerIndex"
class="text-gray-600" class="answer-card"
>
<v-divider :thickness="2" />
<div
class="flex justify-between items-center mb-2"
style="
margin-right: 5px;
margin-left: 5px;
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: space-between;
"
>
<span class="font-semibold text-lg text-gray-800">{{ answer.author.username }}</span>
<span class="text-sm text-gray-500">{{ formatDate(answer.timestamp) }}</span>
</div>
<div
class="text-gray-700 mb-3"
style="margin-left: 10px"
> >
<v-card-title class="author-title">{{ displayNameFor(answer.author) }}</v-card-title>
<v-card-subtitle>{{ formatDate(answer.timestamp) }}</v-card-subtitle>
<v-card-text>
{{ answer.content }} {{ answer.content }}
</div> </v-card-text>
</div> </v-card>
</div> </div>
</using-query-result> </using-query-result>
</div> </div>
</template> </template>
</v-card>
</div>
</template>
<style scoped> <style scoped>
.toggle-answers-btn {
font-size: 0.875rem; .answer-field {
text-decoration: none; max-width: 500px;
background-color: #f0f4ff; /* subtle blue background */
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
} }
.toggle-answers-btn:hover { .answer-button {
background-color: #e0eaff; /* slightly darker on hover */ margin: auto;
text-decoration: underline;
} }
.answer-input-container { .answer-input-container {
margin: 5px; margin: 5px;
} }
.answer-input {
flex-grow: 1; .question-card {
outline: none; margin: 10px;
border: none;
background: transparent;
color: #374151; /* gray-700 */
font-size: 0.875rem; /* smaller font size */
} }
.answer-input::placeholder { .question-actions-container {
color: #9ca3af; /* gray-400 */
}
.submit-button {
margin-left: 0.25rem;
padding: 0.25rem;
background-color: #f3f4f6; /* gray-100 */
border-radius: 9999px;
transition: background-color 0.2s;
border: none;
cursor: pointer;
}
.submit-button:hover {
background-color: #e5e7eb; /* gray-200 */
}
.submit-icon {
width: 0.75rem;
height: 0.75rem;
color: #4b5563; /* gray-600 */
}
.answer-input-container {
display: flex;
align-items: center;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
padding: 0.5rem 1rem;
max-width: 28rem;
width: 100%; width: 100%;
margin-left: 10px;
margin-right: 10px;
}
.answer-card {
margin-top: 10px;
margin-bottom: 10px;
}
.author-title {
font-size: 14pt;
margin-bottom: -10px;
} }
</style> </style>

View file

@ -169,10 +169,12 @@
"hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt", "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt",
"questions": "Fragen", "questions": "Fragen",
"view-questions": "Fragen anzeigen auf ", "view-questions": "Fragen anzeigen auf ",
"question-input-placeholder": "Frage...", "question-input-placeholder": "Ihre Frage...",
"answer-input-placeholder": "Antwort...", "answer-input-placeholder": "Ihre Antwort...",
"answers-toggle-hide": "Antworten verstecken", "answers-toggle-hide": "Antworten verstecken",
"answers-toggle-show": "Antworten anzeigen", "answers-toggle-show": "Antworten anzeigen",
"no-questions": "Keine Fragen", "no-questions": "Keine Fragen",
"no-discussion-tip": "Wählen Sie ein Lernobjekt aus, um dessen Fragen anzuzeigen" "no-discussion-tip": "Wählen Sie ein Lernobjekt aus, um dessen Fragen anzuzeigen",
"askAQuestion": "Eine Frage stellen",
"questionsCapitalized": "Fragen"
} }

View file

@ -169,10 +169,12 @@
"hintKeywordsSeparatedBySpaces": "Keywords separated by spaces", "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces",
"questions": "questions", "questions": "questions",
"view-questions": "View questions in ", "view-questions": "View questions in ",
"question-input-placeholder": "question...", "question-input-placeholder": "Your question...",
"answer-input-placeholder": "answer...", "answer-input-placeholder": "Your answer...",
"answers-toggle-hide": "Hide answers", "answers-toggle-hide": "Hide answers",
"answers-toggle-show": "Show answers", "answers-toggle-show": "Show answers",
"no-questions": "No questions asked yet", "no-questions": "No questions asked yet",
"no-discussion-tip": "Choose a learning object to view its questions" "no-discussion-tip": "Choose a learning object to view its questions",
"askAQuestion": "Ask a question",
"questionsCapitalized": "Questions"
} }

View file

@ -170,10 +170,12 @@
"hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces", "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces",
"questions": "Questions", "questions": "Questions",
"view-questions": "Voir les questions dans ", "view-questions": "Voir les questions dans ",
"question-input-placeholder": "question...", "question-input-placeholder": "Votre question...",
"answer-input-placeholder": "réponse...", "answer-input-placeholder": "Votre réponse...",
"answers-toggle-hide": "Masquer réponses", "answers-toggle-hide": "Masquer réponses",
"answers-toggle-show": "Afficher réponse", "answers-toggle-show": "Afficher réponse",
"no-questions": "Aucune question trouvée", "no-questions": "Aucune question trouvée",
"no-discussion-tip": "Sélectionnez un objet d'apprentissage pour afficher les questions qui s'y rapportent" "no-discussion-tip": "Sélectionnez un objet d'apprentissage pour afficher les questions qui s'y rapportent",
"askAQuestion": "Pose une question",
"questionsCapitalized": "Questions"
} }

View file

@ -169,10 +169,12 @@
"hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties", "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties",
"questions": "vragen", "questions": "vragen",
"view-questions": "Bekijk vragen in ", "view-questions": "Bekijk vragen in ",
"question-input-placeholder": "vraag...", "question-input-placeholder": "Uw vraag...",
"answer-input-placeholder": "antwoord...", "answer-input-placeholder": "Uw antwoord...",
"answers-toggle-hide": "Verberg antwoorden", "answers-toggle-hide": "Verberg antwoorden",
"answers-toggle-show": "Toon antwoorden", "answers-toggle-show": "Toon antwoorden",
"no-questions": "Nog geen vragen gesteld", "no-questions": "Nog geen vragen gesteld",
"no-discussion-tip": "Kies een leerobject om zijn vragen te bekijken" "no-discussion-tip": "Kies een leerobject om zijn vragen te bekijken",
"askAQuestion": "Stel een vraag",
"questionsCapitalized": "Vragen"
} }

View file

@ -15,6 +15,9 @@
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue"; import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue";
import QuestionBox from "@/components/QuestionBox.vue"; import QuestionBox from "@/components/QuestionBox.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const route = useRoute(); const route = useRoute();
@ -120,21 +123,29 @@
<template> <template>
<DiscussionsSideBar></DiscussionsSideBar> <DiscussionsSideBar></DiscussionsSideBar>
<div class="discussions-container">
<QuestionBox <QuestionBox
:hruid="props.hruid" :hruid="props.hruid"
:language="props.language" :language="props.language"
:learningObjectHruid="props.learningObjectHruid" :learningObjectHruid="props.learningObjectHruid"
:forGroup="forGroup" :forGroup="forGroup"
withTitle
/> />
<h3>{{ t("questionsCapitalized") }}:</h3>
<using-query-result <using-query-result
:query-result="getQuestionsQuery" :query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }" v-slot="questionsResponse: { data: QuestionsResponse }"
> >
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" /> <QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" />
</using-query-result> </using-query-result>
</div>
</template> </template>
<style scoped> <style scoped>
.discussions-container {
margin: 20px;
}
.learning-path-title { .learning-path-title {
white-space: normal; white-space: normal;
} }

View file

@ -308,12 +308,6 @@
v-if="currentNode" v-if="currentNode"
></learning-object-view> ></learning-object-view>
</div> </div>
<QuestionBox
:hruid="props.hruid"
:language="props.language"
:learningObjectHruid="props.learningObjectHruid"
:forGroup="forGroup"
/>
<div class="navigation-buttons-container"> <div class="navigation-buttons-container">
<v-btn <v-btn
prepend-icon="mdi-chevron-left" prepend-icon="mdi-chevron-left"
@ -333,6 +327,7 @@
</v-btn> </v-btn>
</div> </div>
<using-query-result <using-query-result
v-if="forGroup"
:query-result="getQuestionsQuery" :query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }" v-slot="questionsResponse: { data: QuestionsResponse }"
> >
@ -346,7 +341,12 @@
</router-link> </router-link>
</span> </span>
</div> </div>
<QuestionBox
:hruid="props.hruid"
:language="props.language"
:learningObjectHruid="props.learningObjectHruid"
:forGroup="forGroup"
/>
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" /> <QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" />
</using-query-result> </using-query-result>
</using-query-result> </using-query-result>