Merge pull request #269 from SELab-2/feat/discussions
feat: Discussions pagina's
This commit is contained in:
commit
f6c2f71edb
22 changed files with 885 additions and 247 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { Request, Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { themes } from '../data/themes.js';
|
import { themes } from '../data/themes.js';
|
||||||
import { FALLBACK_LANG } from '../config.js';
|
import { FALLBACK_LANG } from '../config.js';
|
||||||
import learningPathService from '../services/learning-paths/learning-path-service.js';
|
import learningPathService from '../services/learning-paths/learning-path-service.js';
|
||||||
|
@ -15,7 +15,7 @@ import { requireFields } from './error-helper.js';
|
||||||
/**
|
/**
|
||||||
* Fetch learning paths based on query parameters.
|
* Fetch learning paths based on query parameters.
|
||||||
*/
|
*/
|
||||||
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
|
export async function getLearningPaths(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
const admin = req.query.admin;
|
const admin = req.query.admin;
|
||||||
if (admin) {
|
if (admin) {
|
||||||
const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
|
const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
|
||||||
|
@ -59,6 +59,19 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
hruidList = themes.flatMap((theme) => theme.hruids);
|
hruidList = themes.flatMap((theme) => theme.hruids);
|
||||||
|
|
||||||
|
const apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup);
|
||||||
|
const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || [];
|
||||||
|
let allLearningPaths: LearningPath[] = apiLearningPaths;
|
||||||
|
|
||||||
|
if (req.auth) {
|
||||||
|
const adminUsername = req.auth.username;
|
||||||
|
const userLearningPaths = (await learningPathService.getLearningPathsAdministratedBy(adminUsername)) || [];
|
||||||
|
allLearningPaths = apiLearningPaths.concat(userLearningPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(allLearningPaths);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const learningPaths = await learningPathService.fetchLearningPaths(
|
const learningPaths = await learningPathService.fetchLearningPaths(
|
||||||
|
|
|
@ -7,14 +7,20 @@ import { LearningPathTransition } from '../../entities/content/learning-path-tra
|
||||||
|
|
||||||
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
||||||
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||||
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] });
|
return this.findOne(
|
||||||
|
{
|
||||||
|
hruid: hruid,
|
||||||
|
language: language,
|
||||||
|
},
|
||||||
|
{ populate: ['nodes', 'nodes.transitions', 'admins'] }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all learning paths which have the given language and whose title OR description contains the
|
* Returns all learning paths which have the given language and whose title OR description contains the
|
||||||
* query string.
|
* query string.
|
||||||
*
|
*
|
||||||
* @param query The query string we want to seach for in the title or description.
|
* @param query The query string we want to search for in the title or description.
|
||||||
* @param language The language of the learning paths we want to find.
|
* @param language The language of the learning paths we want to find.
|
||||||
*/
|
*/
|
||||||
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {
|
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {
|
||||||
|
|
|
@ -18,13 +18,14 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
|
||||||
content: question.content,
|
content: question.content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
});
|
});
|
||||||
|
await this.insert(questionEntity);
|
||||||
questionEntity.learningObjectHruid = question.loId.hruid;
|
questionEntity.learningObjectHruid = question.loId.hruid;
|
||||||
questionEntity.learningObjectLanguage = question.loId.language;
|
questionEntity.learningObjectLanguage = question.loId.language;
|
||||||
questionEntity.learningObjectVersion = question.loId.version;
|
questionEntity.learningObjectVersion = question.loId.version;
|
||||||
questionEntity.author = question.author;
|
questionEntity.author = question.author;
|
||||||
questionEntity.inGroup = question.inGroup;
|
questionEntity.inGroup = question.inGroup;
|
||||||
questionEntity.content = question.content;
|
questionEntity.content = question.content;
|
||||||
return await this.insert(questionEntity);
|
return questionEntity;
|
||||||
}
|
}
|
||||||
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
|
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
|
||||||
return this.findAll({
|
return this.findAll({
|
||||||
|
|
|
@ -62,6 +62,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
|
||||||
data: learningPaths,
|
data: learningPaths,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
|
async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
|
||||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
||||||
const params = { all: query, language };
|
const params = { all: query, language };
|
||||||
|
@ -75,7 +76,8 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async getLearningPathsAdministratedBy(_adminUsername: string) {
|
async getLearningPathsAdministratedBy(_adminUsername: string) {
|
||||||
return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
|
// Dwengo API does not have the concept of admins, so we cannot filter by them.
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -14,11 +14,14 @@ import {
|
||||||
testLearningObjectEssayQuestion,
|
testLearningObjectEssayQuestion,
|
||||||
testLearningObjectMultipleChoice,
|
testLearningObjectMultipleChoice,
|
||||||
} from '../../test_assets/content/learning-objects.testdata';
|
} from '../../test_assets/content/learning-objects.testdata';
|
||||||
import { testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata';
|
import { testLearningPath02, testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata';
|
||||||
import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service';
|
import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service';
|
||||||
import { getTestGroup01, getTestGroup02 } from '../../test_assets/assignments/groups.testdata';
|
import { getTestGroup01, getTestGroup02 } from '../../test_assets/assignments/groups.testdata';
|
||||||
import { Group } from '../../../src/entities/assignments/group.entity.js';
|
import { Group } from '../../../src/entities/assignments/group.entity.js';
|
||||||
|
import { Teacher } from '../../../src/entities/users/teacher.entity.js';
|
||||||
import { RequiredEntityData } from '@mikro-orm/core';
|
import { RequiredEntityData } from '@mikro-orm/core';
|
||||||
|
import { getFooFighters, getLimpBizkit } from '../../test_assets/users/teachers.testdata';
|
||||||
|
import { mapToTeacherDTO } from '../../../src/interfaces/teacher';
|
||||||
|
|
||||||
function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode {
|
function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode {
|
||||||
const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid);
|
const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid);
|
||||||
|
@ -33,6 +36,8 @@ describe('DatabaseLearningPathProvider', () => {
|
||||||
let finalLearningObject: RequiredEntityData<LearningObject>;
|
let finalLearningObject: RequiredEntityData<LearningObject>;
|
||||||
let groupA: Group;
|
let groupA: Group;
|
||||||
let groupB: Group;
|
let groupB: Group;
|
||||||
|
let teacherA: Teacher;
|
||||||
|
let teacherB: Teacher;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await setupTestApp();
|
await setupTestApp();
|
||||||
|
@ -42,6 +47,8 @@ describe('DatabaseLearningPathProvider', () => {
|
||||||
finalLearningObject = testLearningObjectEssayQuestion;
|
finalLearningObject = testLearningObjectEssayQuestion;
|
||||||
groupA = getTestGroup01();
|
groupA = getTestGroup01();
|
||||||
groupB = getTestGroup02();
|
groupB = getTestGroup02();
|
||||||
|
teacherA = getFooFighters();
|
||||||
|
teacherB = getLimpBizkit();
|
||||||
|
|
||||||
// Place different submissions for group A and B.
|
// Place different submissions for group A and B.
|
||||||
const submissionRepo = getSubmissionRepository();
|
const submissionRepo = getSubmissionRepository();
|
||||||
|
@ -140,4 +147,18 @@ describe('DatabaseLearningPathProvider', () => {
|
||||||
expect(result.length).toBe(0);
|
expect(result.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getLearningPathsAdministratedBy', () => {
|
||||||
|
it('returns the learning path owned by the admin', async () => {
|
||||||
|
const expectedLearningPath = mapToLearningPath(testLearningPath02, [mapToTeacherDTO(teacherB)]);
|
||||||
|
const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherB], expectedLearningPath.language);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].title).toBe(expectedLearningPath.title);
|
||||||
|
expect(result[0].description).toBe(expectedLearningPath.description);
|
||||||
|
});
|
||||||
|
it('returns an empty result when querying admins that do not have custom learning paths', async () => {
|
||||||
|
const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherA], testLearningPath.language);
|
||||||
|
expect(result.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,10 +14,12 @@ import {
|
||||||
testLearningObjectMultipleChoice,
|
testLearningObjectMultipleChoice,
|
||||||
testLearningObjectPnNotebooks,
|
testLearningObjectPnNotebooks,
|
||||||
} from './learning-objects.testdata';
|
} from './learning-objects.testdata';
|
||||||
|
import { getLimpBizkit } from '../users/teachers.testdata';
|
||||||
|
|
||||||
export function makeTestLearningPaths(_em: EntityManager): LearningPath[] {
|
export function makeTestLearningPaths(_em: EntityManager): LearningPath[] {
|
||||||
const learningPath01 = mapToLearningPath(testLearningPath01, []);
|
const learningPath01 = mapToLearningPath(testLearningPath01, []);
|
||||||
const learningPath02 = mapToLearningPath(testLearningPath02, []);
|
const learningPath02 = mapToLearningPath(testLearningPath02, []);
|
||||||
|
learningPath02.admins = [getLimpBizkit()];
|
||||||
|
|
||||||
const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []);
|
const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []);
|
||||||
const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []);
|
const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []);
|
||||||
|
|
10
common/src/util/match-mode.ts
Normal file
10
common/src/util/match-mode.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
export enum MatchMode {
|
||||||
|
/**
|
||||||
|
* Match any
|
||||||
|
*/
|
||||||
|
ANY = 'ANY',
|
||||||
|
/**
|
||||||
|
* Match all
|
||||||
|
*/
|
||||||
|
ALL = 'ALL',
|
||||||
|
}
|
48
frontend/src/components/DiscussionSideBarElement.vue
Normal file
48
frontend/src/components/DiscussionSideBarElement.vue
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
|
||||||
|
import type { LearningPath } from "@/data-objects/learning-paths/learning-path";
|
||||||
|
import { useLearningObjectListForPathQuery } from "@/queries/learning-objects";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
path: LearningPath;
|
||||||
|
activeObjectId: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-expansion-panel :value="props.path.hruid">
|
||||||
|
<v-expansion-panel-title>
|
||||||
|
{{ path.title }}
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<v-lazy>
|
||||||
|
<using-query-result
|
||||||
|
:query-result="useLearningObjectListForPathQuery(props.path)"
|
||||||
|
v-slot="learningObjects: { data: LearningObject[] }"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="node in learningObjects.data"
|
||||||
|
:key="node.key"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
link
|
||||||
|
:to="{
|
||||||
|
path: `/discussion-reload/${props.path.hruid}/${node.language}/${node.key}`,
|
||||||
|
query: route.query,
|
||||||
|
}"
|
||||||
|
:title="node.title"
|
||||||
|
:active="node.key === props.activeObjectId"
|
||||||
|
>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</using-query-result>
|
||||||
|
</v-lazy>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
70
frontend/src/components/DiscussionsSideBar.vue
Normal file
70
frontend/src/components/DiscussionsSideBar.vue
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||||
|
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||||
|
import DiscussionSideBarElement from "@/components/DiscussionSideBarElement.vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useGetAllLearningPaths } from "@/queries/learning-paths.ts";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const navigationDrawerShown = ref(true);
|
||||||
|
const currentLocale = ref(locale.value);
|
||||||
|
const expanded = ref([route.params.hruid]);
|
||||||
|
|
||||||
|
watch(locale, (newLocale) => {
|
||||||
|
currentLocale.value = newLocale;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allLearningPathsResult = useGetAllLearningPaths(() => currentLocale.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="navigationDrawerShown"
|
||||||
|
:width="350"
|
||||||
|
app
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column h-100">
|
||||||
|
<v-list-item>
|
||||||
|
<template v-slot:title>
|
||||||
|
<div class="title">{{ t("discussions") }}</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-expansion-panels v-model="expanded">
|
||||||
|
<using-query-result
|
||||||
|
:query-result="allLearningPathsResult"
|
||||||
|
v-slot="learningPaths: { data: LearningPath[] }"
|
||||||
|
>
|
||||||
|
<DiscussionSideBarElement
|
||||||
|
v-for="learningPath in learningPaths.data"
|
||||||
|
:path="learningPath"
|
||||||
|
:activeObjectId="'' as string"
|
||||||
|
:key="learningPath.hruid"
|
||||||
|
/>
|
||||||
|
</using-query-result>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</div>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
<div class="control-bar-above-content">
|
||||||
|
<v-btn
|
||||||
|
:icon="navigationDrawerShown ? 'mdi-menu-open' : 'mdi-menu'"
|
||||||
|
class="navigation-drawer-toggle-button"
|
||||||
|
variant="plain"
|
||||||
|
@click="navigationDrawerShown = !navigationDrawerShown"
|
||||||
|
></v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title {
|
||||||
|
color: #0e6942;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bolder;
|
||||||
|
padding-top: 2%;
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -87,14 +87,13 @@
|
||||||
>
|
>
|
||||||
{{ t("classes") }}
|
{{ t("classes") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<!-- TODO Re-enable this button when the discussion page is ready -->
|
<v-btn
|
||||||
<!-- <v-btn-->
|
class="menu_item"
|
||||||
<!-- class="menu_item"-->
|
variant="text"
|
||||||
<!-- variant="text"-->
|
to="/discussion"
|
||||||
<!-- to="/user/discussion"-->
|
>
|
||||||
<!-- >-->
|
{{ t("discussions") }}
|
||||||
<!-- {{ t("discussions") }}-->
|
</v-btn>
|
||||||
<!-- </v-btn>-->
|
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
<v-menu
|
<v-menu
|
||||||
open-on-hover
|
open-on-hover
|
||||||
|
@ -231,7 +230,7 @@
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
to="/user/discussion"
|
to="/discussion"
|
||||||
link
|
link
|
||||||
>
|
>
|
||||||
<v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
|
<v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
|
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
|
||||||
import SingleQuestion from "./SingleQuestion.vue";
|
import SingleQuestion from "./SingleQuestion.vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
questions: QuestionDTO[];
|
questions: QuestionDTO[];
|
||||||
|
@ -8,13 +11,28 @@
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div v-if="questions.length != 0">
|
||||||
v-for="question in questions"
|
<div
|
||||||
:key="(question.sequenceNumber, question.content)"
|
v-for="question in questions"
|
||||||
class="border rounded-2xl p-4 shadow-sm bg-white"
|
:key="(question.sequenceNumber, question.content)"
|
||||||
>
|
>
|
||||||
<SingleQuestion :question="question"></SingleQuestion>
|
<SingleQuestion :question="question"></SingleQuestion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="no-questions">{{ t("no-questions") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.no-questions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 40vh;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
134
frontend/src/components/QuestionBox.vue
Normal file
134
frontend/src/components/QuestionBox.vue
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import authService from "@/services/auth/auth-service.ts";
|
||||||
|
import { Language } from "@/data-objects/language.ts";
|
||||||
|
import { computed, type ComputedRef, ref } from "vue";
|
||||||
|
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
||||||
|
import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students.ts";
|
||||||
|
import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group";
|
||||||
|
import type { QuestionData } from "@dwengo-1/common/interfaces/question";
|
||||||
|
import type { LearningObjectIdentifierDTO } from "@dwengo-1/interfaces/learning-content";
|
||||||
|
import { useCreateQuestionMutation, useQuestionsQuery } from "@/queries/questions.ts";
|
||||||
|
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
|
||||||
|
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||||
|
import { useLearningObjectListForPathQuery } from "@/queries/learning-objects.ts";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { AccountType } from "@dwengo-1/common/util/account-types.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hruid: string;
|
||||||
|
language: Language;
|
||||||
|
learningObjectHruid?: string;
|
||||||
|
forGroup?: GroupDTOId | undefined;
|
||||||
|
withTitle?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const studentAssignmentsQueryResult = useStudentAssignmentsQuery(
|
||||||
|
authService.authState.user?.profile.preferred_username,
|
||||||
|
);
|
||||||
|
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, props.forGroup);
|
||||||
|
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
|
||||||
|
|
||||||
|
const pathIsAssignment = computed(() => {
|
||||||
|
const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || [];
|
||||||
|
return assignments.some(
|
||||||
|
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
|
||||||
|
() => learningPathQueryResult.data.value?.nodesAsList ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentNode = computed(() => {
|
||||||
|
const currentHruid = props.learningObjectHruid;
|
||||||
|
return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getQuestionsQuery = useQuestionsQuery(
|
||||||
|
computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
language: currentNode.value?.language,
|
||||||
|
hruid: currentNode.value?.learningobjectHruid,
|
||||||
|
version: currentNode.value?.version,
|
||||||
|
}) as LearningObjectIdentifierDTO,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const questionInput = ref("");
|
||||||
|
|
||||||
|
const loID: ComputedRef<LearningObjectIdentifierDTO> = computed(() => ({
|
||||||
|
hruid: props.learningObjectHruid as string,
|
||||||
|
language: props.language,
|
||||||
|
}));
|
||||||
|
const createQuestionMutation = useCreateQuestionMutation(loID);
|
||||||
|
const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username);
|
||||||
|
|
||||||
|
const showQuestionBox = computed(
|
||||||
|
() => authService.authState.activeRole === AccountType.Student && pathIsAssignment.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
function submitQuestion(): void {
|
||||||
|
const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[];
|
||||||
|
const assignment = assignments.find(
|
||||||
|
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
|
||||||
|
);
|
||||||
|
const groups = groupsQueryResult.data.value?.groups as GroupDTO[];
|
||||||
|
const group = groups?.find((group) => group.assignment === assignment?.id) as GroupDTO;
|
||||||
|
const questionData: QuestionData = {
|
||||||
|
author: authService.authState.user?.profile.preferred_username,
|
||||||
|
content: questionInput.value,
|
||||||
|
inGroup: group,
|
||||||
|
};
|
||||||
|
if (questionInput.value !== "") {
|
||||||
|
createQuestionMutation.mutate(questionData, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
questionInput.value = ""; // Clear the input field after submission
|
||||||
|
await getQuestionsQuery.refetch(); // Reload the questions
|
||||||
|
},
|
||||||
|
onError: (_) => {
|
||||||
|
// TODO Handle error
|
||||||
|
// - console.error(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3 v-if="props.withTitle && showQuestionBox">{{ t("askAQuestion") }}:</h3>
|
||||||
|
<div
|
||||||
|
class="question-box"
|
||||||
|
v-if="showQuestionBox"
|
||||||
|
>
|
||||||
|
<v-textarea
|
||||||
|
:label="t('question-input-placeholder')"
|
||||||
|
v-model="questionInput"
|
||||||
|
class="question-field"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.question-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,17 +5,34 @@
|
||||||
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 { AccountType } from "@dwengo-1/common/util/account-types";
|
import { AccountType } from "@dwengo-1/common/util/account-types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
question: QuestionDTO;
|
question: QuestionDTO;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
const answersContainer = ref<HTMLElement | null>(null); // Ref for the answers container
|
||||||
|
|
||||||
function toggle(): void {
|
function toggle(): void {
|
||||||
expanded.value = !expanded.value;
|
expanded.value = !expanded.value;
|
||||||
|
|
||||||
|
// Scroll to the answers container if expanded
|
||||||
|
if (expanded.value && answersContainer.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (answersContainer.value) {
|
||||||
|
answersContainer.value.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(timestamp: string | Date): string {
|
function formatDate(timestamp: string | Date): string {
|
||||||
|
@ -49,141 +66,121 @@
|
||||||
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="question-card">
|
||||||
class="flex justify-between items-center mb-2"
|
<v-card-title class="author-title">{{ displayNameFor(question.author) }}</v-card-title>
|
||||||
style="
|
<v-card-subtitle>{{ formatDate(question.timestamp) }}</v-card-subtitle>
|
||||||
margin-right: 5px;
|
<v-card-text>
|
||||||
margin-left: 5px;
|
{{ question.content }}
|
||||||
font-weight: bold;
|
</v-card-text>
|
||||||
display: flex;
|
<template
|
||||||
flex-direction: row;
|
v-slot:actions
|
||||||
justify-content: space-between;
|
v-if="
|
||||||
"
|
authService.authState.activeRole === AccountType.Teacher ||
|
||||||
>
|
answersQuery.data?.value?.answers?.length > 0
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ question.content }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="authService.authState.activeRole === AccountType.Teacher"
|
|
||||||
class="answer-input-container"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="answer"
|
|
||||||
type="text"
|
|
||||||
placeholder="answer: ..."
|
|
||||||
class="answer-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="submitAnswer"
|
|
||||||
class="submit-button"
|
|
||||||
>
|
>
|
||||||
▶
|
<div class="question-actions-container">
|
||||||
</button>
|
<v-textarea
|
||||||
</div>
|
v-if="authService.authState.activeRole === AccountType.Teacher"
|
||||||
<using-query-result
|
:label="t('answer-input-placeholder')"
|
||||||
:query-result="answersQuery"
|
v-model="answer"
|
||||||
v-slot="answersResponse: { data: AnswersResponse }"
|
class="answer-field"
|
||||||
>
|
density="compact"
|
||||||
<button
|
rows="1"
|
||||||
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
|
variant="outlined"
|
||||||
@click="toggle()"
|
auto-grow
|
||||||
class="text-blue-600 hover:underline text-sm"
|
|
||||||
>
|
|
||||||
{{ expanded ? "Hide Answers" : "Show Answers" }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="expanded"
|
|
||||||
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
|
|
||||||
:key="answerIndex"
|
|
||||||
class="text-gray-600"
|
|
||||||
>
|
|
||||||
<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>
|
<template v-slot:append-inner>
|
||||||
<span class="text-sm text-gray-500">{{ formatDate(answer.timestamp) }}</span>
|
<v-btn
|
||||||
</div>
|
icon="mdi mdi-send"
|
||||||
|
size="small"
|
||||||
<div
|
variant="plain"
|
||||||
class="text-gray-700 mb-3"
|
class="answer-button"
|
||||||
style="margin-left: 10px"
|
@click="submitAnswer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-textarea>
|
||||||
|
<using-query-result
|
||||||
|
:query-result="answersQuery"
|
||||||
|
v-slot="answersResponse: { data: AnswersResponse }"
|
||||||
>
|
>
|
||||||
{{ answer.content }}
|
<v-btn
|
||||||
</div>
|
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
|
||||||
|
@click="toggle()"
|
||||||
|
>
|
||||||
|
{{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="expanded"
|
||||||
|
ref="answersContainer"
|
||||||
|
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
|
||||||
|
:key="answerIndex"
|
||||||
|
class="answer-card"
|
||||||
|
>
|
||||||
|
<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 }}
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</using-query-result>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</using-query-result>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.answer-input {
|
.answer-field {
|
||||||
flex-grow: 1;
|
max-width: 500px;
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #374151; /* gray-700 */
|
|
||||||
font-size: 0.875rem; /* smaller font size */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-input::placeholder {
|
.answer-button {
|
||||||
color: #9ca3af; /* gray-400 */
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.answer-input-container {
|
||||||
display: flex;
|
margin: 5px;
|
||||||
align-items: center;
|
}
|
||||||
border: 1px solid #d1d5db; /* gray-300 */
|
|
||||||
border-radius: 9999px;
|
.question-card {
|
||||||
padding: 0.5rem 1rem;
|
margin: 10px;
|
||||||
max-width: 28rem;
|
}
|
||||||
|
|
||||||
|
.question-actions-container {
|
||||||
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>
|
||||||
|
|
|
@ -181,5 +181,15 @@
|
||||||
"current-groups": "Aktuelle Gruppen",
|
"current-groups": "Aktuelle Gruppen",
|
||||||
"group-size-label": "Gruppengröße",
|
"group-size-label": "Gruppengröße",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"unassigned": "Nicht zugewiesen"
|
"unassigned": "Nicht zugewiesen",
|
||||||
|
"questions": "Fragen",
|
||||||
|
"view-questions": "Fragen anzeigen auf ",
|
||||||
|
"question-input-placeholder": "Ihre Frage...",
|
||||||
|
"answer-input-placeholder": "Ihre Antwort...",
|
||||||
|
"answers-toggle-hide": "Antworten verstecken",
|
||||||
|
"answers-toggle-show": "Antworten anzeigen",
|
||||||
|
"no-questions": "Keine Fragen",
|
||||||
|
"no-discussion-tip": "Wählen Sie ein Lernobjekt aus, um dessen Fragen anzuzeigen",
|
||||||
|
"askAQuestion": "Eine Frage stellen",
|
||||||
|
"questionsCapitalized": "Fragen"
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"teacher": "teacher",
|
"teacher": "teacher",
|
||||||
"assignments": "Assignments",
|
"assignments": "Assignments",
|
||||||
"classes": "Classes",
|
"classes": "Classes",
|
||||||
"discussions": "discussions",
|
"discussions": "Discussions",
|
||||||
"logout": "log out",
|
"logout": "log out",
|
||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
|
@ -182,5 +182,15 @@
|
||||||
"current-groups": "Current groups",
|
"current-groups": "Current groups",
|
||||||
"group-size-label": "Group size",
|
"group-size-label": "Group size",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"unassigned": "Unassigned"
|
"unassigned": "Unassigned",
|
||||||
|
"questions": "questions",
|
||||||
|
"view-questions": "View questions in ",
|
||||||
|
"question-input-placeholder": "Your question...",
|
||||||
|
"answer-input-placeholder": "Your answer...",
|
||||||
|
"answers-toggle-hide": "Hide answers",
|
||||||
|
"answers-toggle-show": "Show answers",
|
||||||
|
"no-questions": "No questions asked yet",
|
||||||
|
"no-discussion-tip": "Choose a learning object to view its questions",
|
||||||
|
"askAQuestion": "Ask a question",
|
||||||
|
"questionsCapitalized": "Questions"
|
||||||
}
|
}
|
||||||
|
|
|
@ -182,5 +182,15 @@
|
||||||
"current-groups": "Groupes actuels",
|
"current-groups": "Groupes actuels",
|
||||||
"group-size-label": "Taille des groupes",
|
"group-size-label": "Taille des groupes",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"unassigned": "Non assigné"
|
"unassigned": "Non assigné",
|
||||||
|
"questions": "Questions",
|
||||||
|
"view-questions": "Voir les questions dans ",
|
||||||
|
"question-input-placeholder": "Votre question...",
|
||||||
|
"answer-input-placeholder": "Votre réponse...",
|
||||||
|
"answers-toggle-hide": "Masquer réponses",
|
||||||
|
"answers-toggle-show": "Afficher réponse",
|
||||||
|
"no-questions": "Aucune question trouvée",
|
||||||
|
"no-discussion-tip": "Sélectionnez un objet d'apprentissage pour afficher les questions qui s'y rapportent",
|
||||||
|
"askAQuestion": "Pose une question",
|
||||||
|
"questionsCapitalized": "Questions"
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"teacher": "leerkracht",
|
"teacher": "leerkracht",
|
||||||
"assignments": "Opdrachten",
|
"assignments": "Opdrachten",
|
||||||
"classes": "Klassen",
|
"classes": "Klassen",
|
||||||
"discussions": "discussies",
|
"discussions": "Discussies",
|
||||||
"logout": "log uit",
|
"logout": "log uit",
|
||||||
"error_title": "Fout",
|
"error_title": "Fout",
|
||||||
"previous": "Vorige",
|
"previous": "Vorige",
|
||||||
|
@ -181,5 +181,15 @@
|
||||||
"current-groups": "Huidige groepen",
|
"current-groups": "Huidige groepen",
|
||||||
"group-size-label": "Grootte van groepen",
|
"group-size-label": "Grootte van groepen",
|
||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"unassigned": "Niet toegewezen"
|
"unassigned": "Niet toegewezen",
|
||||||
|
"questions": "vragen",
|
||||||
|
"view-questions": "Bekijk vragen in ",
|
||||||
|
"question-input-placeholder": "Uw vraag...",
|
||||||
|
"answer-input-placeholder": "Uw antwoord...",
|
||||||
|
"answers-toggle-hide": "Verberg antwoorden",
|
||||||
|
"answers-toggle-show": "Toon antwoorden",
|
||||||
|
"no-questions": "Nog geen vragen gesteld",
|
||||||
|
"no-discussion-tip": "Kies een leerobject om zijn vragen te bekijken",
|
||||||
|
"askAQuestion": "Stel een vraag",
|
||||||
|
"questionsCapitalized": "Vragen"
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
|
||||||
import SingleTheme from "@/views/SingleTheme.vue";
|
import SingleTheme from "@/views/SingleTheme.vue";
|
||||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||||
import authService from "@/services/auth/auth-service";
|
import authService from "@/services/auth/auth-service";
|
||||||
|
import DiscussionForward from "@/views/discussions/DiscussionForward.vue";
|
||||||
|
import NoDiscussion from "@/views/discussions/NoDiscussion.vue";
|
||||||
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue";
|
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue";
|
||||||
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
|
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
|
||||||
|
|
||||||
|
@ -57,12 +59,6 @@ const router = createRouter({
|
||||||
name: "UserClasses",
|
name: "UserClasses",
|
||||||
component: UserClasses,
|
component: UserClasses,
|
||||||
},
|
},
|
||||||
// TODO Re-enable this route when the discussion page is ready
|
|
||||||
// {
|
|
||||||
// Path: "discussion",
|
|
||||||
// Name: "UserDiscussions",
|
|
||||||
// Component: UserDiscussions,
|
|
||||||
// },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -102,9 +98,23 @@ const router = createRouter({
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/discussion/:id",
|
path: "/discussion",
|
||||||
|
name: "Discussions",
|
||||||
|
component: NoDiscussion,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/discussion/:hruid/:language/:learningObjectHruid",
|
||||||
name: "SingleDiscussion",
|
name: "SingleDiscussion",
|
||||||
component: SingleDiscussion,
|
component: SingleDiscussion,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/discussion-reload/:hruid/:language/:learningObjectHruid",
|
||||||
|
name: "DiscussionForwardWorkaround",
|
||||||
|
component: DiscussionForward,
|
||||||
|
props: true,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
25
frontend/src/views/discussions/DiscussionForward.vue
Normal file
25
frontend/src/views/discussions/DiscussionForward.vue
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Language } from "@/data-objects/language";
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hruid: string;
|
||||||
|
language: Language;
|
||||||
|
learningObjectHruid?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const discussionURL = "/discussion" + "/" + props.hruid + "/" + props.language + "/" + props.learningObjectHruid;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await router.replace(discussionURL);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main></main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
105
frontend/src/views/discussions/NoDiscussion.vue
Normal file
105
frontend/src/views/discussions/NoDiscussion.vue
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DiscussionsSideBar></DiscussionsSideBar>
|
||||||
|
<div>
|
||||||
|
<p class="no-discussion-tip">{{ t("no-discussion-tip") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.no-discussion-tip {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
.learning-path-title {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.search-field-container {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
.control-bar-above-content {
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: -30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.learning-object-view-container {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.navigation-buttons-container {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.assignment-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 2px solid #f8bcbc;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #f36c6c;
|
||||||
|
background-color: rgba(248, 188, 188, 0.1);
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 2; /* Less than modals/popups */
|
||||||
|
}
|
||||||
|
.question-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-link {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-link a {
|
||||||
|
color: #3b82f6; /* blue */
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,7 +1,195 @@
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { Language } from "@/data-objects/language.ts";
|
||||||
|
import { computed, type ComputedRef, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
|
||||||
|
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||||
|
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
|
||||||
|
import { useQuestionsQuery } from "@/queries/questions";
|
||||||
|
import type { QuestionsResponse } from "@/controllers/questions";
|
||||||
|
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||||
|
import QandA from "@/components/QandA.vue";
|
||||||
|
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
|
||||||
|
import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue";
|
||||||
|
import QuestionBox from "@/components/QuestionBox.vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hruid: string;
|
||||||
|
language: Language;
|
||||||
|
learningObjectHruid?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface LearningPathPageQuery {
|
||||||
|
forGroup?: string;
|
||||||
|
assignmentNo?: string;
|
||||||
|
classId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = computed(() => route.query as LearningPathPageQuery);
|
||||||
|
|
||||||
|
const forGroup = computed(() => {
|
||||||
|
if (query.value.forGroup && query.value.assignmentNo && query.value.classId) {
|
||||||
|
return {
|
||||||
|
forGroup: parseInt(query.value.forGroup),
|
||||||
|
assignmentNo: parseInt(query.value.assignmentNo),
|
||||||
|
classId: query.value.classId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup);
|
||||||
|
|
||||||
|
const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
|
||||||
|
() => learningPathQueryResult.data.value?.nodesAsList ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentNode = computed(() => {
|
||||||
|
const currentHruid = props.learningObjectHruid;
|
||||||
|
return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getQuestionsQuery = useQuestionsQuery(
|
||||||
|
computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
language: currentNode.value?.language,
|
||||||
|
hruid: currentNode.value?.learningobjectHruid,
|
||||||
|
version: currentNode.value?.version,
|
||||||
|
}) as LearningObjectIdentifierDTO,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.params.hruid, route.params.language, route.params.learningObjectHruid],
|
||||||
|
() => {
|
||||||
|
//TODO: moet op een of andere manier createQuestionMutation opnieuw kunnen instellen
|
||||||
|
// Momenteel opgelost door de DiscussionsForward page workaround
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main></main>
|
<DiscussionsSideBar></DiscussionsSideBar>
|
||||||
|
<div class="discussions-container">
|
||||||
|
<QuestionBox
|
||||||
|
:hruid="props.hruid"
|
||||||
|
:language="props.language"
|
||||||
|
:learningObjectHruid="props.learningObjectHruid"
|
||||||
|
:forGroup="forGroup"
|
||||||
|
withTitle
|
||||||
|
/>
|
||||||
|
<h3>{{ t("questionsCapitalized") }}:</h3>
|
||||||
|
<using-query-result
|
||||||
|
:query-result="getQuestionsQuery"
|
||||||
|
v-slot="questionsResponse: { data: QuestionsResponse }"
|
||||||
|
>
|
||||||
|
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" />
|
||||||
|
</using-query-result>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.discussions-container {
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-path-title {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field-container {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-bar-above-content {
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: -30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-object-view-container {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-buttons-container {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 2px solid #f8bcbc;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #f36c6c;
|
||||||
|
background-color: rgba(248, 188, 188, 0.1);
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 2; /* Less than modals/popups */
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-link a {
|
||||||
|
color: #3b82f6; /* blue */
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -17,11 +17,11 @@
|
||||||
import type { QuestionsResponse } from "@/controllers/questions";
|
import type { QuestionsResponse } from "@/controllers/questions";
|
||||||
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||||
import QandA from "@/components/QandA.vue";
|
import QandA from "@/components/QandA.vue";
|
||||||
import type { QuestionData, QuestionDTO } from "@dwengo-1/common/interfaces/question";
|
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
|
||||||
import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students";
|
import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students";
|
||||||
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
|
||||||
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
|
|
||||||
import QuestionNotification from "@/components/QuestionNotification.vue";
|
import QuestionNotification from "@/components/QuestionNotification.vue";
|
||||||
|
import QuestionBox from "@/components/QuestionBox.vue";
|
||||||
import { AccountType } from "@dwengo-1/common/util/account-types";
|
import { AccountType } from "@dwengo-1/common/util/account-types";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -150,12 +150,6 @@
|
||||||
const studentAssignmentsQueryResult = useStudentAssignmentsQuery(
|
const studentAssignmentsQueryResult = useStudentAssignmentsQuery(
|
||||||
authService.authState.user?.profile.preferred_username,
|
authService.authState.user?.profile.preferred_username,
|
||||||
);
|
);
|
||||||
const pathIsAssignment = computed(() => {
|
|
||||||
const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || [];
|
|
||||||
return assignments.some(
|
|
||||||
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const loID: LearningObjectIdentifierDTO = {
|
const loID: LearningObjectIdentifierDTO = {
|
||||||
hruid: props.learningObjectHruid as string,
|
hruid: props.learningObjectHruid as string,
|
||||||
|
@ -166,31 +160,16 @@
|
||||||
|
|
||||||
const questionInput = ref("");
|
const questionInput = ref("");
|
||||||
|
|
||||||
function submitQuestion(): void {
|
const discussionLink = computed(
|
||||||
const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[];
|
() =>
|
||||||
const assignment = assignments.find(
|
"/discussion" +
|
||||||
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
|
"/" +
|
||||||
);
|
props.hruid +
|
||||||
const groups = groupsQueryResult.data.value?.groups as GroupDTO[];
|
"/" +
|
||||||
const group = groups?.find((group) => group.assignment === assignment?.id) as GroupDTO;
|
currentNode.value?.language +
|
||||||
const questionData: QuestionData = {
|
"/" +
|
||||||
author: authService.authState.user?.profile.preferred_username,
|
currentNode.value?.learningobjectHruid,
|
||||||
content: questionInput.value,
|
);
|
||||||
inGroup: group, //TODO: POST response zegt dat dit null is???
|
|
||||||
};
|
|
||||||
if (questionInput.value !== "") {
|
|
||||||
createQuestionMutation.mutate(questionData, {
|
|
||||||
onSuccess: async () => {
|
|
||||||
questionInput.value = ""; // Clear the input field after submission
|
|
||||||
await getQuestionsQuery.refetch(); // Reload the questions
|
|
||||||
},
|
|
||||||
onError: (_) => {
|
|
||||||
// TODO Handle error
|
|
||||||
// - console.error(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -236,7 +215,7 @@
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-itemF
|
<v-list-item
|
||||||
v-if="
|
v-if="
|
||||||
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
|
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
|
||||||
"
|
"
|
||||||
|
@ -248,7 +227,7 @@
|
||||||
v-model="forGroupQueryParam"
|
v-model="forGroupQueryParam"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-list-itemF>
|
</v-list-item>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<div>
|
<div>
|
||||||
<using-query-result
|
<using-query-result
|
||||||
|
@ -329,25 +308,6 @@
|
||||||
v-if="currentNode"
|
v-if="currentNode"
|
||||||
></learning-object-view>
|
></learning-object-view>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
|
|
||||||
class="question-box"
|
|
||||||
>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="question : ..."
|
|
||||||
class="question-input"
|
|
||||||
v-model="questionInput"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="submitQuestion"
|
|
||||||
class="send-button"
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navigation-buttons-container">
|
<div class="navigation-buttons-container">
|
||||||
<v-btn
|
<v-btn
|
||||||
prepend-icon="mdi-chevron-left"
|
prepend-icon="mdi-chevron-left"
|
||||||
|
@ -367,15 +327,43 @@
|
||||||
</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 }"
|
||||||
>
|
>
|
||||||
|
<v-divider :thickness="6"></v-divider>
|
||||||
|
<div class="question-header">
|
||||||
|
<span class="question-title">{{ t("questions") }}</span>
|
||||||
|
<span class="discussion-link-text">
|
||||||
|
{{ t("view-questions") }}
|
||||||
|
<router-link :to="discussionLink">
|
||||||
|
{{ t("discussions") }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.question-title {
|
||||||
|
color: #0e6942;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bolder;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.question-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
.learning-path-title {
|
.learning-path-title {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
@ -414,45 +402,6 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
z-index: 2; /* Less than modals/popups */
|
z-index: 2; /* Less than modals/popups */
|
||||||
}
|
}
|
||||||
.question-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discussion-link {
|
.discussion-link {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue