Skip to content
38 changes: 29 additions & 9 deletions public/locales/fr/ues.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// For more information, check the common.json.ts file

export default {
'browser': 'Guide des UEs',
browser: 'Guide des UEs',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tu veux pas garder les ' ?

'filter.search': 'Recherche dans le guide des UEs',
'filter.search.title': 'Recherche dans le guide des UEs',
'filter.creditType.title': 'Type de crédits',
Expand All @@ -11,31 +11,51 @@ export default {
'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.workTime': 'Temps de travail',
'detailed.workTime.project': 'Projet',
'detailed.semester': "Prochain semestre d'ouverture",
'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 d'enseignement",
'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.branchOptions': 'Niveau',
'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 de {{authorFirstName}} {{authorLastName}} sur l'UE {{ue}} au semestre {{semester}} ({{date}})",
'detailed.comments.resume': "Commentaire sur l'UE {{ue}}",
'detailed.comments.semester': 'Semestre {{semester}}',
'detailed.comments.updatedAt': 'Mis à jour le {{date}}',
'detailed.comments.resume.anonymous': "Commentaire anonyme sur l'UE {{ue}} au semestre {{semester}} ({{date}})",
'detailed.comments.answers.answerTitle': 'Répondre dans ce fil de discussion',
'detailed.comments.answers.answerButton': 'Envoyer',
'detailed.comments.answers.answerEntry': 'Tapez votre réponse ici',
Expand Down
18 changes: 14 additions & 4 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StatusCodes } from 'http-status-codes';
export enum ResponseError {
'not_json',
'timeout',
'aborted',
'unknown',
}

Expand Down Expand Up @@ -65,8 +66,10 @@ export class ResponseHandler<T, R = undefined> {
: () => R | void;
}> = {};
private readonly promise: Promise<R | void>;
public readonly abortController: AbortController;

constructor(rawResponse: Promise<APIResponse<T>>) {
constructor(rawResponse: Promise<APIResponse<T>>, abortController: AbortController) {
this.abortController = abortController;
this.promise = rawResponse.then((response) => {
if ('error' in response) {
return this.handlers[response.error]
Expand Down Expand Up @@ -103,7 +106,7 @@ export class ResponseHandler<T, R = undefined> {
* @param rawResponse The raw response from the API.
*/
function formatResponse<T>(rawResponse: RawResponseType<T>): 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;
Expand Down Expand Up @@ -132,6 +135,7 @@ async function internalRequestAPI<RequestType>(
timeoutMillis: number,
version: string,
isFile: true,
abortController: AbortController,
): Promise<APIResponse<Blob>>;
async function internalRequestAPI<RequestType, ResponseType>(
method: string,
Expand All @@ -140,6 +144,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
timeoutMillis: number,
version: string,
isFile: boolean,
abortController: AbortController,
): Promise<APIResponse<ResponseType>>;
async function internalRequestAPI<RequestType, ResponseType>(
method: string,
Expand All @@ -148,14 +153,14 @@ async function internalRequestAPI<RequestType, ResponseType>(
timeoutMillis: number,
version: string,
isFile: boolean,
abortController: AbortController,
): Promise<APIResponse<ResponseType | Blob>> {
// Generate headers
const headers = new Headers();
headers.append('Authorization', authorizationToken ? `Bearer ${authorizationToken}` : '');
if (!isFile) headers.append('Content-Type', 'application/json');

// Add timeout to the request
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort();
}, timeoutMillis);
Expand Down Expand Up @@ -194,6 +199,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error === 'query-update') return { error: ResponseError.aborted };
if (error instanceof Error && error.name === 'AbortError') {
console.error('Request timed out');
return { error: ResponseError.timeout };
Expand Down Expand Up @@ -241,7 +247,11 @@ function requestAPI<RequestType, ResponseType>(
isFile = false,
}: { timeoutMillis?: number; version?: string; isFile?: boolean } = {},
): ResponseHandler<ResponseType> {
return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile));
const abortController = new AbortController();
return new ResponseHandler(
internalRequestAPI(method, route, body, timeoutMillis, version, isFile, abortController),
abortController,
);
}

// Set the authorization header with the given token for next requests
Expand Down
77 changes: 45 additions & 32 deletions src/api/pagination.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type PaginationHook<T> = {
total: number;
updateFilters: (query: Record<string, string>) => void;
fetchNextItems: () => void;
invalidateItems: () => void;
};

/**
Expand All @@ -15,51 +16,63 @@ type PaginationHook<T> = {
export function usePaginationLoader<T>(path: string): PaginationHook<T> {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const searching = useRef(false);
const lastSearch = useRef<Record<string, string>>({});
const itemsPerPage = useRef(0);
const pageIndex = useRef(1);

const api = useAPI();

const updateItems = (query: Record<string, string>) => {
if (searching.current) return;
else searching.current = true;

const invalidateItems = () => {
searching.current = true;
setItems([...Array(itemsPerPage.current || 20).fill(null)]);
};

const updateItems = (query: Record<string, string>) => {
const { page, ...queryData } = query;
api
.get<Pagination<T>>(`${path}?${new URLSearchParams(query)}`)
.on('success', (body) => {
setTotal(body.itemCount);
setItems(body.items);
itemsPerPage.current = body.itemsPerPage;
lastSearch.current = queryData;
searching.current = false;
pageIndex.current = (page && Number(page)) || 1;
})
.on('error', () => (searching.current = false))
.on('failure', () => (searching.current = false));
if (abortController?.signal?.aborted === false) abortController.abort('query-update');
setAbortController(
api
.get<Pagination<T>>(`${path}?${new URLSearchParams(query)}`)
.on('success', (body) => {
setTotal(body.itemCount);
setItems(body.items);
itemsPerPage.current = body.itemsPerPage;
lastSearch.current = queryData;
searching.current = false;
pageIndex.current = (page && Number(page)) || 1;
})
.on('error', () => (searching.current = false))
.on('failure', () => (searching.current = false)).abortController,
);
};

const fetchNextPage = () => {
if (searching.current || items.length >= total) return;
else searching.current = true;

if (items.length >= total) return;
searching.current = true;
const pendingItemCount = Math.min(itemsPerPage.current, total - items.length);
setItems((prev) => [...prev, ...Array(pendingItemCount).fill(null)]);
api
.get<Pagination<T>>(
`${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`,
)
.on('success', (body) => {
pageIndex.current++;
setTotal(body.itemCount);
setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]);
searching.current = false;
})
.on('error', () => (searching.current = false))
.on('failure', () => (searching.current = false));
setAbortController(
api
.get<Pagination<T>>(
`${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`,
)
.on('success', (body) => {
pageIndex.current++;
setTotal(body.itemCount);
setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]);
searching.current = false;
})
.on('error', () => (searching.current = false))
.on('failure', () => (searching.current = false)).abortController,
);
};
return {
items,
total,
updateFilters: updateItems,
fetchNextItems: fetchNextPage,
invalidateItems,
};
return { items, total, updateFilters: updateItems, fetchNextItems: fetchNextPage };
}
78 changes: 54 additions & 24 deletions src/api/ue/ue.interface.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
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<string>;
languages: Array<string>;
minors: Array<string>;
};
credits: Array<{
credits: number;
category: {
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;
Expand All @@ -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<string>;
requirements: Array<string>;
};
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;
};
Expand Down
Loading