diff --git a/public/locales/fr/ues.json.ts b/public/locales/fr/ues.json.ts index 1793a56..b7e1260 100644 --- a/public/locales/fr/ues.json.ts +++ b/public/locales/fr/ues.json.ts @@ -2,7 +2,63 @@ // For more information, check the common.json.ts file export default { - 'browser': 'Guide des UEs', + browser: 'Guide des UEs', + 'filter.search': 'Recherche dans le guide des UEs', + 'filter.search.title': 'Recherche dans le guide des UEs', + 'filter.creditType.title': 'Type de crédits', + 'filter.branch.title': 'Branche', + 'filter.branchOption.title': 'Filière', + 'filter.semester.title': 'Semestre', + 'filter.semester.autumn': 'Automne', + 'filter.semester.spring': 'Printemps', + 'overview.credits': 'Crédits', + 'overview.minors': 'Mineures', + 'overview.taughtIn': 'Enseigné en', + 'overview.requirements': 'prérequis', + 'detailed.siepLink': 'Voir sur le SIEP', + 'detailed.inscriptionCode': "Code d'inscription", + 'detailed.inscriptionCode.copy': 'Copier dans le presse-papier', + 'detailed.inscriptionCode.empty': "Le code d'inscription n'a pas encore été publié", + 'detailed.worktime.hour': 'h', + 'detailed.worktime.cm': 'CM', + 'detailed.worktime.cm.tooltip': 'Cours Magistral', + 'detailed.worktime.td': 'TD', + 'detailed.worktime.td.tooltip': 'Travaux dirigés', + 'detailed.worktime.tp': 'TP', + 'detailed.worktime.tp.tooltip': 'Travaux pratiques', + 'detailed.worktime.the': 'THE', + 'detailed.worktime.the.tooltip': 'Travail hors encadrement', + 'detailed.worktime.project': 'Projet', + 'detailed.worktime.internship': 'stage', + 'detailed.semester': 'Ouvert en', + 'detailed.semester.none': "Pas de semestre d'ouverture prévu", + 'detailed.description': 'Description', + 'detailed.program': 'Programme', + 'detailed.objectives': 'Objectifs', + 'detailed.taughtIn': 'Langue', + 'detailed.minors': 'Mineurs', + 'detailed.minors.none': 'Cette UE ne compte dans aucun mineur', + 'detailed.credits': 'Crédits', + 'detailed.noWorkingTimeInfo': 'Aucune information sur le temps de travail', + 'detailed.dfpdata': 'Inscription', + 'detailed.branchOptions': 'Profil', + 'detailed.requirements': 'Pré-requis', + 'detailed.requirements.none': 'Aucun pré-requis', + 'detailed.rates.title': 'Avis des étudiants', + 'detailed.rates.error': 'Une erreur est survenue lors du chargement des avis', + 'detailed.rates.delete': 'Supprimer mon avis', + 'detailed.comments.loginRequired': 'Connexion requise pour voir les commentaires', + 'detailed.comments.author.anonymous': 'Anonyme', + 'detailed.comments.author.deleted': 'Utilisateur supprimé', + 'detailed.comments.writtenDate': 'Écrit le {{date}}', + 'detailed.comments.conversation.see': 'Voir la conversation ({{responseCount}} réponses)', + 'detailed.comments.conversation.see.empty': 'Répondre', + 'detailed.comments.resume': "Commentaire sur l'UE {{ue}}", + 'detailed.comments.semester': 'Semestre {{semester}}', + 'detailed.comments.updatedAt': 'Mis à jour le {{date}}', + 'detailed.comments.upvotes': '{{count}} upvotes', + 'detailed.comments.write': 'Écrire un commentaire sur cette UE', + 'detailed.comments.write.send': 'Envoyer le commentaire', 'detailed.annals.title': 'Annales', 'detailed.annals.empty': 'Aucune annale disponible', 'detailed.annals.entry.author': 'déposée par', @@ -16,44 +72,13 @@ export default { 'detailed.annals.send.placeholder': 'Dépose le sujet ici', 'detailed.annals.send.back': 'Retour', 'detailed.annals.send.sumbit': 'Valider', - 'detailed.branchOptions': 'Niveau', - 'detailed.comments.loginRequired': 'Connexion requise pour voir les commentaires', - 'detailed.comments.author.anonymous': 'Anonyme', - 'detailed.comments.author.deleted': 'Utilisateur supprimé', - 'detailed.comments.writtenDate': 'Écrit le {{date}}', - 'detailed.comments.conversation.see': 'Voir la conversation ({{responseCount}} réponses)', - 'detailed.comments.conversation.see.empty': 'Répondre', 'detailed.comments.summary': "Commentaire de {{authorFirstName}} {{authorLastName}} sur l'UE {{ue}} au semestre {{semester}} ({{date}})", 'detailed.comments.summary.anonymous': "Commentaire anonyme sur l'UE {{ue}} au semestre {{semester}} ({{date}})", - 'detailed.comments.updatedAt': 'Mis à jour le {{date}}', 'detailed.comments.answers.send': 'Envoyer', 'detailed.comments.answers.placeholder': 'Tapez votre réponse ici', 'detailed.comments.answers.title': 'Répondre dans ce fil de discussion', - 'detailed.comments.upvotes': '{{count}} upvotes', - 'detailed.comments.write': 'Écrire un commentaire sur cette UE', 'detailed.comments.write.anonymous': 'Commenter anonymement', - 'detailed.comments.write.send': 'Envoyer le commentaire', - 'detailed.credits': 'Crédits', - 'detailed.description': 'Description', - 'detailed.inscriptionCode': "Code d'inscription", - 'detailed.minors': 'Mineurs', - 'detailed.noWorkingTimeInfo': 'Aucune information sur le temps de travail', - 'detailed.objectives': 'Objectifs', - 'detailed.program': 'Programme', - 'detailed.requirements': 'Pré-requis', - 'detailed.requirements.none': 'Aucun pré-requis', - 'detailed.semester': "Prochain semestre d'ouverture", - 'detailed.semester.none': "Pas de semestre d'ouverture prévu", - 'detailed.taughtIn': "Langue d'enseignement", 'detailed.workTime': 'Temps de travail', 'detailed.workTime.project': 'Projet', - 'filter.branch.title': 'Branche', - 'filter.branchOption.title': 'Filière', - 'filter.creditType.title': 'Type de crédits', - 'filter.search': 'Recherche dans le guide des UEs', - 'filter.search.title': 'Recherche dans le guide des UEs', - 'filter.semester.autumn': 'Automne', - 'filter.semester.spring': 'Printemps', - 'filter.semester.title': 'Semestre', } as const; diff --git a/src/api/api.ts b/src/api/api.ts index 1c97ca1..e7a45a0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -11,8 +11,8 @@ import { ApiError } from '@/api/api.interface'; export const enum ResponseFailureReason { not_json, timeout, + aborted, unknown, - abort, } /** @@ -94,6 +94,7 @@ export class ResponseHandler = { fall { private readonly handlers = { fallback: () => undefined } as R; private readonly promise: Promise any ? R[keyof R] : never>>; + // public readonly abortController: AbortController; constructor( rawResponse: Promise>, @@ -167,7 +168,7 @@ export class ResponseHandler = { fall * Fires ResponseError.timeout handler or falls back to failure handler. */ abort() { - this.abortController.abort(ResponseFailureReason.abort); + this.abortController.abort(ResponseFailureReason.aborted); } async toPromise(): Promise any ? R[keyof R] : never>>> { @@ -180,7 +181,7 @@ export class ResponseHandler = { fall * @param rawResponse The raw response from the API. */ function formatResponse(rawResponse: RawResponseType): T { - if (typeof rawResponse === 'string' && !isNaN(Date.parse(rawResponse))) { + if (typeof rawResponse === 'string' && rawResponse.search(/^\d{4}(?:-\d{2}){2}T(?:\d{2}:){2}\d{2}\.\d{3}Z$/) === 0) { return new Date(rawResponse) as T; } else if (Array.isArray(rawResponse)) { return rawResponse.map(formatResponse) as T; @@ -201,8 +202,9 @@ function formatResponse(rawResponse: RawResponseType): T { * @param timeoutMillis The timeout of the request. * @param version The version of the API to use : v1, v2, ... * @param isFile If what we are sending/fetching is a file. + * @param applicationId Id of the application making the request. + * @param abortController AbortController to add to the request. */ - async function internalRequestAPI( method: 'GET', route: string, @@ -279,6 +281,7 @@ async function internalRequestAPI( } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { + if (error === 'query-update') return { failureReason: ResponseFailureReason.aborted }; if (error instanceof Error && error.name === 'AbortError') { console.error('Request timed out'); return { failureReason: ResponseFailureReason.timeout }; @@ -289,8 +292,8 @@ async function internalRequestAPI( console.error('An error occurred when making a request to the API'); } - if (abortController.signal.reason === ResponseFailureReason.abort) - return { failureReason: ResponseFailureReason.abort }; + if (abortController.signal.reason === ResponseFailureReason.aborted) + return { failureReason: ResponseFailureReason.aborted }; return { failureReason: ResponseFailureReason.unknown }; } finally { @@ -424,7 +427,7 @@ function applyDefaultHandler(handler: ResponseHandler, setNotFound: () => .on('error', () => { toast.error('Request resulted in an error'); }) - .on(404, setNotFound); + .on(404, () => setNotFound()); } type DefaultResponseHandlerType = ResponseHandler< diff --git a/src/api/assos/searchAssos.hook.ts b/src/api/assos/searchAssos.hook.ts index 0b7e378..6c12984 100644 --- a/src/api/assos/searchAssos.hook.ts +++ b/src/api/assos/searchAssos.hook.ts @@ -1,16 +1,6 @@ -import { useState } from 'react'; -import { useAPI } from '@/api/api'; -import { Pagination } from '@/api/api.interface'; import { AssoOverview } from '@/api/assos/asso.interface'; +import { PaginationHook, usePaginationLoader } from '@/api/pagination.hook'; -export function useAssos(): [AssoOverview[], number, (query: Record) => void] { - const [assos, setAssos] = useState([]); - const [total, setTotal] = useState(0); - const api = useAPI(); - const updateAssos = async (query: Record) => - api.get>(`/assos?${new URLSearchParams(query)}`).on('success', (body) => { - setAssos(body.items); - setTotal(body.itemCount); - }); - return [assos, total, updateAssos]; +export function useAssos(): PaginationHook { + return usePaginationLoader('/assos'); } diff --git a/src/api/comment/fetchComments.ts b/src/api/comment/fetchComments.ts index 074ac37..5b098b2 100644 --- a/src/api/comment/fetchComments.ts +++ b/src/api/comment/fetchComments.ts @@ -3,7 +3,10 @@ import { Pagination } from '@/api/api.interface'; import { Comment } from '@/api/comment/comment.interface'; import { useAPI } from '@/api/api'; -export default function useComments(code: string): [Comment[] | null, (index: number, comment: Comment) => void] { +type AddComment = (comment: Comment) => void; +type UpdateComment = (index: number, comment: Comment) => void; + +export default function useComments(code: string): [Comment[] | null, AddComment, UpdateComment] { const [comments, setComments] = useState(null); const api = useAPI(); useEffect(() => { @@ -11,6 +14,17 @@ export default function useComments(code: string): [Comment[] | null, (index: nu }, [code]); return [ comments, + (comment) => + setComments((prev) => { + if (!prev) return [comment]; + let insertIndex = prev.findIndex( + (prevComment) => + prevComment.upvotes < comment.upvotes || + (prevComment.upvotes === comment.upvotes && prevComment.createdAt < comment.createdAt), + ); + if (insertIndex === -1) insertIndex = prev.length; + return [...prev.slice(0, insertIndex), comment, ...prev.slice(insertIndex)]; + }), (index, comment) => setComments((prev) => (prev ? [...prev.slice(0, index), comment, ...prev.slice(index + 1)] : null)), ]; diff --git a/src/api/comment/sendComment.ts b/src/api/comment/sendComment.ts index b9752a6..8cb1584 100644 --- a/src/api/comment/sendComment.ts +++ b/src/api/comment/sendComment.ts @@ -7,6 +7,6 @@ export interface SendCommentRequestDto { isAnonymous: boolean; } -export default async function sendComment(api: API, ueCode: string, body: string, isAnonymous: boolean) { +export function sendComment(api: API, ueCode: string, body: string, isAnonymous: boolean) { return api.post(`/ue/comments`, { ueCode, body, isAnonymous }); } diff --git a/src/api/pagination.hook.ts b/src/api/pagination.hook.ts index 26bf03f..0b918bc 100644 --- a/src/api/pagination.hook.ts +++ b/src/api/pagination.hook.ts @@ -2,11 +2,12 @@ import { useState, useRef } from 'react'; import { Abortable, ResponseFailureReason, useAPI } from './api'; import { Pagination } from './api.interface'; -type PaginationHook = { +export type PaginationHook = { items: (T | null)[]; total: number; updateFilters: (query: Record, page?: number) => void; fetchNextItems: () => void; + invalidateItems: () => void; }; /** @@ -22,6 +23,10 @@ export function usePaginationLoader(path: string): PaginationHook { const api = useAPI(); + const invalidateItems = () => { + setItems([...Array(itemsPerPage.current || 20).fill(null)]); + }; + const updateItems = (query: Record, page?: number) => { if (handler.current) handler.current.abort(); @@ -38,11 +43,12 @@ export function usePaginationLoader(path: string): PaginationHook { handler.current = undefined; }) .on('fallback', () => (handler.current = undefined)) - .on(ResponseFailureReason.abort, () => {}); // Hide toast error here as we have aborted the request + .on(ResponseFailureReason.aborted, () => {}); // Hide toast error here as we have aborted the request }; const fetchNextPage = () => { if (handler.current) return; + if (items.length >= total) return; const pendingItemCount = Math.min(itemsPerPage.current, total - items.length); setItems((prev) => [...prev, ...Array(pendingItemCount).fill(null)]); @@ -59,5 +65,11 @@ export function usePaginationLoader(path: string): PaginationHook { .on('fallback', () => (handler.current = undefined)) .on(ResponseFailureReason.timeout, () => {}); // Hide toast error here as we might have aborted the request }; - return { items, total, updateFilters: updateItems, fetchNextItems: fetchNextPage }; + return { + items, + total, + updateFilters: updateItems, + fetchNextItems: fetchNextPage, + invalidateItems, + }; } diff --git a/src/api/ue/ue.interface.ts b/src/api/ue/ue.interface.ts index 9e487ec..0010275 100644 --- a/src/api/ue/ue.interface.ts +++ b/src/api/ue/ue.interface.ts @@ -1,15 +1,10 @@ export interface UE { code: string; - inscriptionCode: string; name: string; info: { - requirements: Array<{ code: string }>; - comment: string; - degree: string; - languages: string; - minors: string; - objectives: string; - program: string; + requirements: Array; + languages: Array; + minors: Array; }; credits: Array<{ credits: number; @@ -17,14 +12,14 @@ export interface UE { code: string; name: string; }; - }>; - branchOption: Array<{ - code: string; - name: string; - branch: { + branchOption: Array<{ code: string; name: string; - }; + branch: { + code: string; + name: string; + }; + }>; }>; openSemester: Array<{ code: string; @@ -33,16 +28,51 @@ export interface UE { }>; } -export interface DetailedUE extends UE { - validationRate: number; - workTime: { - cm: number; - td: number; - tp: number; - the: number; - project: number; - internship: number; - }; +export interface DetailedUE { + code: string; + creationYear: number; + updateYear: number; + ueofs: Array<{ + name: string; + code: string; + siepId: string; + inscriptionCode: string; + credits: Array<{ + credits: number; + category: { + code: string; + name: string; + }; + branchOptions: Array<{ + code: string; + name: string; + branch: { + code: string; + name: string; + }; + }>; + }>; + info: { + objectives: string; + program: string; + language: string; + minors: Array; + requirements: Array; + }; + openSemester: Array<{ + code: string; + start: Date; + end: Date; + }>; + workTime: { + cm: number; + td: number; + tp: number; + the: number; + project: boolean; + internship: number; + }; + }>; starVotes: { [criterionId: string]: number; }; diff --git a/src/app/(wrapper)/assos/page.tsx b/src/app/(wrapper)/assos/page.tsx index adc9960..99dc358 100644 --- a/src/app/(wrapper)/assos/page.tsx +++ b/src/app/(wrapper)/assos/page.tsx @@ -30,12 +30,16 @@ const assoFilters = Object.freeze({ export default function AssoPage() { const { t } = useAppTranslation(); - const [assos, totalAssosCount, updateAssos] = useAssos(); + const { items: assos, total: totalAssosCount, updateFilters: updateAssos, invalidateItems } = useAssos(); return (

{t('assos:browser')}

- filtersData={assoFilters} updateSearch={updateAssos} /> + + filtersData={assoFilters} + updateSearch={updateAssos} + invalidateItems={invalidateItems} + />
{ if (body.status === 'no_account') { setRegisterToken(body.token); + router.replace('/login'); return; } if (body.status === 'no_api_key') { @@ -61,6 +62,9 @@ export default function LoginPage() { markPageLoaded(); } }, [internallyLoaded]); + if (ticket && !registerToken) { + return null; + } if (registerToken) { return ( void) {} + public send(): void { + this.updater = !this.updater; + this._send(); + } + public clear(): void { + this.newComment = null; + } +} + +export function useCommentsCommuniquer(): CommentsCommuniquer { + const [, setUpdater] = useState(false); + const commentsCommuniquer = useRef( + new CommentsCommuniquer(() => setUpdater((updater) => !updater)), + ); + return commentsCommuniquer.current; +} + function CommentEditorFooter(comment: Comment, onUpdate: (text: string, anonymous: boolean) => void) { return function CommentEditorFooter({ text, disable }: { text: string; disable: () => void }) { const [anonymous, setAnonymous] = useState(comment.isAnonymous); @@ -86,11 +106,16 @@ function CommentFooter( }; } -export default function Comments({ code }: { code: string }) { +export default function Comments({ code, communiquer }: { code: string; communiquer: CommentsCommuniquer }) { const { t } = useAppTranslation(); - const [comments, setComment] = useComments(code); + const [comments, addComment, setComment] = useComments(code); const user = useConnectedUser()!; const api = useAPI(); + useEffect(() => { + if (comments === null || !communiquer.newComment) return; + addComment(communiquer.newComment); + communiquer.clear(); + }, [communiquer.updater]); if (comments === null) { return ''; } diff --git a/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx b/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx index 8d7c042..67a3665 100644 --- a/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx +++ b/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import styles from '@/app/(wrapper)/ues/[code]/comments/[commentId]/style.module.scss'; -import { useParams } from 'next/navigation'; import { useUEComment } from '@/api/comment/getComment'; import useUe from '@/api/ue/fetchUe'; import { TFunction, useAppTranslation } from '@/lib/i18n'; @@ -13,7 +12,13 @@ import EditableText from '@/components/EditableText'; import { editCommentReply } from '@/api/commentReply/editCommentReply'; import { useAPI } from '@/api/api'; import { sendCommentReply } from '@/api/commentReply/sendCommentReply'; +import Link from '@/components/UI/Link'; import Page from '@/components/utilities/Page'; +import Clock from '@/icons/Clock'; +import Comment from '@/icons/Comment'; +import Enter from '@/icons/Enter'; +import User from '@/icons/User'; +import { useParams } from 'next/navigation'; function CommentEditorFooter(originalComment: string, onUpdate: (text: string) => void, t: TFunction) { return function CommentEditorFooter({ text, disable }: { text: string; disable: () => void }) { @@ -59,41 +64,68 @@ export default function CommentDetailsPage() { date: comment.createdAt.toLocaleDateString(), })} - {comment.updatedAt && ( -

- {t('ues:detailed.comments.updatedAt', { date: comment.updatedAt.toLocaleDateString() })} -

- )} +
+ {!comment.isAnonymous && ( +
+ + + {comment.author.firstName} {comment.author.lastName} + +
+ )} +
+ + {t('ues:detailed.comments.semester', { semester: comment.semester.code })} +
+
+ +
+
{t('ues:detailed.comments.writtenDate', { date: comment.createdAt.toLocaleDateString() })}
+ {comment.updatedAt && ( +
{t('ues:detailed.comments.updatedAt', { date: comment.updatedAt.toLocaleDateString() })}
+ )} +
+
+

{comment.body}

{comment.answers.map((answer, i) => (
-

- {answer.author - ? `${answer.author.firstName} ${answer.author.lastName}` - : t('ues:detailed.comments.author.deleted')} -

-

- {t('ues:detailed.comments.writtenDate', { date: answer.createdAt.toLocaleDateString() })} -

- { - const newAnswer = await editCommentReply(api, answer.id, body).toPromise(); - if (!newAnswer) return false; - setComment({ - ...comment, - answers: [...comment.answers.slice(0, i), newAnswer, ...comment.answers.slice(i + 1)], - }); - return true; - }, - t, - )} - enabled={answer.author.id === user.id} - /> +
+ +
+
+

+ {answer.author ? ( + + {answer.author.firstName} {answer.author.lastName} + + ) : ( + t('ues:detailed.comments.author.deleted') + )} +

+

+ {t('ues:detailed.comments.writtenDate', { date: answer.createdAt.toLocaleDateString() })} +

+ { + const newAnswer = await editCommentReply(api, answer.id, body).toPromise(); + if (!newAnswer) return false; + setComment({ + ...comment, + answers: [...comment.answers.slice(0, i), newAnswer, ...comment.answers.slice(i + 1)], + }); + return true; + }, + t, + )} + enabled={answer.author.id === user.id} + /> +
))}
diff --git a/src/app/(wrapper)/ues/[code]/comments/[commentId]/style.module.scss b/src/app/(wrapper)/ues/[code]/comments/[commentId]/style.module.scss index 385db2c..dc571b2 100644 --- a/src/app/(wrapper)/ues/[code]/comments/[commentId]/style.module.scss +++ b/src/app/(wrapper)/ues/[code]/comments/[commentId]/style.module.scss @@ -9,8 +9,19 @@ color: $ung-light-blue; } - & > .body { - margin-top: 1.5rem; + .meta { + margin: 1rem 0; + + svg { + width: 1em; + height: 1.1em; + vertical-align: bottom; + } + & > * { + display: flex; + flex-flow: row nowrap; + gap: 5px; + } } .updateDate { @@ -19,11 +30,47 @@ margin-top: 0.3rem; } - .thoughts { + .answerTitle { + margin-top: 3rem; + margin-bottom: 1rem; + color: $ung-dark-grey; + } + + p.body { + text-align: justify; + } + + .comments { + display: flex; + flex-flow: column nowrap; + gap: 1rem; + padding: 1rem 0; + .comment { - margin: 30px 0; + display: flex; + flex-flow: row nowrap; + gap: 1em; + padding: 0 1em; + + .sideIcon { + width: 12px; + height: 12px; + margin-top: 0.5em; + padding: 5px; + box-sizing: content-box; + border-radius: 50%; + border: 1px solid $navigation-item-tooltip-color; + + .answerIcon { + width: 12px; + height: 12px; + color: $ung-light-grey; + transform: scaleY(-1) translateY(25%); + } + } .author { + color: $ung-light-blue; font-weight: bold; font-size: 1.2rem; color: $ung-dark-grey; @@ -31,34 +78,12 @@ } .date { - font-weight: bold; + font-style: italic; color: $ung-light-grey; } .body { - margin-left: 1rem; - margin-top: 0.5rem; - padding-left: 0.7rem; - position: relative; - - &:after { - content: ''; - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 3px; - background: linear-gradient(90deg, $ung-dark-grey, rgba($ung-dark-grey, 0.5)); - } - - .button { - right: 0; - } - } - - &:last-child { - margin-bottom: 0; - //background-color: red; + text-align: justify; } } } @@ -84,4 +109,4 @@ position: relative; } } -} \ No newline at end of file +} diff --git a/src/app/(wrapper)/ues/[code]/page.tsx b/src/app/(wrapper)/ues/[code]/page.tsx index bf7c285..9a7a28f 100644 --- a/src/app/(wrapper)/ues/[code]/page.tsx +++ b/src/app/(wrapper)/ues/[code]/page.tsx @@ -2,32 +2,119 @@ import styles from './style.module.scss'; import { useParams } from 'next/navigation'; +import useAnnals from '@/api/annals/fetchAnnals'; +import useAnnalMetadata from '@/api/annals/fetchMetadata'; +import { useAPI } from '@/api/api'; +import { sendComment } from '@/api/comment/sendComment'; import useUe from '@/api/ue/fetchUe'; -import Comments from '@/app/(wrapper)/ues/[code]/Comments'; +import Comments, { useCommentsCommuniquer } from '@/app/(wrapper)/ues/[code]/Comments'; import { useAppTranslation } from '@/lib/i18n'; import { useUERateCriteria } from '@/module/constantData'; import { UERateCriterion } from '@/api/ueRate/ueRateCriterion.interface'; import Button from '@/components/UI/Button'; import useGetRate from '@/api/ueRate/getUERate'; -import doUERate from '@/api/ueRate/doUERate'; import deleteUERate from '@/api/ueRate/deleteUERate'; +import doUERate from '@/api/ueRate/doUERate'; import StarRating from '@/components/StarRating'; import TextArea from '@/components/UI/TextArea'; -import { useState } from 'react'; -import sendComment from '@/api/comment/sendComment'; -import { useAPI } from '@/api/api'; -import useAnnals from '@/api/annals/fetchAnnals'; -import useAnnalMetadata from '@/api/annals/fetchMetadata'; import ExamList from '@/components/ues/ExamList'; import ExamSender from '@/components/ues/ExamSender'; +import Link from '@/components/UI/Link'; +import Tooltip from '@/components/UI/Tooltip'; import Page from '@/components/utilities/Page'; -import { useLoggedIn } from '@/module/session'; +import { useAppSelector } from '@/lib/hooks'; +import { UserType, useLoggedIn } from '@/module/session'; +import { ReactNode, useState } from 'react'; +import { DetailedUE } from '@/api/ue/ue.interface'; + +function UeWorkTimes({ workTimes }: { workTimes: DetailedUE['ueofs'][number]['workTime'] }): ReactNode { + const { t } = useAppTranslation(); + if (!workTimes) return
{t('ues:detailed.noWorkingTimeInfo')}
; -export default function UEDetailsPage() { + return ( +
+ {workTimes.cm ? ( +
+
+ {workTimes.cm} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.cm')}
+
+
+ ) : ( + <> + )} + {workTimes.td ? ( +
+
+ {workTimes.td} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.td')}
+
+
+ ) : ( + <> + )} + {workTimes.tp ? ( +
+
+ {workTimes.tp} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.tp')}
+
+
+ ) : ( + <> + )} + {workTimes.the ? ( +
+
+ {workTimes.the} + {t('ues:detailed.worktime.hour')} +
+ +
{t('ues:detailed.worktime.the')}
+
+
+ ) : ( + <> + )} + {workTimes.internship ? ( +
+
+ {workTimes.internship} + {t('ues:detailed.worktime.hour')} +
+
{t('ues:detailed.worktime.internship')}
+
+ ) : ( + <> + )} + {workTimes.project ? ( +
+
{Number(workTimes.project)}
+
{t('ues:detailed.worktime.project')}
+
+ ) : ( + <> + )} +
+ ); +} + +export default function UeDetailsPage() { const params = useParams<{ code: string }>(); const { t } = useAppTranslation(); const logged = useLoggedIn(); + const type = useAppSelector((state) => state.session.user?.type); const [ue, refreshUE] = useUe(params.code); + const [ueofIndex, setUeofIndex] = useState(0); const criteria = useUERateCriteria(); const [myRates, setMyRates] = useGetRate(params.code); const [writingComment, setWritingComment] = useState(''); @@ -35,8 +122,9 @@ export default function UEDetailsPage() { const [annalTypes, annalSemesters] = useAnnalMetadata(params.code); const [isAnnalUploaderOpen, setAnnalUploaderOpen] = useState(false); const api = useAPI(); + const commentsCommuniquer = useCommentsCommuniquer(); - if (!ue || !criteria || myRates === undefined) { + if (!ue || (logged && !criteria) || myRates === undefined) { return false; } @@ -61,60 +149,103 @@ export default function UEDetailsPage() { refreshUE(); }; + const clipboardCopy = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const setUeof = (ueof: number | string) => { + if (typeof ueof === 'number') setUeofIndex(ueof); + }; + return ( -

{ue.code}

-

{ue.name}

+
+ {ue.ueofs.map((ueof, index) => ( +
setUeof(index)}> + {ueof.code} +
+ ))} +
+
+
+

{ue.code}

+

{ue.ueofs[ueofIndex].name}

+
+ +
{!isAnnalUploaderOpen ? ( <>
-
-

Informations générales

-

- {t('ues:detailed.description')} : {ue.info.comment} -
- {t('ues:detailed.program')} : {ue.info.program} -
- {t('ues:detailed.objectives')} : {ue.info.objectives} -
- {t('ues:detailed.taughtIn')} : {ue.info.languages} -
- {t('ues:detailed.minors')} : {ue.info.minors} -
- {t('ues:detailed.credits')} :{' '} - {ue.credits.map((credits) => `${credits.credits}${credits.category.code}`).join(', ')} -

+
{t('ues:detailed.program')}
+
{ue.ueofs[ueofIndex].info.program}
+
{t('ues:detailed.objectives')}
+
{ue.ueofs[ueofIndex].info.objectives}
+
{t('ues:detailed.taughtIn')}
+
{ue.ueofs[ueofIndex].info.language}
+
{t('ues:detailed.minors')}
+
+ {ue.ueofs[ueofIndex].info.minors.length + ? ue.ueofs[ueofIndex].info.minors.join(', ') + : t('ues:detailed.minors.none')}
-
-

{t('ues:detailed.workTime')}

- {ue.workTime ? ( - <> -

CM : {ue.workTime.cm}

-

TD : {ue.workTime.td}

-

TP : {ue.workTime.tp}

-

- {t('ues:detailed.workTime.project')} : {ue.workTime.project} -

-

THE : {ue.workTime.the}

- - ) : ( - t('ues:detailed.noWorkingTimeInfo') - )} +
{t('ues:detailed.credits')}
+
+ {Array.from( + new Set(ue.ueofs[ueofIndex].credits.map((credits) => `${credits.credits} ${credits.category.code}`)), + ).join(', ')}
-
-

Information pour faire l'UE

- {t('ues:detailed.semester')} :{' '} - {ue.openSemester.find((semester) => new Date(semester.start).getTime() > Date.now())?.code ?? - t('ues:detailed.semester.none')}{' '} -
- {t('ues:detailed.inscriptionCode')} : {ue.inscriptionCode}
- {t('ues:detailed.branchOptions')} : {ue.branchOption.map((branchOption) => branchOption.code).toString()}{' '} -
- {t('ues:detailed.requirements')} :{' '} - {ue.info.requirements.length === 0 - ? t('ues:detailed.requirements.none') - : ue.info.requirements.toString()} +
+
+

{t('ues:detailed.dfpdata')}

+
+
{t('ues:detailed.semester')}
+
+ {ue.ueofs[ueofIndex].openSemester + .filter((semester) => new Date(semester.end).getTime() > Date.now()) + .map((semester) => semester.code) + .join(', ') || t('ues:detailed.semester.none')}{' '} +
+
{t('ues:detailed.siepLink')}
+
+ + {ue.ueofs[ueofIndex].code} + +
+
{t('ues:detailed.inscriptionCode')}
+
+ {ue.ueofs[ueofIndex].inscriptionCode ? ( + + clipboardCopy(ue.ueofs[ueofIndex].inscriptionCode)}> + {ue.ueofs[ueofIndex].inscriptionCode} + + + ) : ( + t('ues:detailed.inscriptionCode.empty') + )} +
+
{t('ues:detailed.branchOptions')}
+
+ {ue.ueofs[ueofIndex].credits + .flatMap((credit) => credit.branchOptions.map((branchOption) => branchOption.code)) + .join(', ')} +
+
{t('ues:detailed.requirements')}
+
+ {ue.ueofs[ueofIndex].info.requirements.length === 0 + ? t('ues:detailed.requirements.none') + : ue.ueofs[ueofIndex].info.requirements + .map((req) => ( + + {req} + + )) + .reduce((prev, curr) => (prev.length ? [...prev, ', ', curr] : [curr]), [] as ReactNode[])} +
{annals && annalSemesters && annalTypes && ( @@ -125,48 +256,61 @@ export default function UEDetailsPage() { annalTypes={annalTypes} /> )} -
-

Avis des étudiants

-
- {Object.entries(ue.starVotes).map(([id, value]) => { - const myRate = myRates?.find((rate) => rate.criterionId === id); - return ( -
-

{criteria.find((criterion): criterion is UERateCriterion => criterion.id === id)?.name}

- - {myRates && ( - <> - onRate(id as string, !!myRate, rate)} - /> - {myRate && ( - - )} - - )} + {logged && (type === UserType.STUDENT || type === UserType.FORMER_STUDENT) && ( +
+

{t('ues:detailed.rates.title')}

+
c).join(' ')}> + {criteria && ue.starVotes + ? Object.entries(ue.starVotes).map(([id, value]) => { + const myRate = myRates?.find((rate) => rate.criterionId === id); + return ( +
+

+ {criteria.find((criterion): criterion is UERateCriterion => criterion.id === id)?.name} +

+ + {myRates && ( + <> + onRate(id as string, !!myRate, rate)} + /> + {myRate && ( + + )} + + )} +
+ ); + }) + : t('ues:detailed.rates.error')} +
+ {logged && (type === UserType.STUDENT || type === UserType.FORMER_STUDENT) ? ( + <> +
+ {t('ues:detailed.comments.write')} +