wip
This commit is contained in:
parent
07d98b64b3
commit
fbedcf97e3
56 changed files with 2108 additions and 254 deletions
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"frontend/src/locales"
|
||||
]
|
||||
}
|
||||
BIN
LaTeX_pdf/assets/epfl.png
Normal file
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
60
LaTeX_pdf/assignment.cls
Normal 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
27
LaTeX_pdf/box-filter.lua
Normal 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
78
LaTeX_pdf/modele.md
Normal 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
156
LaTeX_pdf/template.tex
Normal 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
138
LaTeX_pdf/template.tex.bak
Normal 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}
|
||||
|
|
@ -11,14 +11,15 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { locales, setLocale } from '@/services/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const { locale: currentLocale } = useI18n()
|
||||
|
||||
const selectedLangFlag = computed(
|
||||
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
|
||||
const selectedLocale = computed(
|
||||
() => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
@ -29,20 +30,22 @@ const selectedLangFlag = computed(
|
|||
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1>
|
||||
|
||||
<ThemeToggle class="ml-auto" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="ml-auto">
|
||||
<span :class="'fi fi-' + selectedLangFlag"></span>
|
||||
<Button variant="outline" class="rounded-lg">
|
||||
<span :class="'fi fi-' + selectedLocale.flag"></span>
|
||||
{{ selectedLocale.lang.toUpperCase() }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="min-w-0">
|
||||
<DropdownMenuItem
|
||||
v-for="locale in locales"
|
||||
:key="locale.lang"
|
||||
class="justify-center"
|
||||
@click="setLocale(locale.lang)"
|
||||
>
|
||||
<span :class="'fi fi-' + locale.flag"></span>
|
||||
{{ locale.lang.toUpperCase() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function onNavigate() {
|
|||
<SidebarMenuItem v-for="section in sections" :key="section.name">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="route.name === section.name"
|
||||
:is-active="(route.meta.section ?? route.name) === section.name"
|
||||
:tooltip="$t(section.title)"
|
||||
>
|
||||
<RouterLink :to="{ name: section.name }" @click="onNavigate">
|
||||
|
|
|
|||
25
frontend/src/components/ThemeToggle.vue
Normal file
25
frontend/src/components/ThemeToggle.vue
Normal 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>
|
||||
189
frontend/src/components/courses/CourseFormDialog.vue
Normal file
189
frontend/src/components/courses/CourseFormDialog.vue
Normal 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>
|
||||
204
frontend/src/components/schedule/SlotFormDialog.vue
Normal file
204
frontend/src/components/schedule/SlotFormDialog.vue
Normal 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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -31,7 +31,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)"
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -33,13 +33,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent
|
||||
const target = originalEvent.target as HTMLElement
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}"
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
|
@ -15,12 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { default as Label } from "./Label.vue"
|
||||
export { default as Label } from './Label.vue'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
|
|
|||
|
|
@ -1,29 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
position: 'popper',
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -31,16 +26,26 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
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',
|
||||
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',
|
||||
props.class,
|
||||
)
|
||||
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',
|
||||
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',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<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 />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectGroup } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SelectItem, 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)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from "reka-ui"
|
||||
import { SelectItemText } from "reka-ui"
|
||||
import type { SelectItemTextProps } from 'reka-ui'
|
||||
import { SelectItemText } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { SelectLabel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronUp } from "lucide-vue-next"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronUp } from '@lucide/vue'
|
||||
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectSeparator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
|
@ -16,10 +16,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
: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',
|
||||
props.class,
|
||||
)"
|
||||
: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',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from "reka-ui"
|
||||
import { SelectValue } from "reka-ui"
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
export { default as Select } from './Select.vue'
|
||||
export { default as SelectContent } from './SelectContent.vue'
|
||||
export { default as SelectGroup } from './SelectGroup.vue'
|
||||
export { default as SelectItem } from './SelectItem.vue'
|
||||
export { default as SelectItemText } from './SelectItemText.vue'
|
||||
export { default as SelectLabel } from './SelectLabel.vue'
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||
export { default as SelectValue } from './SelectValue.vue'
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputRootEmits, TagsInputRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputRootEmits, TagsInputRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TagsInputRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputInputProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputInput, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputInputProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputInput, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemDeleteProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import { TagsInputItemDelete, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<X class="w-4 h-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemTextProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputItemText, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { default as TagsInput } from "./TagsInput.vue"
|
||||
export { default as TagsInputInput } from "./TagsInputInput.vue"
|
||||
export { default as TagsInputItem } from "./TagsInputItem.vue"
|
||||
export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue"
|
||||
export { default as TagsInputItemText } from "./TagsInputItemText.vue"
|
||||
export { default as TagsInput } from './TagsInput.vue'
|
||||
export { default as TagsInputInput } from './TagsInputInput.vue'
|
||||
export { default as TagsInputItem } from './TagsInputItem.vue'
|
||||
export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
|
||||
export { default as TagsInputItemText } from './TagsInputItemText.vue'
|
||||
|
|
|
|||
9
frontend/src/lib/api.d.ts
vendored
9
frontend/src/lib/api.d.ts
vendored
|
|
@ -82,6 +82,15 @@ export interface paths {
|
|||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description The created course */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['Course']
|
||||
}
|
||||
}
|
||||
/** @description The course is invalid */
|
||||
400: {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -18,5 +18,75 @@ home:
|
|||
courses: Find your courses and their resources.
|
||||
pomodoro: Work in focused sessions with regular breaks.
|
||||
calendar: Keep an eye on your exams and deadlines.
|
||||
theme:
|
||||
dark: Switch to dark mode
|
||||
light: Switch to light mode
|
||||
weekdays:
|
||||
'1': Monday
|
||||
'2': Tuesday
|
||||
'3': Wednesday
|
||||
'4': Thursday
|
||||
'5': Friday
|
||||
common:
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
retry: Retry
|
||||
courses:
|
||||
new: New course
|
||||
edit: Edit course
|
||||
empty: No course yet.
|
||||
empty-hint: Add your first course to fill your timetable.
|
||||
load-error: Unable to load the courses.
|
||||
main-page: Course page
|
||||
created: Course created
|
||||
updated: Course updated
|
||||
deleted: Course deleted
|
||||
save-error: Unable to save the course
|
||||
delete-error: Unable to delete the course
|
||||
delete-title: Do you really want to delete “{name}”?
|
||||
delete-description: The course and all its slots will be permanently deleted.
|
||||
form:
|
||||
description: Slots are then added from the timetable.
|
||||
name: Name
|
||||
name-placeholder: Advanced operating systems
|
||||
code: Code
|
||||
code-placeholder: CS-477
|
||||
optional: optional
|
||||
teachers: Teachers
|
||||
teachers-placeholder: Name, then Enter
|
||||
main-page: Main page
|
||||
main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
|
||||
weekday: Day
|
||||
tag: Type
|
||||
start: Start
|
||||
end: End
|
||||
errors:
|
||||
name: The name is required.
|
||||
teachers: Add at least one teacher.
|
||||
main-page: The address must start with http:// or https://.
|
||||
slot-time: Each slot must start before it ends, between 8:00 and 19:00.
|
||||
course:
|
||||
back: Back to courses
|
||||
not-found: This course does not exist.
|
||||
slots: Slots
|
||||
no-slot: No slot. Select a time range in the timetable to add one.
|
||||
schedule:
|
||||
empty: No slot to show.
|
||||
empty-hint: Click or drag on the timetable to add a slot.
|
||||
no-course: Create a course first to add slots to it.
|
||||
select-hint: Click or drag on the timetable to add a slot
|
||||
go-to-courses: Go to courses
|
||||
load-error: Unable to load the timetable.
|
||||
new-slot: New slot
|
||||
new-slot-description: The slot is added to the chosen course, every week.
|
||||
slot-created: Slot added
|
||||
slot-deleted: Slot deleted
|
||||
slot-save-error: Unable to add the slot
|
||||
slot-delete-error: Unable to delete the slot
|
||||
delete-slot: Delete the slot
|
||||
form:
|
||||
course: Course
|
||||
placeholder:
|
||||
coming-soon: This page is coming soon.
|
||||
|
|
|
|||
|
|
@ -18,5 +18,75 @@ home:
|
|||
courses: Retrouve tes cours et leurs ressources.
|
||||
pomodoro: Travaille par sessions concentrées avec des pauses régulières.
|
||||
calendar: Garde un œil sur tes examens et échéances.
|
||||
theme:
|
||||
dark: Passer en mode sombre
|
||||
light: Passer en mode clair
|
||||
weekdays:
|
||||
'1': Lundi
|
||||
'2': Mardi
|
||||
'3': Mercredi
|
||||
'4': Jeudi
|
||||
'5': Vendredi
|
||||
common:
|
||||
cancel: Annuler
|
||||
save: Enregistrer
|
||||
delete: Supprimer
|
||||
edit: Modifier
|
||||
retry: Réessayer
|
||||
courses:
|
||||
new: Nouveau cours
|
||||
edit: Modifier le cours
|
||||
empty: Aucun cours pour le moment.
|
||||
empty-hint: Ajoute ton premier cours pour remplir ton emploi du temps.
|
||||
load-error: Impossible de charger les cours.
|
||||
main-page: Page du cours
|
||||
created: Cours créé
|
||||
updated: Cours modifié
|
||||
deleted: Cours supprimé
|
||||
save-error: Impossible d'enregistrer le cours
|
||||
delete-error: Impossible de supprimer le cours
|
||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||
delete-description: Le cours et tous ses créneaux seront supprimés définitivement.
|
||||
form:
|
||||
description: Les créneaux s'ajoutent ensuite depuis l'emploi du temps.
|
||||
name: Nom
|
||||
name-placeholder: Advanced operating systems
|
||||
code: Code
|
||||
code-placeholder: CS-477
|
||||
optional: facultatif
|
||||
teachers: Enseignant·e·s
|
||||
teachers-placeholder: Nom, puis Entrée
|
||||
main-page: Page principale
|
||||
main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
|
||||
weekday: Jour
|
||||
tag: Type
|
||||
start: Début
|
||||
end: Fin
|
||||
errors:
|
||||
name: Le nom est obligatoire.
|
||||
teachers: Ajoute au moins un·e enseignant·e.
|
||||
main-page: L'adresse doit commencer par http:// ou https://.
|
||||
slot-time: Chaque créneau doit commencer avant de finir, entre 8h et 19h.
|
||||
course:
|
||||
back: Retour aux cours
|
||||
not-found: Ce cours n'existe pas.
|
||||
slots: Créneaux
|
||||
no-slot: Aucun créneau. Sélectionne une plage horaire dans l'emploi du temps pour en ajouter.
|
||||
schedule:
|
||||
empty: Aucun créneau à afficher.
|
||||
empty-hint: Clique ou glisse sur le calendrier pour ajouter un créneau.
|
||||
no-course: Crée d'abord un cours pour pouvoir lui ajouter des créneaux.
|
||||
select-hint: Clique ou glisse sur le calendrier pour ajouter un créneau
|
||||
go-to-courses: Aller aux cours
|
||||
load-error: Impossible de charger l'emploi du temps.
|
||||
new-slot: Nouveau créneau
|
||||
new-slot-description: Le créneau est ajouté au cours choisi, chaque semaine.
|
||||
slot-created: Créneau ajouté
|
||||
slot-deleted: Créneau supprimé
|
||||
slot-save-error: Impossible d'ajouter le créneau
|
||||
slot-delete-error: Impossible de supprimer le créneau
|
||||
delete-slot: Supprimer le créneau
|
||||
form:
|
||||
course: Cours
|
||||
placeholder:
|
||||
coming-soon: Cette page arrive bientôt.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ declare module 'vue-router' {
|
|||
interface RouteMeta {
|
||||
/** i18n key of the page title, shown in the header */
|
||||
title: string
|
||||
/** Name of the sidebar section highlighted on this page, when it is not the page itself */
|
||||
section?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +27,13 @@ const router = createRouter({
|
|||
component: () => import('@/views/CoursesView.vue'),
|
||||
meta: { title: 'nav.courses' },
|
||||
},
|
||||
{
|
||||
name: 'course',
|
||||
path: '/courses/:id(\\d+)',
|
||||
component: () => import('@/views/CourseView.vue'),
|
||||
props: (route) => ({ id: Number(route.params.id) }),
|
||||
meta: { title: 'nav.courses', section: 'courses' },
|
||||
},
|
||||
{
|
||||
name: 'pomodoro',
|
||||
path: '/pomodoro',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ client.use({
|
|||
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
throw new Error('Server error')
|
||||
} else if (response.status === HttpStatus.BAD_REQUEST) {
|
||||
// The backend explains what is wrong with the request in the body
|
||||
throw new Error(await response.clone().text())
|
||||
} else if (response.status === HttpStatus.NOT_FOUND) {
|
||||
throw new Error(`Not found: ${response.url}`)
|
||||
}
|
||||
|
|
|
|||
104
frontend/src/services/api/courses.ts
Normal file
104
frontend/src/services/api/courses.ts
Normal 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),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
35
frontend/src/services/api/slot-tags.ts
Normal file
35
frontend/src/services/api/slot-tags.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.707 0.165 254.624);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -70,12 +70,12 @@
|
|||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-accent: oklch(0.932 0.032 255.585);
|
||||
--sidebar-accent-foreground: oklch(0.424 0.199 265.638);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.707 0.165 254.624);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -85,8 +85,8 @@
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
|
|
@ -105,12 +105,12 @@
|
|||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.282 0.091 267.935);
|
||||
--sidebar-accent-foreground: oklch(0.882 0.059 254.128);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
85
frontend/src/utils/timetable.ts
Normal file
85
frontend/src/utils/timetable.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,2 +1,15 @@
|
|||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly, e.g.
|
||||
// export type Course = components['schemas']['Course']
|
||||
import type { components } from '@/lib/api'
|
||||
|
||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
||||
export type LocalizedString = components['schemas']['LocalizedString']
|
||||
export type Course = components['schemas']['Course']
|
||||
export type CourseBody = components['schemas']['CourseBody']
|
||||
export type TimeSlot = components['schemas']['TimeSlot']
|
||||
export type TimeSlotBody = components['schemas']['TimeSlotBody']
|
||||
|
||||
/** Slot tag with its name resolved in the current locale */
|
||||
export interface SlotTag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
|
|
|||
117
frontend/src/views/CourseView.vue
Normal file
117
frontend/src/views/CourseView.vue
Normal 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>
|
||||
|
|
@ -1,7 +1,157 @@
|
|||
<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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,285 @@
|
|||
<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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ async fn create_course(
|
|||
fn create_course_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Create a course with its time slots")
|
||||
.response_with::<201, Json<Course>, _>(desc("The created course"))
|
||||
.response_with::<400, String, _>(desc("The course is invalid"))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue