diff --git a/.gitignore b/.gitignore
index c78678b..c878d92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules/**
+.next/**
package-lock.json
\ No newline at end of file
diff --git a/package.json b/package.json
index 87bf758..2fd9ac0 100644
--- a/package.json
+++ b/package.json
@@ -9,14 +9,20 @@
"lint": "next lint"
},
"dependencies": {
+ "axios": "^1.6.7",
+ "framer-motion": "^11.0.3",
+ "moment": "^2.30.1",
+ "next": "14.1.0",
"react": "^18",
"react-dom": "^18",
- "next": "14.1.0"
+ "react-icons": "^5.0.1",
+ "sass": "^1.70.0",
+ "universal-cookie": "^7.0.1"
},
"devDependencies": {
- "typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
- "@types/react-dom": "^18"
+ "@types/react-dom": "^18",
+ "typescript": "^5"
}
}
diff --git a/src/app/api/groups/route.ts b/src/app/api/groups/route.ts
new file mode 100644
index 0000000..c308571
--- /dev/null
+++ b/src/app/api/groups/route.ts
@@ -0,0 +1,50 @@
+import promos, { Campus } from "@/constants/promos";
+import axios from "axios";
+import moment from "moment";
+import { type NextRequest, NextResponse } from "next/server";
+
+type PlanningEvent = {
+ resourceId: string;
+ start: string;
+ end: string;
+ title: string;
+ numero: string;
+ nomADE: string;
+ salle: string;
+};
+
+export async function GET(req: NextRequest) {
+ const reqUrl = new URL(req.nextUrl.toString());
+
+ if (!reqUrl.searchParams.get("promo")) {
+ return NextResponse.json({ message: "No promo id entered." });
+ }
+
+ const promoId = parseInt(reqUrl.searchParams.get("promo")!);
+
+ if (isNaN(promoId)) {
+ return NextResponse.json({ message: "Promo id is not a number." });
+ }
+
+ const startDate = moment().startOf("year");
+ const endDate = moment().endOf("year").add(1, "year");
+ const promo = promos[promoId];
+
+ try {
+ if (promo.campus === Campus.Senart) return NextResponse.json(["0"]);
+
+ const url = new URL("http://www.iut-fbleau.fr/EDT/consulter/ajax/ep.php");
+ url.searchParams.set("p", promo.promoId);
+ url.searchParams.set("start", startDate.toJSON());
+ url.searchParams.set("end", endDate.toJSON());
+
+ const response = await axios.get(url.toString());
+ const data: PlanningEvent[] = response.data;
+
+ return NextResponse.json(
+ [...new Set(data.map((event) => event.numero))].sort()
+ );
+ } catch {
+ return NextResponse.json({ message: "An unexpected error occured." });
+ }
+}
diff --git a/src/app/api/planning/route.ts b/src/app/api/planning/route.ts
new file mode 100644
index 0000000..adbb649
--- /dev/null
+++ b/src/app/api/planning/route.ts
@@ -0,0 +1,70 @@
+import { type NextRequest, NextResponse } from "next/server";
+import moment from "moment";
+import promos, { Campus } from "@/constants/promos";
+import fetchFontainebleau from "@/utils/fetchFontainebleau";
+import fetchSenart from "@/utils/fetchSenart";
+
+export async function GET(req: NextRequest) {
+ const reqUrl = new URL(req.nextUrl.toString());
+
+ if (!reqUrl.searchParams.get("start")) {
+ return NextResponse.json({ message: "No start date entered." });
+ }
+
+ if (!reqUrl.searchParams.get("end")) {
+ return NextResponse.json({ message: "No end date entered." });
+ }
+
+ if (!reqUrl.searchParams.get("promo")) {
+ return NextResponse.json({ message: "No promo id entered." });
+ }
+
+ if (!reqUrl.searchParams.get("grp")) {
+ return NextResponse.json({ message: "No group id entered." });
+ }
+
+ const startDate = moment(reqUrl.searchParams.get("start"));
+ const endDate = moment(reqUrl.searchParams.get("end"));
+ const promoId = parseInt(reqUrl.searchParams.get("promo")!);
+ const groupId = parseInt(reqUrl.searchParams.get("grp")!);
+
+ if (!startDate.isValid()) {
+ return NextResponse.json({ message: "Start date is not valid." });
+ }
+
+ if (!endDate.isValid()) {
+ return NextResponse.json({ message: "End date is not valid." });
+ }
+
+ if (startDate.isAfter(endDate)) {
+ return NextResponse.json({ message: "Start date is after end date." });
+ }
+
+ if (isNaN(promoId)) {
+ return NextResponse.json({ message: "Promo id is not a number." });
+ }
+
+ if (isNaN(groupId)) {
+ return NextResponse.json({ message: "Group id is not a number." });
+ }
+
+ const promo = promos[promoId];
+
+ try {
+ switch (promo.campus) {
+ case Campus.Fontainebleau:
+ return NextResponse.json(
+ await fetchFontainebleau(promo, groupId, startDate, endDate)
+ );
+
+ case Campus.Senart:
+ return NextResponse.json(await fetchSenart(promo, startDate, endDate));
+
+ default: {
+ return NextResponse.json({ message: "Promo campus is not valid." });
+ }
+ }
+ } catch {
+ return NextResponse.json({ message: "An unexpected error occured." });
+ }
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
index 718d6fe..176fc40 100644
Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ
diff --git a/src/app/globals.css b/src/app/globals.css
deleted file mode 100644
index f4bd77c..0000000
--- a/src/app/globals.css
+++ /dev/null
@@ -1,107 +0,0 @@
-:root {
- --max-width: 1100px;
- --border-radius: 12px;
- --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
- "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
- "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
-
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 214, 219, 220;
- --background-end-rgb: 255, 255, 255;
-
- --primary-glow: conic-gradient(
- from 180deg at 50% 50%,
- #16abff33 0deg,
- #0885ff33 55deg,
- #54d6ff33 120deg,
- #0071ff33 160deg,
- transparent 360deg
- );
- --secondary-glow: radial-gradient(
- rgba(255, 255, 255, 1),
- rgba(255, 255, 255, 0)
- );
-
- --tile-start-rgb: 239, 245, 249;
- --tile-end-rgb: 228, 232, 233;
- --tile-border: conic-gradient(
- #00000080,
- #00000040,
- #00000030,
- #00000020,
- #00000010,
- #00000010,
- #00000080
- );
-
- --callout-rgb: 238, 240, 241;
- --callout-border-rgb: 172, 175, 176;
- --card-rgb: 180, 185, 188;
- --card-border-rgb: 131, 134, 135;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --foreground-rgb: 255, 255, 255;
- --background-start-rgb: 0, 0, 0;
- --background-end-rgb: 0, 0, 0;
-
- --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
- --secondary-glow: linear-gradient(
- to bottom right,
- rgba(1, 65, 255, 0),
- rgba(1, 65, 255, 0),
- rgba(1, 65, 255, 0.3)
- );
-
- --tile-start-rgb: 2, 13, 46;
- --tile-end-rgb: 2, 5, 19;
- --tile-border: conic-gradient(
- #ffffff80,
- #ffffff40,
- #ffffff30,
- #ffffff20,
- #ffffff10,
- #ffffff10,
- #ffffff80
- );
-
- --callout-rgb: 20, 20, 20;
- --callout-border-rgb: 108, 108, 108;
- --card-rgb: 100, 100, 100;
- --card-border-rgb: 200, 200, 200;
- }
-}
-
-* {
- box-sizing: border-box;
- padding: 0;
- margin: 0;
-}
-
-html,
-body {
- max-width: 100vw;
- overflow-x: hidden;
-}
-
-body {
- color: rgb(var(--foreground-rgb));
- background: linear-gradient(
- to bottom,
- transparent,
- rgb(var(--background-end-rgb))
- )
- rgb(var(--background-start-rgb));
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-@media (prefers-color-scheme: dark) {
- html {
- color-scheme: dark;
- }
-}
diff --git a/src/app/globals.scss b/src/app/globals.scss
new file mode 100644
index 0000000..919ad98
--- /dev/null
+++ b/src/app/globals.scss
@@ -0,0 +1,5 @@
+* {
+ padding: 0;
+ margin: 0;
+ box-sizing: border-box;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3314e47..f631f92 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,22 +1,51 @@
import type { Metadata } from "next";
-import { Inter } from "next/font/google";
-import "./globals.css";
+import { Rubik } from "next/font/google";
+import "./globals.scss";
-const inter = Inter({ subsets: ["latin"] });
+const rubik = Rubik({ subsets: ["latin"] });
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "UPEC Info Planning",
+ applicationName: "UPEC Info Planning",
+ description:
+ "L'emploi du temps du BUT informatique de l'UPEC sur les sites de Fontainebleau et Sénart.",
+ keywords: [
+ "UPEC",
+ "Info",
+ "Planning",
+ "Emploi du temps",
+ "Emploi du temps UPEC",
+ "Emploi du temps Info",
+ "Emploi du temps UPEC Info",
+ "UPEC Informatique",
+ "UPEC Info",
+ "UPEC Info Fontainebleau",
+ "UPEC Info Sénart",
+ "UPEC Info Planning",
+ ],
+ authors: [{ name: "Gaston Chenet", url: "https://gastonchenet.fr" }],
+ creator: "Gaston Chenet",
+ publisher: "Gaston Chenet",
+ openGraph: {
+ title: "UPEC Info Planning",
+ description:
+ "L'emploi du temps du BUT informatique de l'UPEC sur les sites de Fontainebleau et Sénart.",
+ type: "website",
+ },
+};
+
+export const viewport = {
+ themeColor: "#e42535",
};
export default function RootLayout({
- children,
+ children,
}: Readonly<{
- children: React.ReactNode;
+ children: React.ReactNode;
}>) {
- return (
-
-
{children}
-
- );
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/app/page.module.css b/src/app/page.module.css
deleted file mode 100644
index 5c4b1e6..0000000
--- a/src/app/page.module.css
+++ /dev/null
@@ -1,230 +0,0 @@
-.main {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- align-items: center;
- padding: 6rem;
- min-height: 100vh;
-}
-
-.description {
- display: inherit;
- justify-content: inherit;
- align-items: inherit;
- font-size: 0.85rem;
- max-width: var(--max-width);
- width: 100%;
- z-index: 2;
- font-family: var(--font-mono);
-}
-
-.description a {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 0.5rem;
-}
-
-.description p {
- position: relative;
- margin: 0;
- padding: 1rem;
- background-color: rgba(var(--callout-rgb), 0.5);
- border: 1px solid rgba(var(--callout-border-rgb), 0.3);
- border-radius: var(--border-radius);
-}
-
-.code {
- font-weight: 700;
- font-family: var(--font-mono);
-}
-
-.grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(25%, auto));
- max-width: 100%;
- width: var(--max-width);
-}
-
-.card {
- padding: 1rem 1.2rem;
- border-radius: var(--border-radius);
- background: rgba(var(--card-rgb), 0);
- border: 1px solid rgba(var(--card-border-rgb), 0);
- transition: background 200ms, border 200ms;
-}
-
-.card span {
- display: inline-block;
- transition: transform 200ms;
-}
-
-.card h2 {
- font-weight: 600;
- margin-bottom: 0.7rem;
-}
-
-.card p {
- margin: 0;
- opacity: 0.6;
- font-size: 0.9rem;
- line-height: 1.5;
- max-width: 30ch;
- text-wrap: balance;
-}
-
-.center {
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
- padding: 4rem 0;
-}
-
-.center::before {
- background: var(--secondary-glow);
- border-radius: 50%;
- width: 480px;
- height: 360px;
- margin-left: -400px;
-}
-
-.center::after {
- background: var(--primary-glow);
- width: 240px;
- height: 180px;
- z-index: -1;
-}
-
-.center::before,
-.center::after {
- content: "";
- left: 50%;
- position: absolute;
- filter: blur(45px);
- transform: translateZ(0);
-}
-
-.logo {
- position: relative;
-}
-/* Enable hover only on non-touch devices */
-@media (hover: hover) and (pointer: fine) {
- .card:hover {
- background: rgba(var(--card-rgb), 0.1);
- border: 1px solid rgba(var(--card-border-rgb), 0.15);
- }
-
- .card:hover span {
- transform: translateX(4px);
- }
-}
-
-@media (prefers-reduced-motion) {
- .card:hover span {
- transform: none;
- }
-}
-
-/* Mobile */
-@media (max-width: 700px) {
- .content {
- padding: 4rem;
- }
-
- .grid {
- grid-template-columns: 1fr;
- margin-bottom: 120px;
- max-width: 320px;
- text-align: center;
- }
-
- .card {
- padding: 1rem 2.5rem;
- }
-
- .card h2 {
- margin-bottom: 0.5rem;
- }
-
- .center {
- padding: 8rem 0 6rem;
- }
-
- .center::before {
- transform: none;
- height: 300px;
- }
-
- .description {
- font-size: 0.8rem;
- }
-
- .description a {
- padding: 1rem;
- }
-
- .description p,
- .description div {
- display: flex;
- justify-content: center;
- position: fixed;
- width: 100%;
- }
-
- .description p {
- align-items: center;
- inset: 0 0 auto;
- padding: 2rem 1rem 1.4rem;
- border-radius: 0;
- border: none;
- border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
- background: linear-gradient(
- to bottom,
- rgba(var(--background-start-rgb), 1),
- rgba(var(--callout-rgb), 0.5)
- );
- background-clip: padding-box;
- backdrop-filter: blur(24px);
- }
-
- .description div {
- align-items: flex-end;
- pointer-events: none;
- inset: auto 0 0;
- padding: 2rem;
- height: 200px;
- background: linear-gradient(
- to bottom,
- transparent 0%,
- rgb(var(--background-end-rgb)) 40%
- );
- z-index: 1;
- }
-}
-
-/* Tablet and Smaller Desktop */
-@media (min-width: 701px) and (max-width: 1120px) {
- .grid {
- grid-template-columns: repeat(2, 50%);
- }
-}
-
-@media (prefers-color-scheme: dark) {
- .vercelLogo {
- filter: invert(1);
- }
-
- .logo {
- filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
- }
-}
-
-@keyframes rotate {
- from {
- transform: rotate(360deg);
- }
- to {
- transform: rotate(0deg);
- }
-}
diff --git a/src/app/page.module.scss b/src/app/page.module.scss
new file mode 100644
index 0000000..b5f01fa
--- /dev/null
+++ b/src/app/page.module.scss
@@ -0,0 +1,284 @@
+@import "./variables.scss";
+
+section.buttons {
+ margin: 50px;
+ margin-bottom: 15px;
+ display: flex;
+ justify-content: right;
+ gap: 15px;
+
+ @media (max-width: 920px) {
+ margin-left: 100px;
+ flex-direction: column;
+ }
+
+ @media (max-width: 768px) {
+ margin: 15px;
+ }
+
+ article.selectionBar {
+ position: relative;
+ transition: 100ms ease;
+ width: 250px;
+
+ @media (max-width: 920px) {
+ width: 100%;
+ }
+
+ button.selected {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 16px;
+ font-size: 15px;
+ background: #dedfe3;
+ color: $darkGray;
+ border: none;
+ transition: 100ms ease;
+ cursor: pointer;
+ width: 250px;
+
+ @media (max-width: 920px) {
+ width: 100%;
+ }
+
+ &:hover {
+ background: darken(#dedfe3, 2.5);
+ color: $darkGray;
+ }
+
+ .unfoldIconUp,
+ .unfoldIconDown {
+ font-size: 18px;
+ transition: 200ms ease;
+ }
+
+ .unfoldIconDown {
+ transform: rotate(180deg);
+ }
+ }
+
+ div.choices {
+ position: absolute;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ user-select: none;
+ overflow: hidden;
+ width: 100%;
+
+ button.choice {
+ padding: 10px 16px;
+ font-size: 13px;
+ color: $darkGray;
+ background: #dedfe3;
+ text-align: left;
+ border: none;
+ transition: 100ms ease;
+ cursor: pointer;
+ border-top: 1px solid #d9dce5;
+
+ &:first-child {
+ border-top: 1px solid #c8c8c8;
+ }
+
+ &:hover {
+ color: $darkGray;
+ background: darken(#dedfe3, 2.5);
+ }
+ }
+ }
+ }
+
+ article.directions {
+ display: flex;
+
+ @media (max-width: 920px) {
+ width: 100%;
+ }
+
+ button.directionButton {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 16px;
+ font-size: 18px;
+ border: none;
+ background: #dedfe3;
+ color: $darkGray;
+ cursor: pointer;
+ transition: 100ms ease;
+ border-left: 1px solid #b8bac3;
+
+ @media (max-width: 920px) {
+ width: 100%;
+ }
+
+ &:first-child {
+ border-left: none;
+ }
+
+ &:hover {
+ background: darken(#dedfe3, 2.5);
+ color: $darkGray;
+ }
+
+ .textContent {
+ font-size: 15px;
+ }
+ }
+ }
+}
+
+section.planning {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ margin-right: 50px;
+ margin-left: 100px;
+ margin-bottom: 75px;
+ background: #f5f5f5;
+
+ @media (max-width: 768px) {
+ margin-left: 15px;
+ margin-right: 15px;
+ grid-template-columns: 1fr;
+ }
+
+ article.planningDay {
+ div.title {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 18px 0;
+ color: $white;
+ background: $accentColor;
+ border-left: 1px solid $accentColorDark;
+
+ p.duration {
+ font-size: 13px;
+ color: #f4c5c9;
+ }
+ }
+
+ div.dayEvents {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ div.hourPlaceholders {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ div.dayEvent {
+ border-top: 1px solid $secondaryColor;
+ border-left: 1px solid $secondaryColor;
+ height: 75px;
+
+ &:last-child {
+ border-bottom: 1px solid $secondaryColor;
+ }
+ }
+ }
+
+ div.events div.event {
+ position: absolute;
+ width: 100%;
+ background: $white;
+ border-radius: 6px;
+ border-left: 6px solid #00000000;
+ padding: 5px;
+ box-shadow: 0 0 10px #00000011;
+ z-index: 10;
+
+ &::before {
+ content: attr(data-room);
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ font-size: 12px;
+ padding: 3px 5px;
+ background: $accentColor;
+ border-radius: 5px;
+ color: $white;
+
+ @media (max-width: 768px) {
+ font-size: 15px;
+ }
+ }
+
+ p.interval {
+ font-size: 13px;
+ color: $gray;
+
+ @media (max-width: 768px) {
+ font-size: 15px;
+ }
+ }
+
+ p.title {
+ font-weight: 700;
+ margin-right: 20%;
+
+ @media (max-width: 768px) {
+ font-size: 20px;
+ }
+ }
+
+ div.teacher {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+
+ .teacherIcon {
+ font-size: 16px;
+ }
+
+ p.teacherContent {
+ font-size: 15px;
+
+ @media (max-width: 768px) {
+ font-size: 18px;
+ }
+ }
+ }
+ }
+ }
+
+ &:first-child {
+ h3.title {
+ border-left: none;
+ }
+
+ div.dayEvents div.hourPlaceholders div.dayEvent {
+ &::before {
+ content: attr(data-start) ":00";
+ position: absolute;
+ font-size: 13px;
+ transform: translate(-130%, -50%);
+
+ @media (max-width: 768px) {
+ display: none;
+ }
+ }
+
+ &:last-child::after {
+ content: attr(data-end) ":00";
+ position: absolute;
+ font-size: 13px;
+ transform: translate(-130%, 50%);
+ bottom: 0;
+
+ @media (max-width: 768px) {
+ display: none;
+ }
+ }
+ }
+ }
+
+ &:last-child div.dayEvents div.hourPlaceholders div.dayEvent {
+ border-right: 1px solid $secondaryColor;
+ }
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d2c63a4..cb38942 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,95 +1,423 @@
-import Image from "next/image";
-import styles from "./page.module.css";
+"use client";
-export default function Home() {
- return (
-
-
-
- Get started by editing
- src/app/page.tsx
-
-
-
+import React, { useEffect, useState } from "react";
+import style from "./page.module.scss";
+import moment, { Moment, unitOfTime } from "moment";
+import { FcGraduationCap } from "react-icons/fc";
+import { TbArrowNarrowLeft, TbArrowNarrowRight } from "react-icons/tb";
+import { MdOutlineArrowDropUp } from "react-icons/md";
+import Cookies from "universal-cookie";
+import { motion } from "framer-motion";
+import promos from "../constants/promos";
-
-
-
+const cookies = new Cookies();
+moment.locale("fr");
-
-
-
- Docs ->
-
- Find in-depth information about Next.js features and API.
-
+const Days = Object.freeze(["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]);
+const Months: { [key: string]: string } = Object.freeze({
+ January: "Janvier",
+ February: "Février",
+ March: "Mars",
+ April: "Avril",
+ May: "Mai",
+ June: "Juin",
+ July: "Juillet",
+ August: "Août",
+ September: "Septembre",
+ October: "Octobre",
+ November: "Novembre",
+ December: "Décembre",
+});
-
-
- Learn ->
-
- Learn about Next.js in an interactive course with quizzes!
-
+type Promo = {
+ name: string;
+ id: number;
+ promoId: string;
+ campus: 0 | 1;
+};
-
-
- Templates ->
-
- Explore starter templates for Next.js.
-
+type Week = {
+ start: Moment;
+ end: Moment;
+ count: number;
+};
-
-
- Deploy ->
-
-
- Instantly deploy your Next.js site to a shareable URL with Vercel.
-
-
-
-
- );
+type RawPlanningEvent = {
+ title: string;
+ room: string;
+ teacher: string;
+ start: string;
+ end: string;
+ color: string;
+};
+
+type PlanningEvent = {
+ title: string;
+ room: string;
+ teacher: string;
+ start: Moment;
+ end: Moment;
+ color: string;
+};
+
+export async function getGroups(promo: Promo): Promise {
+ const url = new URL("http://localhost:3000/api/groups");
+ url.searchParams.append("promo", promo.id.toString());
+
+ const res = await fetch(url.toString(), {
+ next: { revalidate: 86400 },
+ });
+
+ return res.json();
+}
+
+export async function getPlanning(
+ promo: Promo,
+ group: string,
+ start: Moment,
+ end: Moment
+): Promise {
+ const url = new URL("http://localhost:3000/api/planning");
+
+ url.searchParams.append("start", start.toJSON());
+ url.searchParams.append("end", end.toJSON());
+ url.searchParams.append("grp", group);
+ url.searchParams.append("promo", promo.id.toString());
+
+ const res = await fetch(url.toString(), {
+ next: { revalidate: 0 },
+ });
+
+ return res.json();
+}
+
+export default function Planning() {
+ const [group, setGroup] = useState(null as string | null);
+ const [promo, setPromo] = useState(null as Promo | null);
+ const [groups, setGroups] = useState([] as string[]);
+ const [groupsVisible, setGroupsVisible] = useState(false);
+ const [promosVisible, setPromosVisible] = useState(false);
+ const [mobile, setMobile] = useState(true);
+
+ const [week, setWeek] = useState({
+ start: moment().startOf("day"),
+ end: moment().endOf("day"),
+ count: 0,
+ });
+
+ const [days, setDays] = useState(
+ new Array(1).fill(null).map(() => []) as PlanningEvent[][]
+ );
+
+ async function fetchPlanning(
+ week: {
+ start: Moment;
+ end: Moment;
+ count: number;
+ },
+ group: string,
+ promo: Promo,
+ isMobile: boolean
+ ) {
+ const planning = await getPlanning(promo, group, week.start, week.end);
+ const planningDays: PlanningEvent[][] = new Array(isMobile ? 1 : 5)
+ .fill(null)
+ .map(() => []);
+
+ planning.forEach((event) => {
+ const start = moment(event.start);
+ const end = moment(event.end);
+ const day = start.diff(week.start, "day") - 1;
+
+ planningDays[isMobile ? 0 : day].push({
+ title: event.title,
+ room: event.room,
+ teacher: event.teacher,
+ start: start,
+ end: end,
+ color: event.color,
+ });
+ });
+
+ setDays(planningDays);
+ }
+
+ function defineGroup(newGroup: string) {
+ cookies.set("group", newGroup);
+ setGroup(newGroup);
+ setGroupsVisible(false);
+
+ if (promo) fetchPlanning(week, newGroup, promo, mobile);
+ }
+
+ async function definePromo(newPromo: Promo) {
+ cookies.set("promo", newPromo);
+ setPromo(newPromo);
+ setPromosVisible(false);
+
+ const groups = await getGroups(newPromo);
+ const defaultGroup = cookies.get("group") ?? groups[0];
+
+ setGroups(groups);
+ setGroup(defaultGroup);
+
+ fetchPlanning(week, defaultGroup, newPromo, mobile);
+ }
+
+ function addWeek(addCount: number) {
+ const unit: unitOfTime.DurationConstructor = mobile ? "day" : "week";
+
+ if (addCount === 0) {
+ var count = 0;
+ var start = moment().startOf(unit);
+ var end = moment().endOf(unit);
+ } else {
+ var count = week.count + addCount;
+ var start = moment().startOf(unit).add(count, unit);
+ var end = moment().endOf(unit).add(count, unit);
+ }
+
+ setWeek({ count, start, end });
+ if (group && promo) {
+ fetchPlanning({ count, start, end }, group, promo, mobile);
+ }
+ }
+
+ function getDayStart(events: PlanningEvent[]): Moment {
+ return events.reduce((pre: any, val) => {
+ if (!pre) return val.start;
+ if (val.start.diff(pre, "minutes") < 0) return val.start;
+ return pre;
+ }, null);
+ }
+
+ function getDayEnd(events: PlanningEvent[]): Moment {
+ return events.reduce((pre: any, val) => {
+ if (!pre) return val.end;
+ if (val.end.diff(pre, "minutes") > 0) return val.end;
+ return pre;
+ }, null);
+ }
+
+ async function updatePlanning(week: Week, mobile: boolean) {
+ const defaultPromo = cookies.get("promo") ?? promos[0];
+ setPromo(defaultPromo);
+
+ const groups = await getGroups(defaultPromo);
+ const defaultGroup = cookies.get("group") ?? groups[0];
+
+ setGroups(groups);
+ setGroup(defaultGroup);
+
+ fetchPlanning(week, defaultGroup, defaultPromo, mobile);
+ }
+
+ useEffect(() => {
+ let isMobile = true;
+ let currentWeek = week;
+
+ function handleResize() {
+ isMobile = window.innerWidth < 768;
+ const unit: unitOfTime.DurationConstructor = isMobile ? "day" : "week";
+
+ currentWeek = {
+ start: moment().startOf(unit),
+ end: moment().endOf(unit),
+ count: 0,
+ };
+
+ setMobile(isMobile);
+ setWeek(currentWeek);
+
+ setDays(
+ new Array(isMobile ? 1 : 5)
+ .fill(null)
+ .map(() => []) as PlanningEvent[][]
+ );
+ }
+
+ window.addEventListener("resize", handleResize);
+ handleResize();
+
+ updatePlanning(currentWeek, isMobile);
+
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return (
+
+
+
+
+
+ {promos.map((p, i) => (
+
+ ))}
+
+
+ {groups.length > 1 && (
+
+
+
+ {groups.map((g, i) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ {days.map((day, index) => (
+
+
+ {!mobile &&
{Days[index]}
}
+
+ {moment(week.start)
+ .add(index + (mobile ? 0 : 1), "days")
+ .format("DD MMMM")
+ .replace(/([a-z]+)/gi, (m) => {
+ return Months[m];
+ })}
+
+
+ {getDayStart(day)?.format("HH:mm")} -{" "}
+ {getDayEnd(day)?.format("HH:mm")}
+
+
+
+
+ {day.map((event: PlanningEvent, i) => (
+
+
+ {event.start.format("HH:mm")} -{" "}
+ {event.end.format("HH:mm")}
+
+ {event.title}
+
+
+ ))}
+
+
+ {new Array(11).fill(null).map((_, j) => (
+
+ ))}
+
+
+
+ ))}
+
+
+ );
}
diff --git a/src/app/variables.scss b/src/app/variables.scss
new file mode 100644
index 0000000..d9ac218
--- /dev/null
+++ b/src/app/variables.scss
@@ -0,0 +1,12 @@
+$accentColor: #e42535;
+$accentColorDark: #c72331;
+$accentColorDarker: #ac1e2a;
+
+$white: #ffffff;
+$black: #000000;
+$lightBlack: #2a2a2a;
+$darkGray: #555555;
+$gray: #888888;
+
+$primaryColor: #f0f0f0;
+$secondaryColor: #e0e0e0;
diff --git a/src/constants/colors.ts b/src/constants/colors.ts
new file mode 100644
index 0000000..55bc8cd
--- /dev/null
+++ b/src/constants/colors.ts
@@ -0,0 +1,10 @@
+export default Object.freeze([
+ "#e8588f",
+ "#694994",
+ "#38abb8",
+ "#00519c",
+ "#88b917",
+ "#367b2a",
+ "#f5c82f",
+ "#f19217",
+]);
diff --git a/src/constants/promos.ts b/src/constants/promos.ts
new file mode 100644
index 0000000..8f51da7
--- /dev/null
+++ b/src/constants/promos.ts
@@ -0,0 +1,44 @@
+export enum Campus {
+ Fontainebleau,
+ Senart,
+}
+
+export type Promo = {
+ id: number;
+ name: string;
+ promoId: string;
+ campus: Campus;
+};
+
+export default Object.freeze([
+ {
+ id: 0,
+ name: "BUT 1 (Fontainebleau)",
+ promoId: "50",
+ campus: Campus.Fontainebleau,
+ },
+ {
+ id: 1,
+ name: "BUT 1 (Sénart)",
+ promoId: "14:113",
+ campus: Campus.Senart,
+ },
+ {
+ id: 2,
+ name: "BUT 2 Fi",
+ promoId: "51",
+ campus: Campus.Fontainebleau,
+ },
+ {
+ id: 3,
+ name: "BUT 2 Fa",
+ promoId: "52",
+ campus: Campus.Fontainebleau,
+ },
+ {
+ id: 4,
+ name: "BUT 3",
+ promoId: "53",
+ campus: Campus.Fontainebleau,
+ },
+]);
diff --git a/src/utils/fetchFontainebleau.ts b/src/utils/fetchFontainebleau.ts
new file mode 100644
index 0000000..aa3611e
--- /dev/null
+++ b/src/utils/fetchFontainebleau.ts
@@ -0,0 +1,47 @@
+import colors from "@/constants/colors";
+import { Promo } from "@/constants/promos";
+import axios from "axios";
+import moment, { Moment } from "moment";
+
+type PlanningEvent = {
+ resourceId: string;
+ start: string;
+ end: string;
+ title: string;
+ numero: string;
+ nomADE: string;
+ salle: string;
+};
+
+export default async function fetchFontainebleau(
+ promo: Promo,
+ groupId: number,
+ startDate: Moment,
+ endDate: Moment
+) {
+ const url = new URL("http://www.iut-fbleau.fr/EDT/consulter/ajax/ep.php");
+
+ url.searchParams.set("p", promo.promoId);
+ url.searchParams.set("start", startDate.toJSON());
+ url.searchParams.set("end", endDate.toJSON());
+
+ const response = await axios.get(url.toString());
+ const data: PlanningEvent[] = response.data;
+
+ return data
+ .filter((event) => event.numero === groupId.toString())
+ .map((event) => ({
+ start: moment(event.start).toJSON(),
+ end: moment(event.end).toJSON(),
+ title: event.title,
+ teacher: event.nomADE,
+ room: event.salle ?? "Inconnu",
+ color:
+ colors[
+ event.nomADE
+ .split("")
+ .reduce((acc, val) => val.charCodeAt(0) ** 2 + acc, 0) %
+ colors.length
+ ],
+ }));
+}
diff --git a/src/utils/fetchSenart.ts b/src/utils/fetchSenart.ts
new file mode 100644
index 0000000..90080f1
--- /dev/null
+++ b/src/utils/fetchSenart.ts
@@ -0,0 +1,78 @@
+import colors from "@/constants/colors";
+import { Promo } from "@/constants/promos";
+import axios from "axios";
+import moment, { Moment } from "moment";
+
+type SenPlanning = {
+ header: { left: string; center: string; right: string };
+ defaultDate: string;
+ defaultView: string;
+ scrollTime: string;
+ minTime: string;
+ maxTime: string;
+ navLinks: boolean;
+ locale: string;
+ noEventsMessage: string;
+ hiddenDays: number[];
+ editable: boolean;
+ eventLimit: boolean;
+ events: { title: string; start: string; end: string }[];
+};
+
+async function fetchSemester(promo: Promo, semesterNum: number) {
+ const url = new URL("https://dynasis.iutsf.org/index.php");
+
+ url.searchParams.set("group_id", "6");
+ url.searchParams.set("id", promo.promoId.split(":")[semesterNum]);
+
+ const response = await axios.get(url.toString());
+ const rawJsObject: string = response.data
+ .split(".fullCalendar(")[1]
+ .split(");")[0];
+
+ const readableJSONObject = rawJsObject
+ .replace(/\/\/.+/g, "")
+ .replace(/(? fetchSemester(promo, i))
+ );
+
+ const filteredEvents = results.map((r) =>
+ r.events.filter(
+ (e) =>
+ moment(e.start).isAfter(startDate) && moment(e.end).isBefore(endDate)
+ )
+ );
+
+ return filteredEvents.flat().map((event) => {
+ const [title, room, details] = event.title.split(/\r?\n/);
+ const regex = /^.+\(Sénart\)\s(?.+\s)\s\(.+\)$/;
+ const teacher = details.match(regex)?.groups?.teacher ?? "A PRECISER";
+
+ return {
+ title: title.trim(),
+ room: room.trim() || "Inconnu",
+ teacher: teacher.trim(),
+ start: moment(event.start),
+ end: moment(event.end),
+ color:
+ colors[
+ teacher
+ .split("")
+ .reduce((acc, val) => val.charCodeAt(0) ** 2 + acc, 0) %
+ colors.length
+ ],
+ };
+ });
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7b28589..7c93a06 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,6 +12,7 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
+ "downlevelIteration": true,
"plugins": [
{
"name": "next"