This commit is contained in:
Antoine Pelletier 2026-09-28 16:35:30 +02:00
parent 07d98b64b3
commit fbedcf97e3
56 changed files with 2108 additions and 254 deletions

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"i18n-ally.localesPaths": [
"frontend/src/locales"
]
}

BIN
LaTeX_pdf/assets/epfl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

60
LaTeX_pdf/assignment.cls Normal file
View file

@ -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}
}

27
LaTeX_pdf/box-filter.lua Normal file
View file

@ -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

78
LaTeX_pdf/modele.md Normal file
View file

@ -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"
---
<!--
MODELE DE COURS EN MARKDOWN
============================
- Chaque "\section{...}" du LaTeX devient un titre "# " en Markdown.
- Chaque "\subsection{...}" devient "## ".
- Les listes à puces \begin{itemize} deviennent simplement des "-".
- Le gras \textbf{...} devient **...** et l'italique \textit{...} devient *...*.
- Pour les encadrés Def./Theo./Ex. (tcolorbox), on garde du LaTeX brut :
pandoc laisse passer tel quel tout bloc LaTeX qu'il ne reconnaît pas
en Markdown, donc \begin{defbox}[titre] ... \end{defbox} fonctionne
directement dans le .md.
- Les blocs de code ```c ... ``` sont automatiquement transformés en
\begin{lstlisting} grâce à l'option --listings de pandoc.
-->
# 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.

156
LaTeX_pdf/template.tex Normal file
View file

@ -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}

138
LaTeX_pdf/template.tex.bak Normal file
View file

@ -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}

View file

@ -11,14 +11,15 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar' import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { locales, setLocale } from '@/services/i18n' import { locales, setLocale } from '@/services/i18n'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const route = useRoute() const route = useRoute()
const { locale: currentLocale } = useI18n() const { locale: currentLocale } = useI18n()
const selectedLangFlag = computed( const selectedLocale = computed(
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag, () => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
) )
</script> </script>
@ -29,20 +30,22 @@ const selectedLangFlag = computed(
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1> <h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1>
<ThemeToggle class="ml-auto" />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="ml-auto"> <Button variant="outline" class="rounded-lg">
<span :class="'fi fi-' + selectedLangFlag"></span> <span :class="'fi fi-' + selectedLocale.flag"></span>
{{ selectedLocale.lang.toUpperCase() }}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-0"> <DropdownMenuContent align="end" class="min-w-0">
<DropdownMenuItem <DropdownMenuItem
v-for="locale in locales" v-for="locale in locales"
:key="locale.lang" :key="locale.lang"
class="justify-center"
@click="setLocale(locale.lang)" @click="setLocale(locale.lang)"
> >
<span :class="'fi fi-' + locale.flag"></span> <span :class="'fi fi-' + locale.flag"></span>
{{ locale.lang.toUpperCase() }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -66,7 +66,7 @@ function onNavigate() {
<SidebarMenuItem v-for="section in sections" :key="section.name"> <SidebarMenuItem v-for="section in sections" :key="section.name">
<SidebarMenuButton <SidebarMenuButton
as-child as-child
:is-active="route.name === section.name" :is-active="(route.meta.section ?? route.name) === section.name"
:tooltip="$t(section.title)" :tooltip="$t(section.title)"
> >
<RouterLink :to="{ name: section.name }" @click="onNavigate"> <RouterLink :to="{ name: section.name }" @click="onNavigate">

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import { Moon, Sun } from '@lucide/vue'
import { useDark, useToggle } from '@vueuse/core'
import { Button } from '@/components/ui/button'
// Adds the `dark` class on <html>, follows the system until the user picks a theme,
// and remembers the choice in the local storage
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
<template>
<Button
variant="outline"
size="icon"
class="rounded-lg"
:title="isDark ? $t('theme.light') : $t('theme.dark')"
@click="toggleDark()"
>
<Sun v-if="isDark" />
<Moon v-else />
<span class="sr-only">{{ isDark ? $t('theme.light') : $t('theme.dark') }}</span>
</Button>
</template>

View file

@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* Creates a course, or edits `course` when given. Slots are not edited here but by
* selecting a time range in the schedule: editing a course keeps its slots.
*/
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
} from '@/components/ui/tags-input'
import { toCourseBody, useCreateCourse, useUpdateCourse } from '@/services/api/courses'
import type { Course, CourseBody } from '@/utils/types'
const props = defineProps<{ course?: Course }>()
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const createCourse = useCreateCourse()
const updateCourse = useUpdateCourse()
const name = ref('')
const code = ref('')
const teachers = ref<string[]>([])
const mainPageUrl = ref('')
const submitted = ref(false)
// Reset the form every time the dialog opens
watch(
open,
(isOpen) => {
if (!isOpen) return
const course = props.course
name.value = course?.name ?? ''
code.value = course?.code ?? ''
teachers.value = [...(course?.teachers ?? [])]
mainPageUrl.value = course?.main_page_url ?? ''
submitted.value = false
},
{ immediate: true },
)
// Same rules as the backend, checked here to point at the faulty field
const errors = computed(() => {
const url = mainPageUrl.value.trim()
return {
name: !name.value.trim(),
teachers: !teachers.value.some((teacher) => teacher.trim()),
mainPage: url !== '' && !/^https?:\/\//.test(url),
}
})
const isValid = computed(() => !Object.values(errors.value).some(Boolean))
const isPending = computed(() => createCourse.isPending.value || updateCourse.isPending.value)
async function submit() {
submitted.value = true
if (!isValid.value) return
const body: CourseBody = {
name: name.value.trim(),
code: code.value.trim() || null,
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
main_page_url: mainPageUrl.value.trim() || null,
slots: props.course ? toCourseBody(props.course).slots : [],
}
try {
if (props.course) {
await updateCourse.mutateAsync({ id: props.course.id, body })
toast.success(t('courses.updated'))
} else {
await createCourse.mutateAsync(body)
toast.success(t('courses.created'))
}
open.value = false
} catch (err) {
toast.error(t('courses.save-error'), {
description: err instanceof Error ? err.message : undefined,
})
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[90svh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ course ? $t('courses.edit') : $t('courses.new') }}</DialogTitle>
<DialogDescription>{{ $t('courses.form.description') }}</DialogDescription>
</DialogHeader>
<form id="course-form" class="flex flex-col gap-5" novalidate @submit.prevent="submit">
<div class="grid gap-4 sm:grid-cols-[1fr_9rem]">
<div class="flex flex-col gap-2">
<Label for="course-name">{{ $t('courses.form.name') }}</Label>
<Input
id="course-name"
v-model="name"
:placeholder="$t('courses.form.name-placeholder')"
:aria-invalid="submitted && errors.name"
/>
<p v-if="submitted && errors.name" class="text-sm text-destructive">
{{ $t('courses.form.errors.name') }}
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="course-code">
{{ $t('courses.form.code') }}
<span class="font-normal text-muted-foreground">
({{ $t('courses.form.optional') }})
</span>
</Label>
<Input
id="course-code"
v-model="code"
:placeholder="$t('courses.form.code-placeholder')"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<Label for="course-teachers">{{ $t('courses.form.teachers') }}</Label>
<TagsInput
v-model="teachers"
add-on-blur
add-on-paste
:aria-invalid="submitted && errors.teachers"
class="aria-invalid:border-destructive"
>
<TagsInputItem v-for="teacher in teachers" :key="teacher" :value="teacher">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
id="course-teachers"
:placeholder="$t('courses.form.teachers-placeholder')"
/>
</TagsInput>
<p v-if="submitted && errors.teachers" class="text-sm text-destructive">
{{ $t('courses.form.errors.teachers') }}
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="course-main-page">
{{ $t('courses.form.main-page') }}
<span class="font-normal text-muted-foreground">
({{ $t('courses.form.optional') }})
</span>
</Label>
<Input
id="course-main-page"
v-model="mainPageUrl"
type="url"
:placeholder="$t('courses.form.main-page-placeholder')"
:aria-invalid="submitted && errors.mainPage"
/>
<p v-if="submitted && errors.mainPage" class="text-sm text-destructive">
{{ $t('courses.form.errors.main-page') }}
</p>
</div>
</form>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
<Button type="submit" form="course-form" :disabled="isPending">
{{ $t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,204 @@
<script setup lang="ts">
/**
* Adds a slot to a course, prefilled with the time range selected in the schedule.
* The course is updated as a whole since its update replaces all its slots.
*/
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toCourseBody, useCourses, useUpdateCourse } from '@/services/api/courses'
import { useSlotTags } from '@/services/api/slot-tags'
import {
DAY_END,
DAY_START,
WEEKDAYS,
toApiTime,
toMinutes,
type SlotSelection,
} from '@/utils/timetable'
const props = defineProps<{ selection?: SlotSelection }>()
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const { data: courses } = useCourses()
const { data: tags } = useSlotTags()
const updateCourse = useUpdateCourse()
const courseId = ref<number>()
const tagId = ref<number>()
const weekday = ref(1)
const start = ref('')
const end = ref('')
const submitted = ref(false)
// Reset the form every time the dialog opens, keeping the last course and tag picked
watch(
open,
(isOpen) => {
if (!isOpen) return
weekday.value = props.selection?.weekday ?? 1
start.value = props.selection?.start ?? '08:15'
end.value = props.selection?.end ?? '10:00'
if (!courses.value?.some((course) => course.id === courseId.value)) {
courseId.value = courses.value?.[0]?.id
}
tagId.value ??= tags.value[0]?.id
submitted.value = false
},
{ immediate: true },
)
// Same rules as the backend, checked here to point at the faulty field
const errors = computed(() => {
const startMinutes = toMinutes(start.value)
const endMinutes = toMinutes(end.value)
return {
course: courseId.value === undefined,
time:
!start.value ||
!end.value ||
startMinutes >= endMinutes ||
startMinutes < toMinutes(DAY_START) ||
endMinutes > toMinutes(DAY_END),
}
})
async function submit() {
submitted.value = true
const course = courses.value?.find((c) => c.id === courseId.value)
if (errors.value.course || errors.value.time || !course || tagId.value === undefined) return
const slots = [
...toCourseBody(course).slots,
{
tag_id: tagId.value,
weekday: weekday.value,
start_time: toApiTime(start.value),
end_time: toApiTime(end.value),
},
]
try {
await updateCourse.mutateAsync({ id: course.id, body: toCourseBody(course, slots) })
toast.success(t('schedule.slot-created'))
open.value = false
} catch (err) {
toast.error(t('schedule.slot-save-error'), {
description: err instanceof Error ? err.message : undefined,
})
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('schedule.new-slot') }}</DialogTitle>
<DialogDescription>{{ $t('schedule.new-slot-description') }}</DialogDescription>
</DialogHeader>
<form id="slot-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
<div class="col-span-2 flex flex-col gap-2">
<Label for="slot-course">{{ $t('schedule.form.course') }}</Label>
<Select v-model="courseId">
<SelectTrigger id="slot-course" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name }}
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
{{ course.code }}
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-2">
<Label for="slot-tag">{{ $t('courses.form.tag') }}</Label>
<Select v-model="tagId">
<SelectTrigger id="slot-tag" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="tag in tags" :key="tag.id" :value="tag.id">
<span class="size-2 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-2">
<Label for="slot-weekday">{{ $t('courses.form.weekday') }}</Label>
<Select v-model="weekday">
<SelectTrigger id="slot-weekday" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="day in WEEKDAYS" :key="day" :value="day">
{{ $t(`weekdays.${day}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-2">
<Label for="slot-start">{{ $t('courses.form.start') }}</Label>
<Input
id="slot-start"
v-model="start"
type="time"
:min="DAY_START"
:max="DAY_END"
step="300"
:aria-invalid="submitted && errors.time"
/>
</div>
<div class="flex flex-col gap-2">
<Label for="slot-end">{{ $t('courses.form.end') }}</Label>
<Input
id="slot-end"
v-model="end"
type="time"
:min="DAY_START"
:max="DAY_END"
step="300"
:aria-invalid="submitted && errors.time"
/>
</div>
<p v-if="submitted && errors.time" class="col-span-2 text-sm text-destructive">
{{ $t('courses.form.errors.slot-time') }}
</p>
</form>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
<Button type="submit" form="slot-form" :disabled="updateCourse.isPending.value">
{{ $t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui" import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from "reka-ui" import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>() const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>() const emits = defineEmits<DialogRootEmits>()

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogCloseProps } from "reka-ui" import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from "reka-ui" import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>() const props = defineProps<DialogCloseProps>()
</script> </script>

View file

@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { X } from "lucide-vue-next" import { X } from '@lucide/vue'
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -31,7 +31,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
cn( 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', '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, props.class,
)" )
"
> >
<slot /> <slot />

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui" import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from "reka-ui" import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>

View file

@ -1,19 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes["class"] }>() const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot /> <slot />
</div> </div>
</template> </template>

View file

@ -1,16 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes['class']
}>() }>()
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot /> <slot />
</div> </div>
</template> </template>

View file

@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { X } from "lucide-vue-next" import { X } from '@lucide/vue'
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -33,13 +33,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
) )
" "
v-bind="forwarded" v-bind="forwarded"
@pointer-down-outside="(event) => { @pointer-down-outside="
const originalEvent = event.detail.originalEvent; (event) => {
const target = originalEvent.target as HTMLElement; const originalEvent = event.detail.originalEvent
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) { const target = originalEvent.target as HTMLElement
event.preventDefault(); if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault()
} }
}" }
"
> >
<slot /> <slot />

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogTitleProps } from "reka-ui" import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from "reka-ui" import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@ -15,12 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<template> <template>
<DialogTitle <DialogTitle
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
> >
<slot /> <slot />
</DialogTitle> </DialogTitle>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui" import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from "reka-ui" import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>() const props = defineProps<DialogTriggerProps>()
</script> </script>

View file

@ -1,9 +1,9 @@
export { default as Dialog } from "./Dialog.vue" export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from "./DialogClose.vue" export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from "./DialogContent.vue" export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from "./DialogDescription.vue" export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from "./DialogFooter.vue" export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from "./DialogHeader.vue" export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogScrollContent } from "./DialogScrollContent.vue" export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from "./DialogTitle.vue" export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from "./DialogTrigger.vue" export { default as DialogTrigger } from './DialogTrigger.vue'

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LabelProps } from "reka-ui" import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { Label } from "reka-ui" import { Label } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
</script> </script>
<template> <template>

View file

@ -1 +1 @@
export { default as Label } from "./Label.vue" export { default as Label } from './Label.vue'

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui" import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from "reka-ui" import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>() const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>() const emits = defineEmits<SelectRootEmits>()

View file

@ -1,29 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
SelectContent, import { cn } from '@/lib/utils'
SelectPortal, import { SelectScrollDownButton, SelectScrollUpButton } from '.'
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults( const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{ {
position: "popper", position: 'popper',
}, },
) )
const emits = defineEmits<SelectContentEmits>() const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -31,16 +26,26 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<SelectPortal> <SelectPortal>
<SelectContent <SelectContent
v-bind="{ ...forwarded, ...$attrs }" :class="cn( v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' position === 'popper' &&
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class, props.class,
) )
" "
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)')"> <SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)',
)
"
>
<slot /> <slot />
</SelectViewport> </SelectViewport>
<SelectScrollDownButton /> <SelectScrollDownButton />

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectGroupProps } from "reka-ui" import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from "reka-ui" import { SelectGroup } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
</script> </script>
<template> <template>

View file

@ -1,19 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemProps } from "reka-ui" import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { Check } from "lucide-vue-next" import { Check } from '@lucide/vue'
import { import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
SelectItem, import { cn } from '@/lib/utils'
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui" import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from "reka-ui" import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>() const props = defineProps<SelectItemTextProps>()
</script> </script>

View file

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectLabelProps } from "reka-ui" import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { SelectLabel } from "reka-ui" import { SelectLabel } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script> </script>
<template> <template>

View file

@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollDownButtonProps } from "reka-ui" import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from "lucide-vue-next" import { ChevronDown } from '@lucide/vue'
import { SelectScrollDownButton, useForwardProps } from "reka-ui" import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)"> <SelectScrollDownButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot> <slot>
<ChevronDown class="h-4 w-4" /> <ChevronDown class="h-4 w-4" />
</slot> </slot>

View file

@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollUpButtonProps } from "reka-ui" import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { ChevronUp } from "lucide-vue-next" import { ChevronUp } from '@lucide/vue'
import { SelectScrollUpButton, useForwardProps } from "reka-ui" import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)"> <SelectScrollUpButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot> <slot>
<ChevronUp class="h-4 w-4" /> <ChevronUp class="h-4 w-4" />
</slot> </slot>

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui" import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from "reka-ui" import { SelectSeparator } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
</script> </script>
<template> <template>

View file

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui" import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from "lucide-vue-next" import { ChevronDown } from '@lucide/vue'
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui" import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@ -16,10 +16,12 @@ const forwardedProps = useForwardProps(delegatedProps)
<template> <template>
<SelectTrigger <SelectTrigger
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start', 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class, props.class,
)" )
"
> >
<slot /> <slot />
<SelectIcon as-child> <SelectIcon as-child>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectValueProps } from "reka-ui" import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from "reka-ui" import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>() const props = defineProps<SelectValueProps>()
</script> </script>

View file

@ -1,11 +1,11 @@
export { default as Select } from "./Select.vue" export { default as Select } from './Select.vue'
export { default as SelectContent } from "./SelectContent.vue" export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from "./SelectGroup.vue" export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from "./SelectItem.vue" export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from "./SelectItemText.vue" export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from "./SelectLabel.vue" export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue" export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue" export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from "./SelectSeparator.vue" export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from "./SelectTrigger.vue" export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from "./SelectValue.vue" export { default as SelectValue } from './SelectValue.vue'

View file

@ -1,20 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TagsInputRootEmits, TagsInputRootProps } from "reka-ui" import type { TagsInputRootEmits, TagsInputRootProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { TagsInputRoot, useForwardPropsEmits } from "reka-ui" import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits>() const emits = defineEmits<TagsInputRootEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<TagsInputRoot v-bind="forwarded" :class="cn('flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-3 py-2 text-sm', props.class)"> <TagsInputRoot
v-bind="forwarded"
:class="
cn(
'flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-3 py-2 text-sm',
props.class,
)
"
>
<slot /> <slot />
</TagsInputRoot> </TagsInputRoot>
</template> </template>

View file

@ -1,17 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TagsInputInputProps } from "reka-ui" import type { TagsInputInputProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { TagsInputInput, useForwardProps } from "reka-ui" import { TagsInputInput, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<TagsInputInput v-bind="forwardedProps" :class="cn('text-sm min-h-6 focus:outline-none flex-1 bg-transparent px-1', props.class)" /> <TagsInputInput
v-bind="forwardedProps"
:class="cn('text-sm min-h-6 focus:outline-none flex-1 bg-transparent px-1', props.class)"
/>
</template> </template>

View file

@ -1,19 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TagsInputItemProps } from "reka-ui" import type { TagsInputItemProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { TagsInputItem, useForwardProps } from "reka-ui" import { TagsInputItem, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<TagsInputItem v-bind="forwardedProps" :class="cn('flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background', props.class)"> <TagsInputItem
v-bind="forwardedProps"
:class="
cn(
'flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background',
props.class,
)
"
>
<slot /> <slot />
</TagsInputItem> </TagsInputItem>
</template> </template>

View file

@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TagsInputItemDeleteProps } from "reka-ui" import type { TagsInputItemDeleteProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { X } from "lucide-vue-next" import { X } from '@lucide/vue'
import { TagsInputItemDelete, useForwardProps } from "reka-ui" import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<TagsInputItemDelete v-bind="forwardedProps" :class="cn('flex rounded bg-transparent mr-1', props.class)"> <TagsInputItemDelete
v-bind="forwardedProps"
:class="cn('flex rounded bg-transparent mr-1', props.class)"
>
<slot> <slot>
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</slot> </slot>

View file

@ -1,17 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TagsInputItemTextProps } from "reka-ui" import type { TagsInputItemTextProps } from 'reka-ui'
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from '@vueuse/core'
import { TagsInputItemText, useForwardProps } from "reka-ui" import { TagsInputItemText, useForwardProps } from 'reka-ui'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<TagsInputItemText v-bind="forwardedProps" :class="cn('py-1 px-2 text-sm rounded bg-transparent', props.class)" /> <TagsInputItemText
v-bind="forwardedProps"
:class="cn('py-1 px-2 text-sm rounded bg-transparent', props.class)"
/>
</template> </template>

View file

@ -1,5 +1,5 @@
export { default as TagsInput } from "./TagsInput.vue" export { default as TagsInput } from './TagsInput.vue'
export { default as TagsInputInput } from "./TagsInputInput.vue" export { default as TagsInputInput } from './TagsInputInput.vue'
export { default as TagsInputItem } from "./TagsInputItem.vue" export { default as TagsInputItem } from './TagsInputItem.vue'
export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue" export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
export { default as TagsInputItemText } from "./TagsInputItemText.vue" export { default as TagsInputItemText } from './TagsInputItemText.vue'

View file

@ -82,6 +82,15 @@ export interface paths {
} }
} }
responses: { responses: {
/** @description The created course */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Course']
}
}
/** @description The course is invalid */ /** @description The course is invalid */
400: { 400: {
headers: { headers: {

View file

@ -18,5 +18,75 @@ home:
courses: Find your courses and their resources. courses: Find your courses and their resources.
pomodoro: Work in focused sessions with regular breaks. pomodoro: Work in focused sessions with regular breaks.
calendar: Keep an eye on your exams and deadlines. 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: placeholder:
coming-soon: This page is coming soon. coming-soon: This page is coming soon.

View file

@ -18,5 +18,75 @@ home:
courses: Retrouve tes cours et leurs ressources. courses: Retrouve tes cours et leurs ressources.
pomodoro: Travaille par sessions concentrées avec des pauses régulières. pomodoro: Travaille par sessions concentrées avec des pauses régulières.
calendar: Garde un œil sur tes examens et échéances. 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: placeholder:
coming-soon: Cette page arrive bientôt. coming-soon: Cette page arrive bientôt.

View file

@ -6,6 +6,8 @@ declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
/** i18n key of the page title, shown in the header */ /** i18n key of the page title, shown in the header */
title: string 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'), component: () => import('@/views/CoursesView.vue'),
meta: { title: 'nav.courses' }, 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', name: 'pomodoro',
path: '/pomodoro', path: '/pomodoro',

View file

@ -17,6 +17,9 @@ client.use({
`Server error from ${response.url}: ${response.status} ${response.statusText}`, `Server error from ${response.url}: ${response.status} ${response.statusText}`,
) )
throw new Error('Server error') 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) { } else if (response.status === HttpStatus.NOT_FOUND) {
throw new Error(`Not found: ${response.url}`) throw new Error(`Not found: ${response.url}`)
} }

View file

@ -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<Course[]>(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<Course[]>(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<Course[]>(COURSES_KEY, (courses) =>
(courses ?? []).filter((c) => c.id !== id),
)
},
})
}

View file

@ -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<SlotTag[]>(
() =>
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 }
}

View file

@ -50,7 +50,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 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); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@ -62,7 +62,7 @@
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: 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-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@ -70,12 +70,12 @@
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 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-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.932 0.032 255.585);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.424 0.199 265.638);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.707 0.165 254.624);
} }
.dark { .dark {
@ -85,8 +85,8 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0); --popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0); --primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
@ -97,7 +97,7 @@
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0); --border: oklch(0.269 0 0);
--input: 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-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
@ -105,12 +105,12 @@
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 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-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.282 0.091 267.935);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.882 0.059 254.128);
--sidebar-border: oklch(0.269 0 0); --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 { @layer base {

View file

@ -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 `<input type="time">` */
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<T> {
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<T>(
items: T[],
start: (item: T) => number,
end: (item: T) => number,
): Positioned<T>[] {
const sorted = [...items].sort((a, b) => start(a) - start(b) || end(b) - end(a))
const result: Positioned<T>[] = []
let cluster: Positioned<T>[] = []
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
}

View file

@ -1,2 +1,15 @@
// Shorthands over the generated schemas, so views never import `@/lib/api` directly, e.g. import type { components } from '@/lib/api'
// export type Course = components['schemas']['Course']
// 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
}

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
/** Personal page of a course. For now: its details and its weekly slots. */
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { ArrowLeft, ExternalLink, Trash2 } from '@lucide/vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toCourseBody, useCourses, useUpdateCourse } from '@/services/api/courses'
import { useSlotTags } from '@/services/api/slot-tags'
import { toHourMinutes } from '@/utils/timetable'
import type { TimeSlot } from '@/utils/types'
const props = defineProps<{ id: number }>()
const { t } = useI18n()
const { data: courses, isPending, isError, refetch } = useCourses()
const { tagsById } = useSlotTags()
const updateCourse = useUpdateCourse()
const course = computed(() => courses.value?.find((c) => c.id === props.id))
async function removeSlot(slot: TimeSlot) {
if (!course.value) return
const slots = course.value.slots.filter((s) => s.id !== slot.id)
try {
await updateCourse.mutateAsync({
id: course.value.id,
body: toCourseBody({ ...course.value, slots }),
})
toast.success(t('schedule.slot-deleted'))
} catch {
toast.error(t('schedule.slot-delete-error'))
}
}
</script>
<template>
<div class="mx-auto flex max-w-3xl flex-col gap-4">
<Button variant="ghost" class="self-start" as-child>
<RouterLink :to="{ name: 'courses' }">
<ArrowLeft />
{{ $t('course.back') }}
</RouterLink>
</Button>
<Skeleton v-if="isPending" class="h-64 w-full rounded-xl" />
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
<CardHeader>
<CardTitle>{{ isError ? $t('courses.load-error') : $t('course.not-found') }}</CardTitle>
</CardHeader>
<CardFooter v-if="isError" class="justify-center">
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
</CardFooter>
</Card>
<Card v-else>
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle class="text-2xl">{{ course.name }}</CardTitle>
<Badge v-if="course.code" variant="secondary" class="font-mono">
{{ course.code }}
</Badge>
</div>
<CardDescription>{{ course.teachers.join(', ') }}</CardDescription>
<Button v-if="course.main_page_url" variant="link" class="self-start px-0" as-child>
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
<ExternalLink />
{{ $t('courses.main-page') }}
</a>
</Button>
</CardHeader>
<CardContent class="flex flex-col gap-2">
<h2 class="text-sm font-medium">{{ $t('course.slots') }}</h2>
<ul v-if="course.slots.length" class="flex flex-col gap-1 text-sm">
<li v-for="slot in course.slots" :key="slot.id" class="flex items-center gap-2">
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: tagsById.get(slot.tag_id)?.color }"
/>
<span class="w-24 shrink-0">{{ $t(`weekdays.${slot.weekday}`) }}</span>
<span class="tabular-nums text-muted-foreground">
{{ toHourMinutes(slot.start_time) }}–{{ toHourMinutes(slot.end_time) }}
</span>
<span class="ml-auto text-muted-foreground">
{{ tagsById.get(slot.tag_id)?.name }}
</span>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive hover:text-destructive"
:title="$t('schedule.delete-slot')"
:disabled="updateCourse.isPending.value"
@click="removeSlot(slot)"
>
<Trash2 />
<span class="sr-only">{{ $t('schedule.delete-slot') }}</span>
</Button>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">{{ $t('course.no-slot') }}</p>
</CardContent>
</Card>
</div>
</template>

View file

@ -1,7 +1,157 @@
<script setup lang="ts"> <script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { BookOpen, Pencil, Plus, Trash2 } from '@lucide/vue'
import CourseFormDialog from '@/components/courses/CourseFormDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useCourses, useDeleteCourse } from '@/services/api/courses'
import type { Course } from '@/utils/types'
const { t } = useI18n()
const { data: courses, isPending, isError, refetch } = useCourses()
const deleteCourse = useDeleteCourse()
const formOpen = ref(false)
const editedCourse = ref<Course>()
function openForm(course?: Course) {
editedCourse.value = course
formOpen.value = true
}
const deleteOpen = ref(false)
const deletedCourse = ref<Course>()
function askDelete(course: Course) {
deletedCourse.value = course
deleteOpen.value = true
}
async function confirmDelete() {
if (!deletedCourse.value) return
try {
await deleteCourse.mutateAsync(deletedCourse.value.id)
toast.success(t('courses.deleted'))
} catch {
toast.error(t('courses.delete-error'))
}
}
</script> </script>
<template> <template>
<PagePlaceholder title="nav.courses" /> <div class="flex flex-col gap-6">
<div class="flex items-center justify-end">
<Button @click="openForm()">
<Plus />
{{ $t('courses.new') }}
</Button>
</div>
<div v-if="isPending" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Skeleton v-for="i in 3" :key="i" class="h-24 rounded-xl" />
</div>
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
<CardHeader>
<CardTitle>{{ $t('courses.load-error') }}</CardTitle>
</CardHeader>
<CardFooter class="justify-center">
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
</CardFooter>
</Card>
<Card v-else-if="!courses?.length" class="mx-auto max-w-md text-center">
<CardHeader>
<BookOpen class="mx-auto mb-2 size-8 text-muted-foreground" />
<CardTitle>{{ $t('courses.empty') }}</CardTitle>
<CardDescription>{{ $t('courses.empty-hint') }}</CardDescription>
</CardHeader>
<CardFooter class="justify-center">
<Button @click="openForm()">
<Plus />
{{ $t('courses.new') }}
</Button>
</CardFooter>
</Card>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<!-- The link to the course page is stretched over the whole card -->
<Card
v-for="course in courses"
:key="course.id"
class="relative flex flex-row items-start gap-2 p-4 transition-shadow focus-within:ring-2 focus-within:ring-ring/50 hover:shadow-md"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<CardTitle class="text-lg leading-snug">
<RouterLink
:to="{ name: 'course', params: { id: course.id } }"
class="outline-none after:absolute after:inset-0 after:content-['']"
>
{{ course.name }}
</RouterLink>
</CardTitle>
<Badge v-if="course.code" variant="secondary" class="font-mono">
{{ course.code }}
</Badge>
</div>
<CardDescription>{{ course.teachers.join(', ') }}</CardDescription>
</div>
<div class="relative z-10 flex shrink-0 gap-1">
<Button variant="ghost" size="icon" :title="$t('common.edit')" @click="openForm(course)">
<Pencil />
<span class="sr-only">{{ $t('common.edit') }}</span>
</Button>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive"
:title="$t('common.delete')"
@click="askDelete(course)"
>
<Trash2 />
<span class="sr-only">{{ $t('common.delete') }}</span>
</Button>
</div>
</Card>
</div>
<CourseFormDialog v-model:open="formOpen" :course="editedCourse" />
<AlertDialog v-model:open="deleteOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{{ $t('courses.delete-title', { name: deletedCourse?.name }) }}
</AlertDialogTitle>
<AlertDialogDescription>{{ $t('courses.delete-description') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-white hover:bg-destructive/90"
@click="confirmDelete"
>
{{ $t('common.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template> </template>

View file

@ -1,7 +1,285 @@
<script setup lang="ts"> <script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue' /**
* Typical week (no dates): monday to friday, 8:00 to 19:00. Slots that overlap are
* drawn side by side. A slot is added by selecting a time range with the pointer, and
* clicking a slot opens the page of its course.
*/
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarClock } from '@lucide/vue'
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCourses } from '@/services/api/courses'
import { useSlotTags } from '@/services/api/slot-tags'
import {
DAY_END_HOUR,
DAY_START_HOUR,
WEEKDAYS,
fromMinutes,
layoutOverlapping,
toHourMinutes,
toMinutes,
type SlotSelection,
} from '@/utils/timetable'
import type { Course, SlotTag, TimeSlot } from '@/utils/types'
const { t } = useI18n()
const { data: courses, isPending, isError, refetch } = useCourses()
const { data: tags, tagsById } = useSlotTags()
const HOURS = Array.from(
{ length: DAY_END_HOUR - DAY_START_HOUR + 1 },
(_, i) => DAY_START_HOUR + i,
)
const DAY_START_MINUTES = DAY_START_HOUR * 60
const DAY_MINUTES = (DAY_END_HOUR - DAY_START_HOUR) * 60
interface Block {
slot: TimeSlot
course: Course
tag?: SlotTag
start: number
end: number
}
const slotCount = computed(() =>
(courses.value ?? []).reduce((count, course) => count + course.slots.length, 0),
)
/** Positioned blocks of each weekday */
const days = computed(() => {
const blocks: Block[] = (courses.value ?? []).flatMap((course) =>
course.slots.map((slot) => ({
slot,
course,
tag: tagsById.value.get(slot.tag_id),
start: toMinutes(slot.start_time),
end: toMinutes(slot.end_time),
})),
)
return WEEKDAYS.map((weekday) => ({
weekday,
blocks: layoutOverlapping(
blocks.filter((block) => block.slot.weekday === weekday),
(block) => block.start,
(block) => block.end,
),
}))
})
function percent(minutes: number) {
return `${((minutes - DAY_START_MINUTES) / DAY_MINUTES) * 100}%`
}
function blockStyle(block: Block, lane: number, lanes: number) {
const color = block.tag?.color ?? 'var(--primary)'
return {
top: percent(block.start),
height: `${((block.end - block.start) / DAY_MINUTES) * 100}%`,
left: `calc(${(lane / lanes) * 100}% + 2px)`,
width: `calc(${100 / lanes}% - 4px)`,
borderLeftColor: color,
backgroundColor: `color-mix(in oklab, ${color} 22%, var(--card))`,
}
}
/** Granularity of the time range selection, in minutes */
const SELECTION_STEP = 15
/** Length of the range proposed on a simple click, in minutes */
const CLICK_DURATION = 60
interface Selection {
weekday: number
/** Steps where the pointer went down and where it is now, from the start of the day */
anchor: number
current: number
moved: boolean
}
const selection = ref<Selection>()
/** Time range covered by the selection, in minutes since midnight */
const selectedRange = computed(() => {
if (!selection.value) return undefined
const { anchor, current, moved } = selection.value
const start = DAY_START_MINUTES + Math.min(anchor, current) * SELECTION_STEP
const end = moved
? DAY_START_MINUTES + (Math.max(anchor, current) + 1) * SELECTION_STEP
: Math.min(start + CLICK_DURATION, DAY_START_MINUTES + DAY_MINUTES)
return { start, end }
})
/** Selection step under the pointer, in the day column the pointer went down on */
function stepAt(event: PointerEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 0.9999)
return Math.floor((ratio * DAY_MINUTES) / SELECTION_STEP)
}
function startSelection(event: PointerEvent, weekday: number) {
if (event.button !== 0) return // Keeps receiving the moves once the pointer leaves the column
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
const step = stepAt(event)
selection.value = { weekday, anchor: step, current: step, moved: false }
}
function moveSelection(event: PointerEvent) {
if (!selection.value) return
const step = stepAt(event)
if (step !== selection.value.current) {
selection.value.current = step
selection.value.moved = true
}
}
const slotFormOpen = ref(false)
const slotSelection = ref<SlotSelection>()
function endSelection() {
const range = selectedRange.value
const weekday = selection.value?.weekday
selection.value = undefined
if (!range || weekday === undefined) return
if (!courses.value?.length) {
toast.info(t('schedule.no-course'))
return
}
slotSelection.value = { weekday, start: fromMinutes(range.start), end: fromMinutes(range.end) }
slotFormOpen.value = true
}
</script> </script>
<template> <template>
<PagePlaceholder title="nav.schedule" /> <div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-4 text-sm">
<span v-for="tag in tags" :key="tag.id" class="flex items-center gap-2">
<span class="size-2.5 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.name }}
</span>
<span v-if="slotCount > 0" class="ml-auto text-muted-foreground">
{{ $t('schedule.select-hint') }}
</span>
</div>
<Skeleton v-if="isPending" class="h-[55rem] w-full rounded-xl" />
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
<CardHeader>
<CardTitle>{{ $t('schedule.load-error') }}</CardTitle>
</CardHeader>
<CardFooter class="justify-center">
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
</CardFooter>
</Card>
<template v-else>
<Card v-if="slotCount === 0" class="flex flex-col items-center gap-2 p-4 sm:flex-row">
<CalendarClock class="size-6 shrink-0 text-muted-foreground" />
<div class="flex-1 text-center sm:text-left">
<CardTitle class="text-base">{{ $t('schedule.empty') }}</CardTitle>
<CardDescription>
{{ courses?.length ? $t('schedule.empty-hint') : $t('schedule.no-course') }}
</CardDescription>
</div>
<Button v-if="!courses?.length" as-child>
<RouterLink :to="{ name: 'courses' }">{{ $t('schedule.go-to-courses') }}</RouterLink>
</Button>
</Card>
<div class="overflow-x-auto rounded-xl border bg-card">
<div class="grid min-w-[44rem] grid-cols-[3.5rem_repeat(5,minmax(0,1fr))]">
<!-- Header: weekdays -->
<div class="border-b" />
<div
v-for="day in days"
:key="day.weekday"
class="border-b border-l px-2 py-3 text-center text-sm font-medium"
>
{{ $t(`weekdays.${day.weekday}`) }}
</div>
<!-- Hours column -->
<div class="relative h-[55rem]">
<span
v-for="hour in HOURS.slice(0, -1)"
:key="hour"
class="absolute right-2 mt-1 text-xs tabular-nums text-muted-foreground"
:style="{ top: percent(hour * 60) }"
>
{{ hour }}:00
</span>
</div>
<!-- One column per weekday, where a time range is selected to add a slot -->
<div
v-for="day in days"
:key="day.weekday"
class="relative h-[55rem] cursor-cell select-none border-l"
@pointerdown="startSelection($event, day.weekday)"
@pointermove="moveSelection"
@pointerup="endSelection"
@pointercancel="selection = undefined"
>
<div
v-for="hour in HOURS.slice(1, -1)"
:key="hour"
class="pointer-events-none absolute inset-x-0 border-t border-dashed"
:style="{ top: percent(hour * 60) }"
/>
<Tooltip v-for="{ item: block, lane, lanes } in day.blocks" :key="block.slot.id">
<TooltipTrigger as-child>
<RouterLink
:to="{ name: 'course', params: { id: block.course.id } }"
class="absolute flex cursor-pointer flex-col gap-0.5 overflow-hidden rounded-md border-l-4 px-2 py-1 text-foreground transition-shadow hover:z-10 hover:shadow-md"
:style="blockStyle(block, lane, lanes)"
@pointerdown.stop
>
<span class="line-clamp-2 text-[0.8125rem] font-semibold leading-tight">
{{ block.course.name }}
</span>
<span class="truncate text-xs tabular-nums text-foreground/70">
{{ toHourMinutes(block.slot.start_time) }}–{{
toHourMinutes(block.slot.end_time)
}}
· {{ block.tag?.name }}
</span>
</RouterLink>
</TooltipTrigger>
<TooltipContent side="right" class="flex flex-col gap-0.5">
<span class="font-medium">{{ block.course.name }}</span>
<span v-if="block.course.code">{{ block.course.code }}</span>
<span>
{{ block.tag?.name }} · {{ $t(`weekdays.${block.slot.weekday}`) }}
{{ toHourMinutes(block.slot.start_time) }}–{{
toHourMinutes(block.slot.end_time)
}}
</span>
<span>{{ block.course.teachers.join(', ') }}</span>
</TooltipContent>
</Tooltip>
<div
v-if="selection?.weekday === day.weekday && selectedRange"
class="pointer-events-none absolute inset-x-0.5 z-20 flex items-start rounded-md border-2 border-dashed border-primary bg-primary/10 px-2 py-1 text-xs font-medium tabular-nums text-primary"
:style="{
top: percent(selectedRange.start),
height: `${((selectedRange.end - selectedRange.start) / DAY_MINUTES) * 100}%`,
}"
>
{{ fromMinutes(selectedRange.start) }}–{{ fromMinutes(selectedRange.end) }}
</div>
</div>
</div>
</div>
</template>
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" />
</div>
</template> </template>

View file

@ -57,6 +57,7 @@ async fn create_course(
fn create_course_docs(op: TransformOperation) -> TransformOperation { fn create_course_docs(op: TransformOperation) -> TransformOperation {
op.tag("Courses") op.tag("Courses")
.summary("Create a course with its time slots") .summary("Create a course with its time slots")
.response_with::<201, Json<Course>, _>(desc("The created course"))
.response_with::<400, String, _>(desc("The course is invalid")) .response_with::<400, String, _>(desc("The course is invalid"))
} }