diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..bfc0469
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "i18n-ally.localesPaths": [
+ "frontend/src/locales"
+ ]
+}
\ No newline at end of file
diff --git a/LaTeX_pdf/assets/epfl.png b/LaTeX_pdf/assets/epfl.png
new file mode 100644
index 0000000..c54999e
Binary files /dev/null and b/LaTeX_pdf/assets/epfl.png differ
diff --git a/LaTeX_pdf/assignment.cls b/LaTeX_pdf/assignment.cls
new file mode 100644
index 0000000..b327253
--- /dev/null
+++ b/LaTeX_pdf/assignment.cls
@@ -0,0 +1,60 @@
+% https://github.com/Ziqi-Yang/latex_assignment_template
+\NeedsTeXFormat{LaTeX2e}
+\ProvidesClass{assignment}[a latex class for editing your assignment]
+
+\LoadClass[11pt,letterpaper]{article}
+
+\RequirePackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
+\RequirePackage[dvipsnames]{xcolor}
+\RequirePackage{listings}
+\RequirePackage{tcolorbox}
+
+\setlength{\textwidth}{6.5in}
+\setlength{\textheight}{9.in}
+\setlength{\oddsidemargin}{0in}
+\setlength{\headheight}{0in}
+
+% create a box to highlight word
+\newtcbox{\hword}[1][orange]{on line, arc = 0.3mm,auto outer arc,colback = #1!12!, colframe = #1!60!,colupper = black, boxsep =0mm, left =0.3mm, right = 0.3mm, top =0.4mm, bottom = 0.4mm,boxrule =0.2mm, bottomrule =0.2mm, toprule = 0.2mm}
+
+% default code style
+\lstset{
+ framerule=1pt,
+ frame=tb,
+ emphstyle={\small\ttfamily\bfseries\color{Orange}},
+ numbers=left,
+ numberstyle= \tiny\color{black},
+ basicstyle = \small\ttfamily,
+ keywordstyle = \bfseries\color{BrickRed},
+ identifierstyle = \bfseries\color{black},
+ stringstyle = \bfseries\color{ForestGreen},
+ commentstyle = \bfseries\color{Violet},
+ breaklines = true,
+ columns = fixed,
+ basewidth = .5em,
+ backgroundcolor=\color{Gray!5},
+ tabsize=2,
+ showspaces=false,
+ showstringspaces=false,
+}
+
+\newcommand{\assignmentTitle}[5]{
+ \begin{flushleft}
+ \textbf{Student Name:} #1\\
+ \textbf{Student ID:} #2
+ \end{flushleft}
+
+ \begin{flushright}\vspace{-15mm}
+ \includegraphics[height=1cm]{#3}
+ \end{flushright}
+
+ \begin{flushleft}\vspace{0mm}
+ \textbf{\large #4}
+ \end{flushleft}
+
+ \begin{flushleft}\vspace{-3.5mm}
+ \textbf{\large #5}
+ \end{flushleft}
+
+ \noindent\rule{\linewidth}{.6pt}
+}
diff --git a/LaTeX_pdf/box-filter.lua b/LaTeX_pdf/box-filter.lua
new file mode 100644
index 0000000..7146bc1
--- /dev/null
+++ b/LaTeX_pdf/box-filter.lua
@@ -0,0 +1,27 @@
+-- box-filter.lua
+-- Converts fenced divs like:
+-- ::: {.defbox title="Def. 1.1 -- Something"}
+-- content (normal Markdown: **bold**, lists, code blocks, math...)
+-- :::
+-- into raw LaTeX tcolorbox environments (defbox/theobox/exbox), while the
+-- content itself stays as ordinary Markdown blocks that pandoc converts
+-- normally (bold -> \textbf, code fences -> lstlisting, etc.).
+
+local boxnames = { defbox = true, theobox = true, exbox = true }
+
+function Div(el)
+ for name, _ in pairs(boxnames) do
+ if el.classes:includes(name) then
+ local title = el.attributes["title"] or ""
+ local open = pandoc.RawBlock("latex", "\\begin{" .. name .. "}[" .. title .. "]")
+ local close = pandoc.RawBlock("latex", "\\end{" .. name .. "}")
+ local blocks = { open }
+ for _, b in ipairs(el.content) do
+ table.insert(blocks, b)
+ end
+ table.insert(blocks, close)
+ return blocks
+ end
+ end
+ return nil
+end
diff --git a/LaTeX_pdf/modele.md b/LaTeX_pdf/modele.md
new file mode 100644
index 0000000..093ddb3
--- /dev/null
+++ b/LaTeX_pdf/modele.md
@@ -0,0 +1,78 @@
+---
+student-name: "Antoine Pelletier"
+student-id: "356029"
+logo: "/home/tibiscuit/LaTeX_pdf/assets/epfl.png"
+course: "CS473 -- System Programming for SOC's"
+subtitle: "Lecture 2 Notes: Memory subsystem"
+---
+
+
+
+# Introduction
+
+Texte d'introduction du cours. On peut utiliser du **gras**, de
+l'*italique*, des listes :
+
+- premier point
+- second point
+ - sous-point
+
+et des énumérations numérotées :
+
+1. étape une
+2. étape deux
+
+## Sous-section
+
+On peut librement imbriquer des sous-sections avec `##`, `###`, etc.
+
+# Définitions, théorèmes et exemples
+
+Pour respecter le rendu exact du LaTeX d'origine, les encadrés colorés
+sont insérés en LaTeX brut au milieu du Markdown :
+
+\begin{defbox}[Nom de la définition]
+Contenu de la définition, en Markdown normal (gras, italique, etc.
+fonctionnent aussi à l'intérieur).
+\end{defbox}
+
+\begin{theobox}[Nom du théorème]
+Énoncé du théorème ou de la propriété importante à retenir.
+\end{theobox}
+
+\begin{exbox}[Nom de l'exemple]
+Illustration concrète, éventuellement avec un extrait de code :
+
+```c
+int main() {
+ return 0;
+}
+```
+\end{exbox}
+
+# Tableaux
+
+Un tableau Markdown standard :
+
+| Colonne A | Colonne B | Colonne C |
+|:----------|:---------:|----------:|
+| gauche | centré | droite |
+| ligne 2 | ligne 2 | ligne 2 |
+
+# Conclusion
+
+Résumé des points clés du cours, comme dans la dernière slide de chaque
+lecture.
diff --git a/LaTeX_pdf/template.tex b/LaTeX_pdf/template.tex
new file mode 100644
index 0000000..9317767
--- /dev/null
+++ b/LaTeX_pdf/template.tex
@@ -0,0 +1,156 @@
+\documentclass[11pt]{article}
+
+% ---------- Encoding & fonts (pdflatex-compatible, no external fonts needed) ----------
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage{lmodern}
+\usepackage{textcomp}
+% Unicode chars pdflatex doesn't know out of the box
+\DeclareUnicodeCharacter{2212}{\ensuremath{-}} % − minus sign
+\DeclareUnicodeCharacter{2192}{\ensuremath{\rightarrow}}
+\DeclareUnicodeCharacter{2190}{\ensuremath{\leftarrow}}
+\DeclareUnicodeCharacter{2264}{\ensuremath{\leq}}
+\DeclareUnicodeCharacter{2265}{\ensuremath{\geq}}
+\DeclareUnicodeCharacter{00D7}{\ensuremath{\times}}
+
+% ---------- Page geometry (matches assignment.cls) ----------
+\usepackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
+\usepackage{setspace}
+\setstretch{1.08}
+
+% ---------- Math ----------
+\usepackage{amsmath,amssymb}
+
+% ---------- Colour & boxes ----------
+\usepackage[dvipsnames]{xcolor}
+\usepackage[most]{tcolorbox}
+\tcbuselibrary{skins,breakable}
+
+\newtcolorbox{defbox}[1][]{%
+ enhanced, breakable, colback=blue!5, colframe=blue!65!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+\newtcolorbox{theobox}[1][]{%
+ enhanced, breakable, colback=red!5, colframe=red!55!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+\newtcolorbox{exbox}[1][]{%
+ enhanced, breakable, colback=green!5, colframe=green!40!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+
+% Word-highlight box, matching assignment.cls's \hword
+\newtcbox{\hword}[1][orange]{on line, arc=0.3mm, auto outer arc,
+ colback=#1!12!, colframe=#1!60!, colupper=black, boxsep=0mm,
+ left=0.3mm, right=0.3mm, top=0.4mm, bottom=0.4mm,
+ boxrule=0.2mm, bottomrule=0.2mm, toprule=0.2mm}
+
+% ---------- Tables ----------
+\usepackage{calc}
+\usepackage{booktabs}
+\usepackage{longtable}
+\usepackage{array}
+\providecommand{\tightlist}{%
+ \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
+\providecommand{\passthrough}[1]{#1}
+\providecommand{\pandocbounded}[1]{#1}
+
+% Fix for a real bug (see jgm/pandoc#11201): longtable >= 4.21 (shipped with
+% recent TeX Live, e.g. 2026) combined with the caption package's longtable
+% patch tries to reference a counter literally named "none" as a placeholder
+% for uncaptioned tables, which was never actually defined anywhere, crashing
+% with "LaTeX Error: No counter 'none' defined." Defining it as an unused
+% dummy counter satisfies that reference harmlessly.
+\newcounter{none}
+
+% ---------- Code listings (pandoc --listings), matching assignment.cls ----------
+\usepackage{listings}
+\usepackage{pmboxdraw} % box-drawing glyphs (├ └ │ ─ ...) under pdflatex
+\lstset{
+ % listings is not UTF-8 aware under pdflatex: map multibyte chars explicitly
+ literate=
+ {├}{{\textSFviii}}1 {└}{{\textSFii}}1 {┌}{{\textSFi}}1 {┐}{{\textSFiii}}1 {┘}{{\textSFiv}}1 {┤}{{\textSFix}}1
+ {┬}{{\textSFvi}}1 {┴}{{\textSFvii}}1 {┼}{{\textSFv}}1 {│}{{\textSFxi}}1 {─}{{\textSFx}}1
+ {é}{{\'e}}1 {è}{{\`e}}1 {ê}{{\^e}}1 {ë}{{\"e}}1 {à}{{\`a}}1 {â}{{\^a}}1
+ {ù}{{\`u}}1 {û}{{\^u}}1 {ü}{{\"u}}1 {ô}{{\^o}}1 {ö}{{\"o}}1 {î}{{\^i}}1
+ {ï}{{\"i}}1 {ç}{{\c{c}}}1 {É}{{\'E}}1 {È}{{\`E}}1 {À}{{\`A}}1 {Ç}{{\c{C}}}1
+ {«}{{\guillemotleft}}1 {»}{{\guillemotright}}1 {’}{{'}}1 {…}{{...}}3 {−}{{-}}1 {–}{{--}}1 {—}{{---}}1
+ {≤}{{$$\leq$$}}1 {≥}{{$$\geq$$}}1 {×}{{$$\times$$}}1
+ {→}{{$$\rightarrow$$}}2 {←}{{$$\leftarrow$$}}2,
+ framerule=1pt,
+ frame=tb,
+ emphstyle={\small\ttfamily\bfseries\color{Orange}},
+ numbers=left,
+ numberstyle=\tiny\color{black},
+ basicstyle=\small\ttfamily,
+ keywordstyle=\bfseries\color{BrickRed},
+ identifierstyle=\bfseries\color{black},
+ stringstyle=\bfseries\color{ForestGreen},
+ commentstyle=\bfseries\color{Violet},
+ breaklines=true,
+ columns=fixed,
+ basewidth=.5em,
+ backgroundcolor=\color{Gray!5},
+ tabsize=2,
+ showspaces=false,
+ showstringspaces=false,
+}
+
+% ---------- Links ----------
+\usepackage[colorlinks=true,linkcolor=blue!60!black,urlcolor=blue!60!black,citecolor=blue!60!black]{hyperref}
+
+% caption is loaded AFTER hyperref on purpose: it needs to detect hyperref's
+% presence at load time to patch longtable's anchor/bookmark generation
+% correctly. Loading it before hyperref (as an earlier version of this
+% template did) is a known cause of exactly the
+% "LaTeX Error: No counter 'none' defined" crash on \begin{longtable}.
+\usepackage{caption}
+\captionsetup{skip=6pt}
+
+% Failsafe belt-and-braces: bypass hyperref's auto-computed \theHtable /
+% \theHfigure chain entirely.
+\providecommand{\theHtable}{\thetable}
+\providecommand{\theHfigure}{\thefigure}
+
+% ---------- Section styling ----------
+\usepackage{sectsty}
+\sectionfont{\Large\bfseries}
+\subsectionfont{\large\bfseries\color{black!80}}
+
+\usepackage{graphicx}
+
+\begin{document}
+
+% ---------- Header (matches assignment.cls's \assignmentTitle) ----------
+\begin{flushleft}
+$if(student-name)$\textbf{Student Name:} $student-name$\\$endif$
+$if(student-id)$\textbf{Student ID:} $student-id$$endif$
+\end{flushleft}
+
+$if(logo)$
+\begin{flushright}\vspace{-15mm}
+\includegraphics[height=1cm]{$logo$}
+\end{flushright}
+$endif$
+
+$if(course)$
+\begin{flushleft}\vspace{0mm}
+\textbf{\large $course$}
+\end{flushleft}
+$endif$
+
+$if(subtitle)$
+\begin{flushleft}\vspace{-3.5mm}
+\textbf{\large $subtitle$}
+\end{flushleft}
+$endif$
+
+\noindent\rule{\linewidth}{.6pt}
+\vspace{4mm}
+
+$body$
+
+\end{document}
diff --git a/LaTeX_pdf/template.tex.bak b/LaTeX_pdf/template.tex.bak
new file mode 100644
index 0000000..5d7f2ce
--- /dev/null
+++ b/LaTeX_pdf/template.tex.bak
@@ -0,0 +1,138 @@
+\documentclass[11pt]{article}
+
+% ---------- Encoding & fonts (pdflatex-compatible, no external fonts needed) ----------
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage{lmodern}
+\usepackage{textcomp}
+
+% ---------- Page geometry (matches assignment.cls) ----------
+\usepackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
+\usepackage{setspace}
+\setstretch{1.08}
+
+% ---------- Math ----------
+\usepackage{amsmath,amssymb}
+
+% ---------- Colour & boxes ----------
+\usepackage[dvipsnames]{xcolor}
+\usepackage[most]{tcolorbox}
+\tcbuselibrary{skins,breakable}
+
+\newtcolorbox{defbox}[1][]{%
+ enhanced, breakable, colback=blue!5, colframe=blue!65!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+\newtcolorbox{theobox}[1][]{%
+ enhanced, breakable, colback=red!5, colframe=red!55!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+\newtcolorbox{exbox}[1][]{%
+ enhanced, breakable, colback=green!5, colframe=green!40!black,
+ fonttitle=\bfseries, coltitle=white, title={#1},
+ boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
+}
+
+% Word-highlight box, matching assignment.cls's \hword
+\newtcbox{\hword}[1][orange]{on line, arc=0.3mm, auto outer arc,
+ colback=#1!12!, colframe=#1!60!, colupper=black, boxsep=0mm,
+ left=0.3mm, right=0.3mm, top=0.4mm, bottom=0.4mm,
+ boxrule=0.2mm, bottomrule=0.2mm, toprule=0.2mm}
+
+% ---------- Tables ----------
+\usepackage{calc}
+\usepackage{booktabs}
+\usepackage{longtable}
+\usepackage{array}
+\providecommand{\tightlist}{%
+ \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
+\providecommand{\passthrough}[1]{#1}
+\providecommand{\pandocbounded}[1]{#1}
+
+% Fix for a real bug (see jgm/pandoc#11201): longtable >= 4.21 (shipped with
+% recent TeX Live, e.g. 2026) combined with the caption package's longtable
+% patch tries to reference a counter literally named "none" as a placeholder
+% for uncaptioned tables, which was never actually defined anywhere, crashing
+% with "LaTeX Error: No counter 'none' defined." Defining it as an unused
+% dummy counter satisfies that reference harmlessly.
+\newcounter{none}
+
+% ---------- Code listings (pandoc --listings), matching assignment.cls ----------
+\usepackage{listings}
+\lstset{
+ framerule=1pt,
+ frame=tb,
+ emphstyle={\small\ttfamily\bfseries\color{Orange}},
+ numbers=left,
+ numberstyle=\tiny\color{black},
+ basicstyle=\small\ttfamily,
+ keywordstyle=\bfseries\color{BrickRed},
+ identifierstyle=\bfseries\color{black},
+ stringstyle=\bfseries\color{ForestGreen},
+ commentstyle=\bfseries\color{Violet},
+ breaklines=true,
+ columns=fixed,
+ basewidth=.5em,
+ backgroundcolor=\color{Gray!5},
+ tabsize=2,
+ showspaces=false,
+ showstringspaces=false,
+}
+
+% ---------- Links ----------
+\usepackage[colorlinks=true,linkcolor=blue!60!black,urlcolor=blue!60!black,citecolor=blue!60!black]{hyperref}
+
+% caption is loaded AFTER hyperref on purpose: it needs to detect hyperref's
+% presence at load time to patch longtable's anchor/bookmark generation
+% correctly. Loading it before hyperref (as an earlier version of this
+% template did) is a known cause of exactly the
+% "LaTeX Error: No counter 'none' defined" crash on \begin{longtable}.
+\usepackage{caption}
+\captionsetup{skip=6pt}
+
+% Failsafe belt-and-braces: bypass hyperref's auto-computed \theHtable /
+% \theHfigure chain entirely.
+\providecommand{\theHtable}{\thetable}
+\providecommand{\theHfigure}{\thefigure}
+
+% ---------- Section styling ----------
+\usepackage{sectsty}
+\sectionfont{\Large\bfseries}
+\subsectionfont{\large\bfseries\color{black!80}}
+
+\usepackage{graphicx}
+
+\begin{document}
+
+% ---------- Header (matches assignment.cls's \assignmentTitle) ----------
+\begin{flushleft}
+$if(student-name)$\textbf{Student Name:} $student-name$\\$endif$
+$if(student-id)$\textbf{Student ID:} $student-id$$endif$
+\end{flushleft}
+
+$if(logo)$
+\begin{flushright}\vspace{-15mm}
+\includegraphics[height=1cm]{$logo$}
+\end{flushright}
+$endif$
+
+$if(course)$
+\begin{flushleft}\vspace{0mm}
+\textbf{\large $course$}
+\end{flushleft}
+$endif$
+
+$if(subtitle)$
+\begin{flushleft}\vspace{-3.5mm}
+\textbf{\large $subtitle$}
+\end{flushleft}
+$endif$
+
+\noindent\rule{\linewidth}{.6pt}
+\vspace{4mm}
+
+$body$
+
+\end{document}
diff --git a/frontend/src/components/AppContent.vue b/frontend/src/components/AppContent.vue
index da44fd4..926e0c1 100644
--- a/frontend/src/components/AppContent.vue
+++ b/frontend/src/components/AppContent.vue
@@ -11,14 +11,15 @@ import {
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
+import ThemeToggle from '@/components/ThemeToggle.vue'
import { locales, setLocale } from '@/services/i18n'
import { useI18n } from 'vue-i18n'
const route = useRoute()
const { locale: currentLocale } = useI18n()
-const selectedLangFlag = computed(
- () => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
+const selectedLocale = computed(
+ () => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
)
@@ -29,20 +30,22 @@ const selectedLangFlag = computed(
{{ route.meta.title ? $t(route.meta.title) : '' }}
+
-
+ {{ locale.lang.toUpperCase() }}
diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue
index 9358251..c766af3 100644
--- a/frontend/src/components/AppSidebar.vue
+++ b/frontend/src/components/AppSidebar.vue
@@ -66,7 +66,7 @@ function onNavigate() {
diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue
new file mode 100644
index 0000000..1842ae4
--- /dev/null
+++ b/frontend/src/components/ThemeToggle.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ {{ isDark ? $t('theme.light') : $t('theme.dark') }}
+
+
diff --git a/frontend/src/components/courses/CourseFormDialog.vue b/frontend/src/components/courses/CourseFormDialog.vue
new file mode 100644
index 0000000..7dac4dd
--- /dev/null
+++ b/frontend/src/components/courses/CourseFormDialog.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
diff --git a/frontend/src/components/schedule/SlotFormDialog.vue b/frontend/src/components/schedule/SlotFormDialog.vue
new file mode 100644
index 0000000..7a2b367
--- /dev/null
+++ b/frontend/src/components/schedule/SlotFormDialog.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue
index 47b0968..ff50359 100644
--- a/frontend/src/components/ui/dialog/Dialog.vue
+++ b/frontend/src/components/ui/dialog/Dialog.vue
@@ -1,6 +1,6 @@
diff --git a/frontend/src/components/ui/dialog/DialogContent.vue b/frontend/src/components/ui/dialog/DialogContent.vue
index 4bc0a1c..b2bf24a 100644
--- a/frontend/src/components/ui/dialog/DialogContent.vue
+++ b/frontend/src/components/ui/dialog/DialogContent.vue
@@ -1,21 +1,21 @@
@@ -31,7 +31,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
- )"
+ )
+ "
>
diff --git a/frontend/src/components/ui/dialog/DialogDescription.vue b/frontend/src/components/ui/dialog/DialogDescription.vue
index 062c3a5..bf04fdc 100644
--- a/frontend/src/components/ui/dialog/DialogDescription.vue
+++ b/frontend/src/components/ui/dialog/DialogDescription.vue
@@ -1,13 +1,13 @@
diff --git a/frontend/src/components/ui/dialog/DialogFooter.vue b/frontend/src/components/ui/dialog/DialogFooter.vue
index 5f481e5..c6f5d4e 100644
--- a/frontend/src/components/ui/dialog/DialogFooter.vue
+++ b/frontend/src/components/ui/dialog/DialogFooter.vue
@@ -1,19 +1,12 @@
-
+
diff --git a/frontend/src/components/ui/dialog/DialogHeader.vue b/frontend/src/components/ui/dialog/DialogHeader.vue
index 33aa003..e22a1fd 100644
--- a/frontend/src/components/ui/dialog/DialogHeader.vue
+++ b/frontend/src/components/ui/dialog/DialogHeader.vue
@@ -1,16 +1,14 @@
-
+
diff --git a/frontend/src/components/ui/dialog/DialogScrollContent.vue b/frontend/src/components/ui/dialog/DialogScrollContent.vue
index 59a32ec..46bb80e 100644
--- a/frontend/src/components/ui/dialog/DialogScrollContent.vue
+++ b/frontend/src/components/ui/dialog/DialogScrollContent.vue
@@ -1,21 +1,21 @@
@@ -33,13 +33,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
v-bind="forwarded"
- @pointer-down-outside="(event) => {
- const originalEvent = event.detail.originalEvent;
- const target = originalEvent.target as HTMLElement;
- if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
- event.preventDefault();
+ @pointer-down-outside="
+ (event) => {
+ const originalEvent = event.detail.originalEvent
+ const target = originalEvent.target as HTMLElement
+ if (
+ originalEvent.offsetX > target.clientWidth ||
+ originalEvent.offsetY > target.clientHeight
+ ) {
+ event.preventDefault()
+ }
}
- }"
+ "
>
diff --git a/frontend/src/components/ui/dialog/DialogTitle.vue b/frontend/src/components/ui/dialog/DialogTitle.vue
index 1de56de..b4dfbeb 100644
--- a/frontend/src/components/ui/dialog/DialogTitle.vue
+++ b/frontend/src/components/ui/dialog/DialogTitle.vue
@@ -1,13 +1,13 @@
@@ -15,12 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
diff --git a/frontend/src/components/ui/dialog/DialogTrigger.vue b/frontend/src/components/ui/dialog/DialogTrigger.vue
index a4fc3ee..4723d52 100644
--- a/frontend/src/components/ui/dialog/DialogTrigger.vue
+++ b/frontend/src/components/ui/dialog/DialogTrigger.vue
@@ -1,6 +1,6 @@
diff --git a/frontend/src/components/ui/dialog/index.ts b/frontend/src/components/ui/dialog/index.ts
index c9c577f..ca8cfea 100644
--- a/frontend/src/components/ui/dialog/index.ts
+++ b/frontend/src/components/ui/dialog/index.ts
@@ -1,9 +1,9 @@
-export { default as Dialog } from "./Dialog.vue"
-export { default as DialogClose } from "./DialogClose.vue"
-export { default as DialogContent } from "./DialogContent.vue"
-export { default as DialogDescription } from "./DialogDescription.vue"
-export { default as DialogFooter } from "./DialogFooter.vue"
-export { default as DialogHeader } from "./DialogHeader.vue"
-export { default as DialogScrollContent } from "./DialogScrollContent.vue"
-export { default as DialogTitle } from "./DialogTitle.vue"
-export { default as DialogTrigger } from "./DialogTrigger.vue"
+export { default as Dialog } from './Dialog.vue'
+export { default as DialogClose } from './DialogClose.vue'
+export { default as DialogContent } from './DialogContent.vue'
+export { default as DialogDescription } from './DialogDescription.vue'
+export { default as DialogFooter } from './DialogFooter.vue'
+export { default as DialogHeader } from './DialogHeader.vue'
+export { default as DialogScrollContent } from './DialogScrollContent.vue'
+export { default as DialogTitle } from './DialogTitle.vue'
+export { default as DialogTrigger } from './DialogTrigger.vue'
diff --git a/frontend/src/components/ui/label/Label.vue b/frontend/src/components/ui/label/Label.vue
index a746407..c4e5b16 100644
--- a/frontend/src/components/ui/label/Label.vue
+++ b/frontend/src/components/ui/label/Label.vue
@@ -1,13 +1,13 @@
diff --git a/frontend/src/components/ui/label/index.ts b/frontend/src/components/ui/label/index.ts
index 036e35c..572c2f0 100644
--- a/frontend/src/components/ui/label/index.ts
+++ b/frontend/src/components/ui/label/index.ts
@@ -1 +1 @@
-export { default as Label } from "./Label.vue"
+export { default as Label } from './Label.vue'
diff --git a/frontend/src/components/ui/select/Select.vue b/frontend/src/components/ui/select/Select.vue
index 416fefd..b9b1f33 100644
--- a/frontend/src/components/ui/select/Select.vue
+++ b/frontend/src/components/ui/select/Select.vue
@@ -1,6 +1,6 @@
@@ -31,16 +26,26 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
-
+
diff --git a/frontend/src/components/ui/select/SelectGroup.vue b/frontend/src/components/ui/select/SelectGroup.vue
index 5e08945..37b1c9a 100644
--- a/frontend/src/components/ui/select/SelectGroup.vue
+++ b/frontend/src/components/ui/select/SelectGroup.vue
@@ -1,13 +1,13 @@
diff --git a/frontend/src/components/ui/select/SelectItem.vue b/frontend/src/components/ui/select/SelectItem.vue
index c4596a7..e320f3f 100644
--- a/frontend/src/components/ui/select/SelectItem.vue
+++ b/frontend/src/components/ui/select/SelectItem.vue
@@ -1,19 +1,14 @@
diff --git a/frontend/src/components/ui/select/SelectItemText.vue b/frontend/src/components/ui/select/SelectItemText.vue
index 3339d52..1cc7db1 100644
--- a/frontend/src/components/ui/select/SelectItemText.vue
+++ b/frontend/src/components/ui/select/SelectItemText.vue
@@ -1,6 +1,6 @@
diff --git a/frontend/src/components/ui/select/SelectLabel.vue b/frontend/src/components/ui/select/SelectLabel.vue
index c751840..0d68aa6 100644
--- a/frontend/src/components/ui/select/SelectLabel.vue
+++ b/frontend/src/components/ui/select/SelectLabel.vue
@@ -1,10 +1,10 @@
diff --git a/frontend/src/components/ui/select/SelectScrollDownButton.vue b/frontend/src/components/ui/select/SelectScrollDownButton.vue
index c999e65..70708ca 100644
--- a/frontend/src/components/ui/select/SelectScrollDownButton.vue
+++ b/frontend/src/components/ui/select/SelectScrollDownButton.vue
@@ -1,20 +1,23 @@
-
+
diff --git a/frontend/src/components/ui/select/SelectScrollUpButton.vue b/frontend/src/components/ui/select/SelectScrollUpButton.vue
index 4906cc8..8871414 100644
--- a/frontend/src/components/ui/select/SelectScrollUpButton.vue
+++ b/frontend/src/components/ui/select/SelectScrollUpButton.vue
@@ -1,20 +1,23 @@
-
+
diff --git a/frontend/src/components/ui/select/SelectSeparator.vue b/frontend/src/components/ui/select/SelectSeparator.vue
index 89c12ea..1cfbada 100644
--- a/frontend/src/components/ui/select/SelectSeparator.vue
+++ b/frontend/src/components/ui/select/SelectSeparator.vue
@@ -1,13 +1,13 @@
diff --git a/frontend/src/components/ui/select/SelectTrigger.vue b/frontend/src/components/ui/select/SelectTrigger.vue
index 1667365..8b57792 100644
--- a/frontend/src/components/ui/select/SelectTrigger.vue
+++ b/frontend/src/components/ui/select/SelectTrigger.vue
@@ -1,14 +1,14 @@
@@ -16,10 +16,12 @@ const forwardedProps = useForwardProps(delegatedProps)
diff --git a/frontend/src/components/ui/select/SelectValue.vue b/frontend/src/components/ui/select/SelectValue.vue
index d8bba4d..4ffa580 100644
--- a/frontend/src/components/ui/select/SelectValue.vue
+++ b/frontend/src/components/ui/select/SelectValue.vue
@@ -1,6 +1,6 @@
diff --git a/frontend/src/components/ui/select/index.ts b/frontend/src/components/ui/select/index.ts
index 96eae60..31b9294 100644
--- a/frontend/src/components/ui/select/index.ts
+++ b/frontend/src/components/ui/select/index.ts
@@ -1,11 +1,11 @@
-export { default as Select } from "./Select.vue"
-export { default as SelectContent } from "./SelectContent.vue"
-export { default as SelectGroup } from "./SelectGroup.vue"
-export { default as SelectItem } from "./SelectItem.vue"
-export { default as SelectItemText } from "./SelectItemText.vue"
-export { default as SelectLabel } from "./SelectLabel.vue"
-export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
-export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
-export { default as SelectSeparator } from "./SelectSeparator.vue"
-export { default as SelectTrigger } from "./SelectTrigger.vue"
-export { default as SelectValue } from "./SelectValue.vue"
+export { default as Select } from './Select.vue'
+export { default as SelectContent } from './SelectContent.vue'
+export { default as SelectGroup } from './SelectGroup.vue'
+export { default as SelectItem } from './SelectItem.vue'
+export { default as SelectItemText } from './SelectItemText.vue'
+export { default as SelectLabel } from './SelectLabel.vue'
+export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
+export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
+export { default as SelectSeparator } from './SelectSeparator.vue'
+export { default as SelectTrigger } from './SelectTrigger.vue'
+export { default as SelectValue } from './SelectValue.vue'
diff --git a/frontend/src/components/ui/tags-input/TagsInput.vue b/frontend/src/components/ui/tags-input/TagsInput.vue
index 356ae00..79219e9 100644
--- a/frontend/src/components/ui/tags-input/TagsInput.vue
+++ b/frontend/src/components/ui/tags-input/TagsInput.vue
@@ -1,20 +1,28 @@
-
+
diff --git a/frontend/src/components/ui/tags-input/TagsInputInput.vue b/frontend/src/components/ui/tags-input/TagsInputInput.vue
index 064822f..f34602f 100644
--- a/frontend/src/components/ui/tags-input/TagsInputInput.vue
+++ b/frontend/src/components/ui/tags-input/TagsInputInput.vue
@@ -1,17 +1,20 @@
-
+
diff --git a/frontend/src/components/ui/tags-input/TagsInputItem.vue b/frontend/src/components/ui/tags-input/TagsInputItem.vue
index a678d7e..df8a079 100644
--- a/frontend/src/components/ui/tags-input/TagsInputItem.vue
+++ b/frontend/src/components/ui/tags-input/TagsInputItem.vue
@@ -1,19 +1,27 @@
-
+
diff --git a/frontend/src/components/ui/tags-input/TagsInputItemDelete.vue b/frontend/src/components/ui/tags-input/TagsInputItemDelete.vue
index 9ec0acb..797fc3a 100644
--- a/frontend/src/components/ui/tags-input/TagsInputItemDelete.vue
+++ b/frontend/src/components/ui/tags-input/TagsInputItemDelete.vue
@@ -1,20 +1,23 @@
-
+
diff --git a/frontend/src/components/ui/tags-input/TagsInputItemText.vue b/frontend/src/components/ui/tags-input/TagsInputItemText.vue
index 3aac329..b48df1e 100644
--- a/frontend/src/components/ui/tags-input/TagsInputItemText.vue
+++ b/frontend/src/components/ui/tags-input/TagsInputItemText.vue
@@ -1,17 +1,20 @@
-
+
diff --git a/frontend/src/components/ui/tags-input/index.ts b/frontend/src/components/ui/tags-input/index.ts
index 8736692..31305f3 100644
--- a/frontend/src/components/ui/tags-input/index.ts
+++ b/frontend/src/components/ui/tags-input/index.ts
@@ -1,5 +1,5 @@
-export { default as TagsInput } from "./TagsInput.vue"
-export { default as TagsInputInput } from "./TagsInputInput.vue"
-export { default as TagsInputItem } from "./TagsInputItem.vue"
-export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue"
-export { default as TagsInputItemText } from "./TagsInputItemText.vue"
+export { default as TagsInput } from './TagsInput.vue'
+export { default as TagsInputInput } from './TagsInputInput.vue'
+export { default as TagsInputItem } from './TagsInputItem.vue'
+export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
+export { default as TagsInputItemText } from './TagsInputItemText.vue'
diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts
index cc8150b..5ef0cf2 100644
--- a/frontend/src/lib/api.d.ts
+++ b/frontend/src/lib/api.d.ts
@@ -82,6 +82,15 @@ export interface paths {
}
}
responses: {
+ /** @description The created course */
+ 201: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['Course']
+ }
+ }
/** @description The course is invalid */
400: {
headers: {
diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml
index db628d5..bf4b2c4 100644
--- a/frontend/src/locales/en.yml
+++ b/frontend/src/locales/en.yml
@@ -18,5 +18,75 @@ home:
courses: Find your courses and their resources.
pomodoro: Work in focused sessions with regular breaks.
calendar: Keep an eye on your exams and deadlines.
+theme:
+ dark: Switch to dark mode
+ light: Switch to light mode
+weekdays:
+ '1': Monday
+ '2': Tuesday
+ '3': Wednesday
+ '4': Thursday
+ '5': Friday
+common:
+ cancel: Cancel
+ save: Save
+ delete: Delete
+ edit: Edit
+ retry: Retry
+courses:
+ new: New course
+ edit: Edit course
+ empty: No course yet.
+ empty-hint: Add your first course to fill your timetable.
+ load-error: Unable to load the courses.
+ main-page: Course page
+ created: Course created
+ updated: Course updated
+ deleted: Course deleted
+ save-error: Unable to save the course
+ delete-error: Unable to delete the course
+ delete-title: Do you really want to delete “{name}”?
+ delete-description: The course and all its slots will be permanently deleted.
+ form:
+ description: Slots are then added from the timetable.
+ name: Name
+ name-placeholder: Advanced operating systems
+ code: Code
+ code-placeholder: CS-477
+ optional: optional
+ teachers: Teachers
+ teachers-placeholder: Name, then Enter
+ main-page: Main page
+ main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
+ weekday: Day
+ tag: Type
+ start: Start
+ end: End
+ errors:
+ name: The name is required.
+ teachers: Add at least one teacher.
+ main-page: The address must start with http:// or https://.
+ slot-time: Each slot must start before it ends, between 8:00 and 19:00.
+course:
+ back: Back to courses
+ not-found: This course does not exist.
+ slots: Slots
+ no-slot: No slot. Select a time range in the timetable to add one.
+schedule:
+ empty: No slot to show.
+ empty-hint: Click or drag on the timetable to add a slot.
+ no-course: Create a course first to add slots to it.
+ select-hint: Click or drag on the timetable to add a slot
+ go-to-courses: Go to courses
+ load-error: Unable to load the timetable.
+ new-slot: New slot
+ new-slot-description: The slot is added to the chosen course, every week.
+ slot-created: Slot added
+ slot-deleted: Slot deleted
+ slot-save-error: Unable to add the slot
+ slot-delete-error: Unable to delete the slot
+ delete-slot: Delete the slot
+ form:
+ course: Course
placeholder:
coming-soon: This page is coming soon.
diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml
index ebd4797..4a81203 100644
--- a/frontend/src/locales/fr.yml
+++ b/frontend/src/locales/fr.yml
@@ -18,5 +18,75 @@ home:
courses: Retrouve tes cours et leurs ressources.
pomodoro: Travaille par sessions concentrées avec des pauses régulières.
calendar: Garde un œil sur tes examens et échéances.
+theme:
+ dark: Passer en mode sombre
+ light: Passer en mode clair
+weekdays:
+ '1': Lundi
+ '2': Mardi
+ '3': Mercredi
+ '4': Jeudi
+ '5': Vendredi
+common:
+ cancel: Annuler
+ save: Enregistrer
+ delete: Supprimer
+ edit: Modifier
+ retry: Réessayer
+courses:
+ new: Nouveau cours
+ edit: Modifier le cours
+ empty: Aucun cours pour le moment.
+ empty-hint: Ajoute ton premier cours pour remplir ton emploi du temps.
+ load-error: Impossible de charger les cours.
+ main-page: Page du cours
+ created: Cours créé
+ updated: Cours modifié
+ deleted: Cours supprimé
+ save-error: Impossible d'enregistrer le cours
+ delete-error: Impossible de supprimer le cours
+ delete-title: Voulez-vous vraiment supprimer « {name} » ?
+ delete-description: Le cours et tous ses créneaux seront supprimés définitivement.
+ form:
+ description: Les créneaux s'ajoutent ensuite depuis l'emploi du temps.
+ name: Nom
+ name-placeholder: Advanced operating systems
+ code: Code
+ code-placeholder: CS-477
+ optional: facultatif
+ teachers: Enseignant·e·s
+ teachers-placeholder: Nom, puis Entrée
+ main-page: Page principale
+ main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
+ weekday: Jour
+ tag: Type
+ start: Début
+ end: Fin
+ errors:
+ name: Le nom est obligatoire.
+ teachers: Ajoute au moins un·e enseignant·e.
+ main-page: L'adresse doit commencer par http:// ou https://.
+ slot-time: Chaque créneau doit commencer avant de finir, entre 8h et 19h.
+course:
+ back: Retour aux cours
+ not-found: Ce cours n'existe pas.
+ slots: Créneaux
+ no-slot: Aucun créneau. Sélectionne une plage horaire dans l'emploi du temps pour en ajouter.
+schedule:
+ empty: Aucun créneau à afficher.
+ empty-hint: Clique ou glisse sur le calendrier pour ajouter un créneau.
+ no-course: Crée d'abord un cours pour pouvoir lui ajouter des créneaux.
+ select-hint: Clique ou glisse sur le calendrier pour ajouter un créneau
+ go-to-courses: Aller aux cours
+ load-error: Impossible de charger l'emploi du temps.
+ new-slot: Nouveau créneau
+ new-slot-description: Le créneau est ajouté au cours choisi, chaque semaine.
+ slot-created: Créneau ajouté
+ slot-deleted: Créneau supprimé
+ slot-save-error: Impossible d'ajouter le créneau
+ slot-delete-error: Impossible de supprimer le créneau
+ delete-slot: Supprimer le créneau
+ form:
+ course: Cours
placeholder:
coming-soon: Cette page arrive bientôt.
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 96bcccb..5cfabbb 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -6,6 +6,8 @@ declare module 'vue-router' {
interface RouteMeta {
/** i18n key of the page title, shown in the header */
title: string
+ /** Name of the sidebar section highlighted on this page, when it is not the page itself */
+ section?: string
}
}
@@ -25,6 +27,13 @@ const router = createRouter({
component: () => import('@/views/CoursesView.vue'),
meta: { title: 'nav.courses' },
},
+ {
+ name: 'course',
+ path: '/courses/:id(\\d+)',
+ component: () => import('@/views/CourseView.vue'),
+ props: (route) => ({ id: Number(route.params.id) }),
+ meta: { title: 'nav.courses', section: 'courses' },
+ },
{
name: 'pomodoro',
path: '/pomodoro',
diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts
index 6ecf74e..2836b36 100644
--- a/frontend/src/services/api/client.ts
+++ b/frontend/src/services/api/client.ts
@@ -17,6 +17,9 @@ client.use({
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
)
throw new Error('Server error')
+ } else if (response.status === HttpStatus.BAD_REQUEST) {
+ // The backend explains what is wrong with the request in the body
+ throw new Error(await response.clone().text())
} else if (response.status === HttpStatus.NOT_FOUND) {
throw new Error(`Not found: ${response.url}`)
}
diff --git a/frontend/src/services/api/courses.ts b/frontend/src/services/api/courses.ts
new file mode 100644
index 0000000..44afa84
--- /dev/null
+++ b/frontend/src/services/api/courses.ts
@@ -0,0 +1,104 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { HttpStatus } from 'http-status-ts'
+
+import type { Course, CourseBody, TimeSlotBody } from '@/utils/types'
+import { getClient } from './client'
+
+const COURSES_KEY = ['courses']
+
+function sortByName(courses: Course[]) {
+ return [...courses].sort((a, b) => a.name.localeCompare(b.name))
+}
+
+/**
+ * Body that keeps `course` as is, except its slots when `slots` is given. Needed because
+ * the update replaces the whole course, slots included.
+ */
+export function toCourseBody(course: Course, slots?: TimeSlotBody[]): CourseBody {
+ return {
+ name: course.name,
+ code: course.code,
+ teachers: course.teachers,
+ main_page_url: course.main_page_url,
+ slots:
+ slots ??
+ course.slots.map(({ tag_id, weekday, start_time, end_time }) => ({
+ tag_id,
+ weekday,
+ start_time,
+ end_time,
+ })),
+ }
+}
+
+export function useCourses() {
+ return useQuery({
+ queryKey: COURSES_KEY,
+ queryFn: async () => {
+ const { data, response } = await getClient().GET('/api/courses')
+ if (response.status === HttpStatus.OK && data) {
+ return data
+ }
+ throw new Error(`Unexpected status code received: ${response.status}`)
+ },
+ })
+}
+
+export function useCreateCourse() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (body: CourseBody) => {
+ const { data, response } = await getClient().POST('/api/courses', { body })
+ if (response.status === HttpStatus.CREATED && data) {
+ return data
+ }
+ throw new Error(`Unexpected status code received: ${response.status}`)
+ },
+ onSuccess: (course) => {
+ queryClient.setQueryData(COURSES_KEY, (courses) =>
+ sortByName([...(courses ?? []), course]),
+ )
+ },
+ })
+}
+
+export function useUpdateCourse() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({ id, body }: { id: number; body: CourseBody }) => {
+ const { data, response } = await getClient().PUT('/api/courses/{id}', {
+ params: { path: { id } },
+ body,
+ })
+ if (response.status === HttpStatus.OK && data) {
+ return data
+ }
+ throw new Error(`Unexpected status code received: ${response.status}`)
+ },
+ onSuccess: (course) => {
+ queryClient.setQueryData(COURSES_KEY, (courses) =>
+ sortByName((courses ?? []).map((c) => (c.id === course.id ? course : c))),
+ )
+ },
+ })
+}
+
+export function useDeleteCourse() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (id: number) => {
+ const { response } = await getClient().DELETE('/api/courses/{id}', {
+ params: { path: { id } },
+ })
+ if (response.status !== HttpStatus.NO_CONTENT) {
+ throw new Error(`Unexpected status code received: ${response.status}`)
+ }
+ return id
+ },
+ onSuccess: (id) => {
+ queryClient.setQueryData(COURSES_KEY, (courses) =>
+ (courses ?? []).filter((c) => c.id !== id),
+ )
+ },
+ })
+}
diff --git a/frontend/src/services/api/slot-tags.ts b/frontend/src/services/api/slot-tags.ts
new file mode 100644
index 0000000..a9226c9
--- /dev/null
+++ b/frontend/src/services/api/slot-tags.ts
@@ -0,0 +1,35 @@
+import { useQuery } from '@tanstack/vue-query'
+import { HttpStatus } from 'http-status-ts'
+import { computed } from 'vue'
+
+import type { SlotTag } from '@/utils/types'
+import { i18n } from '../i18n'
+import { getClient } from './client'
+
+/** Kinds of time slots (lecture, exercises, lab...), with their name in the current locale */
+export function useSlotTags() {
+ const query = useQuery({
+ queryKey: ['slot-tags'],
+ staleTime: 3600 * 1000,
+ queryFn: async () => {
+ const { data, response } = await getClient().GET('/api/slot-tags')
+ if (response.status === HttpStatus.OK && data) {
+ return data
+ }
+ throw new Error(`Unexpected status code received: ${response.status}`)
+ },
+ })
+
+ const locale = i18n.locale
+
+ const tags = computed(
+ () =>
+ query.data.value?.map((tag) => ({
+ ...tag,
+ name: tag.name[locale.value],
+ })) ?? [],
+ )
+ const tagsById = computed(() => new Map(tags.value.map((tag) => [tag.id, tag])))
+
+ return { ...query, data: tags, tagsById }
+}
diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css
index 5997be4..a603325 100644
--- a/frontend/src/styles/tailwind.css
+++ b/frontend/src/styles/tailwind.css
@@ -50,7 +50,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
+ --primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@@ -62,7 +62,7 @@
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
+ --ring: oklch(0.707 0.165 254.624);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -70,12 +70,12 @@
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-accent: oklch(0.932 0.032 255.585);
+ --sidebar-accent-foreground: oklch(0.424 0.199 265.638);
--sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --sidebar-ring: oklch(0.707 0.165 254.624);
}
.dark {
@@ -85,8 +85,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.985 0 0);
- --primary-foreground: oklch(0.205 0 0);
+ --primary: oklch(0.623 0.214 259.815);
+ --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -97,7 +97,7 @@
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
- --ring: oklch(0.439 0 0);
+ --ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -105,12 +105,12 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.282 0.091 267.935);
+ --sidebar-accent-foreground: oklch(0.882 0.059 254.128);
--sidebar-border: oklch(0.269 0 0);
- --sidebar-ring: oklch(0.439 0 0);
+ --sidebar-ring: oklch(0.488 0.243 264.376);
}
@layer base {
diff --git a/frontend/src/utils/timetable.ts b/frontend/src/utils/timetable.ts
new file mode 100644
index 0000000..759f12e
--- /dev/null
+++ b/frontend/src/utils/timetable.ts
@@ -0,0 +1,85 @@
+/** Days and hours shown by the timetable. The backend enforces the same bounds. */
+export const WEEKDAYS = [1, 2, 3, 4, 5] as const
+export const DAY_START_HOUR = 8
+export const DAY_END_HOUR = 19
+export const DAY_START = '08:00'
+export const DAY_END = '19:00'
+
+/** "10:15:00" or "10:15" -> 615 */
+export function toMinutes(time: string): number {
+ const [hours = 0, minutes = 0] = time.split(':').map(Number)
+ return hours * 60 + minutes
+}
+
+/** 615 -> "10:15" */
+export function fromMinutes(minutes: number): string {
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`
+}
+
+/** "10:15:00" -> "10:15", the format of `` */
+export function toHourMinutes(time: string): string {
+ return time.slice(0, 5)
+}
+
+/** "10:15" -> "10:15:00", the format expected by the backend */
+export function toApiTime(time: string): string {
+ return time.length === 5 ? `${time}:00` : time
+}
+
+/** Time range picked in the timetable, times as "HH:MM" */
+export interface SlotSelection {
+ weekday: number
+ start: string
+ end: string
+}
+
+export interface Positioned {
+ item: T
+ /** Index of the column the item is drawn in, among `lanes` */
+ lane: number
+ /** Number of columns shared by the items overlapping this one */
+ lanes: number
+}
+
+/**
+ * Lays out the events of a day so that overlapping ones are drawn side by side.
+ * Events are grouped into clusters of (transitively) overlapping events; inside a
+ * cluster each event gets the first column that is free at its start time.
+ */
+export function layoutOverlapping(
+ items: T[],
+ start: (item: T) => number,
+ end: (item: T) => number,
+): Positioned[] {
+ const sorted = [...items].sort((a, b) => start(a) - start(b) || end(b) - end(a))
+ const result: Positioned[] = []
+
+ let cluster: Positioned[] = []
+ let laneEnds: number[] = []
+ let clusterEnd = -Infinity
+
+ const closeCluster = () => {
+ for (const positioned of cluster) positioned.lanes = laneEnds.length
+ result.push(...cluster)
+ cluster = []
+ laneEnds = []
+ }
+
+ for (const item of sorted) {
+ if (start(item) >= clusterEnd) closeCluster()
+
+ let lane = laneEnds.findIndex((laneEnd) => laneEnd <= start(item))
+ if (lane === -1) {
+ lane = laneEnds.length
+ laneEnds.push(end(item))
+ } else {
+ laneEnds[lane] = end(item)
+ }
+ cluster.push({ item, lane, lanes: 0 })
+ clusterEnd = Math.max(clusterEnd, end(item))
+ }
+ closeCluster()
+
+ return result
+}
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index f73305a..4dd2fcf 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -1,2 +1,15 @@
-// Shorthands over the generated schemas, so views never import `@/lib/api` directly, e.g.
-// export type Course = components['schemas']['Course']
+import type { components } from '@/lib/api'
+
+// Shorthands over the generated schemas, so views never import `@/lib/api` directly
+export type LocalizedString = components['schemas']['LocalizedString']
+export type Course = components['schemas']['Course']
+export type CourseBody = components['schemas']['CourseBody']
+export type TimeSlot = components['schemas']['TimeSlot']
+export type TimeSlotBody = components['schemas']['TimeSlotBody']
+
+/** Slot tag with its name resolved in the current locale */
+export interface SlotTag {
+ id: number
+ name: string
+ color: string
+}
diff --git a/frontend/src/views/CourseView.vue b/frontend/src/views/CourseView.vue
new file mode 100644
index 0000000..fc3fa36
--- /dev/null
+++ b/frontend/src/views/CourseView.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+ {{ $t('course.back') }}
+
+
+
+
+
+
+
+ {{ isError ? $t('courses.load-error') : $t('course.not-found') }}
+
+
+ {{ $t('common.retry') }}
+
+
+
+
+
+
+ {{ course.name }}
+
+ {{ course.code }}
+
+
+ {{ course.teachers.join(', ') }}
+
+
+
+ {{ $t('courses.main-page') }}
+
+
+
+
+
+ {{ $t('course.slots') }}
+
+ -
+
+ {{ $t(`weekdays.${slot.weekday}`) }}
+
+ {{ toHourMinutes(slot.start_time) }}–{{ toHourMinutes(slot.end_time) }}
+
+
+ {{ tagsById.get(slot.tag_id)?.name }}
+
+
+
+ {{ $t('schedule.delete-slot') }}
+
+
+
+ {{ $t('course.no-slot') }}
+
+
+
+
diff --git a/frontend/src/views/CoursesView.vue b/frontend/src/views/CoursesView.vue
index 027a781..219e670 100644
--- a/frontend/src/views/CoursesView.vue
+++ b/frontend/src/views/CoursesView.vue
@@ -1,7 +1,157 @@
-
+
+
+
+
+ {{ $t('courses.new') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('courses.load-error') }}
+
+
+ {{ $t('common.retry') }}
+
+
+
+
+
+
+ {{ $t('courses.empty') }}
+ {{ $t('courses.empty-hint') }}
+
+
+
+
+ {{ $t('courses.new') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ course.name }}
+
+
+
+ {{ course.code }}
+
+
+
{{ course.teachers.join(', ') }}
+
+
+
+
+
+ {{ $t('common.edit') }}
+
+
+
+ {{ $t('common.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('courses.delete-title', { name: deletedCourse?.name }) }}
+
+ {{ $t('courses.delete-description') }}
+
+
+ {{ $t('common.cancel') }}
+
+ {{ $t('common.delete') }}
+
+
+
+
+
diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue
index 0dabfa6..9cbf47c 100644
--- a/frontend/src/views/ScheduleView.vue
+++ b/frontend/src/views/ScheduleView.vue
@@ -1,7 +1,285 @@
-
+
+
+
+
+ {{ tag.name }}
+
+
+ {{ $t('schedule.select-hint') }}
+
+
+
+
+
+
+
+ {{ $t('schedule.load-error') }}
+
+
+ {{ $t('common.retry') }}
+
+
+
+
+
+
+
+ {{ $t('schedule.empty') }}
+
+ {{ courses?.length ? $t('schedule.empty-hint') : $t('schedule.no-course') }}
+
+
+
+ {{ $t('schedule.go-to-courses') }}
+
+
+
+
+
+
+
+
+ {{ $t(`weekdays.${day.weekday}`) }}
+
+
+
+
+
+ {{ hour }}:00
+
+
+
+
+
+
+
+
+
+
+
+ {{ block.course.name }}
+
+
+ {{ toHourMinutes(block.slot.start_time) }}–{{
+ toHourMinutes(block.slot.end_time)
+ }}
+ · {{ block.tag?.name }}
+
+
+
+
+ {{ block.course.name }}
+ {{ block.course.code }}
+
+ {{ block.tag?.name }} · {{ $t(`weekdays.${block.slot.weekday}`) }}
+ {{ toHourMinutes(block.slot.start_time) }}–{{
+ toHourMinutes(block.slot.end_time)
+ }}
+
+ {{ block.course.teachers.join(', ') }}
+
+
+
+
+ {{ fromMinutes(selectedRange.start) }}–{{ fromMinutes(selectedRange.end) }}
+
+
+
+
+
+
+
+
diff --git a/src/api/courses.rs b/src/api/courses.rs
index 3be757c..226e19c 100644
--- a/src/api/courses.rs
+++ b/src/api/courses.rs
@@ -57,6 +57,7 @@ async fn create_course(
fn create_course_docs(op: TransformOperation) -> TransformOperation {
op.tag("Courses")
.summary("Create a course with its time slots")
+ .response_with::<201, Json, _>(desc("The created course"))
.response_with::<400, String, _>(desc("The course is invalid"))
}