wip
This commit is contained in:
parent
fbedcf97e3
commit
08b69851cd
91 changed files with 7372 additions and 677 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
/target
|
/target
|
||||||
config.yml
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
|
/storage
|
||||||
|
|
|
||||||
60
Cargo.lock
generated
60
Cargo.lock
generated
|
|
@ -281,7 +281,7 @@ version = "0.1.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"tiny-keccak",
|
"tiny-keccak",
|
||||||
]
|
]
|
||||||
|
|
@ -512,6 +512,12 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|
@ -676,6 +682,17 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -1067,6 +1084,12 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -1383,6 +1406,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
|
|
@ -1410,7 +1439,7 @@ version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1512,6 +1541,19 @@ dependencies = [
|
||||||
"ordered-multimap",
|
"ordered-multimap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -1997,6 +2039,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|
@ -2049,6 +2092,19 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.19"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,14 @@ sqlx = { version = "0.8.6", features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
] }
|
] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "signal"] }
|
tempfile = "3"
|
||||||
|
tokio = { version = "1.52.3", features = [
|
||||||
|
"fs",
|
||||||
|
"process",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"signal",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
tower-http = { version = "0.6.10", features = ["fs"] }
|
tower-http = { version = "0.6.10", features = ["fs"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ psql -h localhost -U postgres -c 'CREATE DATABASE studium'
|
||||||
dbmate up
|
dbmate up
|
||||||
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data
|
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data
|
||||||
|
|
||||||
# 2. Configure the app
|
# 2. Configure the app (the lecture notes also need pandoc and pdflatex)
|
||||||
cp config.example.yml config.yml
|
cp config.example.yml config.yml
|
||||||
|
|
||||||
# 3. Run the backend (port 3000)
|
# 3. Run the backend (port 3000)
|
||||||
|
|
@ -53,6 +53,8 @@ key, keep it up to date. Every value can also come from the environment
|
||||||
│ │ └── repositories/ traits describing what the core needs from the storage
|
│ │ └── repositories/ traits describing what the core needs from the storage
|
||||||
│ ├── services/ implementations of the repositories (postgres/sqlx)
|
│ ├── services/ implementations of the repositories (postgres/sqlx)
|
||||||
│ └── utils/ configuration
|
│ └── utils/ configuration
|
||||||
|
├── LaTeX_pdf/ template of the lecture notes pdf (pandoc + pdflatex)
|
||||||
|
├── storage/ files of the courses, stands in for the NAS (gitignored)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── migrations/ dbmate migrations
|
│ ├── migrations/ dbmate migrations
|
||||||
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,17 @@ postgres:
|
||||||
|
|
||||||
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
||||||
frontend_dir: frontend/dist
|
frontend_dir: frontend/dist
|
||||||
|
|
||||||
|
# Where the files of the courses are stored (lecture notes and their pdf...).
|
||||||
|
# A local directory standing in for the NAS for now.
|
||||||
|
storage_dir: storage
|
||||||
|
|
||||||
|
# Rendering of the lecture notes to pdf (needs pandoc and pdflatex)
|
||||||
|
notes:
|
||||||
|
# Directory of template.tex, box-filter.lua and assets/
|
||||||
|
template_dir: LaTeX_pdf
|
||||||
|
# Relative to template_dir
|
||||||
|
logo: assets/epfl.png
|
||||||
|
# Shown in the header of the pdf, both optional
|
||||||
|
student_name: Jane Doe
|
||||||
|
student_id: "123456"
|
||||||
|
|
|
||||||
70
db/migrations/20260928150000_course_page.sql
Normal file
70
db/migrations/20260928150000_course_page.sql
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
-- Tags are shared by the time slots and the tasks
|
||||||
|
ALTER TABLE slot_tags RENAME TO tags;
|
||||||
|
ALTER TABLE tags RENAME CONSTRAINT slot_tags_pkey TO tags_pkey;
|
||||||
|
ALTER SEQUENCE slot_tags_id_seq RENAME TO tags_id_seq;
|
||||||
|
|
||||||
|
INSERT INTO tags ("name", color) VALUES
|
||||||
|
('{"fr": "Devoir", "en": "Homework"}', '#9333ea'),
|
||||||
|
('{"fr": "Examen", "en": "Exam"}', '#dc2626'),
|
||||||
|
('{"fr": "Projet", "en": "Project"}', '#0891b2');
|
||||||
|
|
||||||
|
-- Grades of a course, on any scale: `score` out of `max_score`. The weights are relative
|
||||||
|
-- to each other, the average of the course is weighted by them.
|
||||||
|
CREATE TABLE grades (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
score DOUBLE PRECISION NOT NULL CHECK (score >= 0),
|
||||||
|
max_score DOUBLE PRECISION NOT NULL CHECK (max_score > 0),
|
||||||
|
weight DOUBLE PRECISION NOT NULL CHECK (weight > 0),
|
||||||
|
CHECK (score <= max_score)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX grades_course_id_idx ON grades (course_id);
|
||||||
|
|
||||||
|
-- Homework, exams, or anything to do for a course. A task may have subtasks (one level).
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||||
|
parent_id INTEGER REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
links TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
deadline TIMESTAMPTZ,
|
||||||
|
done BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tasks_course_id_idx ON tasks (course_id);
|
||||||
|
|
||||||
|
CREATE TABLE task_tags (
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lecture notes written in markdown. The content (.md) and the rendered pdf are files
|
||||||
|
-- in the storage, not in the database.
|
||||||
|
CREATE TABLE lecture_notes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
-- False when the last rendering to pdf failed
|
||||||
|
has_pdf BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX lecture_notes_course_id_idx ON lecture_notes (course_id);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP TABLE IF EXISTS lecture_notes;
|
||||||
|
DROP TABLE IF EXISTS task_tags;
|
||||||
|
DROP TABLE IF EXISTS tasks;
|
||||||
|
DROP TABLE IF EXISTS grades;
|
||||||
|
|
||||||
|
DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM time_slots) AND id > 3;
|
||||||
|
ALTER SEQUENCE tags_id_seq RENAME TO slot_tags_id_seq;
|
||||||
|
ALTER TABLE tags RENAME CONSTRAINT tags_pkey TO slot_tags_pkey;
|
||||||
|
ALTER TABLE tags RENAME TO slot_tags;
|
||||||
51
db/migrations/20260929090000_course_files.sql
Normal file
51
db/migrations/20260929090000_course_files.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
-- What each tag can label: time slots, tasks and/or course files
|
||||||
|
ALTER TABLE tags ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{}'
|
||||||
|
CHECK (scopes <@ ARRAY['slot', 'task', 'file']);
|
||||||
|
-- Stable name, for the tags the backend needs to find (e.g. `notes` for the lecture notes)
|
||||||
|
ALTER TABLE tags ADD COLUMN slug TEXT UNIQUE;
|
||||||
|
UPDATE tags SET scopes = '{slot,task}', slug = lower("name"->>'en')
|
||||||
|
WHERE "name"->>'en' IN ('Lecture', 'Exercises', 'Lab');
|
||||||
|
UPDATE tags SET scopes = '{task}', slug = lower("name"->>'en')
|
||||||
|
WHERE "name"->>'en' IN ('Homework', 'Exam', 'Project');
|
||||||
|
INSERT INTO tags ("name", color, scopes, slug) VALUES
|
||||||
|
('{"fr": "Slides", "en": "Slides"}', '#0d9488', '{file}', 'slides'),
|
||||||
|
('{"fr": "Notes", "en": "Notes"}', '#ca8a04', '{file}', 'notes');
|
||||||
|
|
||||||
|
-- The lecture notes become the first kind of course files. Uploaded files (slides...)
|
||||||
|
-- will be the second one. Ids are kept: they are part of the paths in the storage.
|
||||||
|
ALTER TABLE lecture_notes RENAME TO course_files;
|
||||||
|
ALTER TABLE course_files RENAME CONSTRAINT lecture_notes_pkey TO course_files_pkey;
|
||||||
|
ALTER TABLE course_files RENAME CONSTRAINT lecture_notes_course_id_fkey TO course_files_course_id_fkey;
|
||||||
|
ALTER SEQUENCE lecture_notes_id_seq RENAME TO course_files_id_seq;
|
||||||
|
ALTER INDEX lecture_notes_course_id_idx RENAME TO course_files_course_id_idx;
|
||||||
|
ALTER TABLE course_files RENAME COLUMN title TO "name";
|
||||||
|
-- `note`: markdown written in the app and rendered to pdf, `upload`: any file
|
||||||
|
ALTER TABLE course_files ADD COLUMN kind TEXT NOT NULL DEFAULT 'note'
|
||||||
|
CHECK (kind IN ('note', 'upload'));
|
||||||
|
ALTER TABLE course_files ALTER COLUMN kind DROP DEFAULT;
|
||||||
|
|
||||||
|
CREATE TABLE file_tags (
|
||||||
|
file_id INTEGER NOT NULL REFERENCES course_files (id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (file_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO file_tags (file_id, tag_id)
|
||||||
|
SELECT f.id, t.id FROM course_files f, tags t WHERE t.slug = 'notes';
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP TABLE IF EXISTS file_tags;
|
||||||
|
DELETE FROM course_files WHERE kind <> 'note';
|
||||||
|
ALTER TABLE course_files DROP COLUMN kind;
|
||||||
|
ALTER TABLE course_files RENAME COLUMN "name" TO title;
|
||||||
|
ALTER INDEX course_files_course_id_idx RENAME TO lecture_notes_course_id_idx;
|
||||||
|
ALTER SEQUENCE course_files_id_seq RENAME TO lecture_notes_id_seq;
|
||||||
|
ALTER TABLE course_files RENAME CONSTRAINT course_files_course_id_fkey TO lecture_notes_course_id_fkey;
|
||||||
|
ALTER TABLE course_files RENAME CONSTRAINT course_files_pkey TO lecture_notes_pkey;
|
||||||
|
ALTER TABLE course_files RENAME TO lecture_notes;
|
||||||
|
|
||||||
|
DELETE FROM tags WHERE scopes = '{file}';
|
||||||
|
ALTER TABLE tags DROP COLUMN slug;
|
||||||
|
ALTER TABLE tags DROP COLUMN scopes;
|
||||||
325
db/schema.sql
325
db/schema.sql
|
|
@ -19,6 +19,42 @@ SET default_tablespace = '';
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.course_files (
|
||||||
|
id integer CONSTRAINT lecture_notes_id_not_null NOT NULL,
|
||||||
|
course_id integer CONSTRAINT lecture_notes_course_id_not_null NOT NULL,
|
||||||
|
name text CONSTRAINT lecture_notes_title_not_null NOT NULL,
|
||||||
|
has_pdf boolean DEFAULT false CONSTRAINT lecture_notes_has_pdf_not_null NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_created_at_not_null NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_updated_at_not_null NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
CONSTRAINT course_files_kind_check CHECK ((kind = ANY (ARRAY['note'::text, 'upload'::text])))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.course_files_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.course_files_id_seq OWNED BY public.course_files.id;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: courses; Type: TABLE; Schema: public; Owner: -
|
-- Name: courses; Type: TABLE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -53,30 +89,38 @@ ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
|
-- Name: file_tags; Type: TABLE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
CREATE TABLE public.schema_migrations (
|
CREATE TABLE public.file_tags (
|
||||||
version character varying NOT NULL
|
file_id integer NOT NULL,
|
||||||
|
tag_id integer NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: slot_tags; Type: TABLE; Schema: public; Owner: -
|
-- Name: grades; Type: TABLE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
CREATE TABLE public.slot_tags (
|
CREATE TABLE public.grades (
|
||||||
id integer NOT NULL,
|
id integer NOT NULL,
|
||||||
name jsonb NOT NULL,
|
course_id integer NOT NULL,
|
||||||
color text NOT NULL
|
name text NOT NULL,
|
||||||
|
score double precision NOT NULL,
|
||||||
|
max_score double precision NOT NULL,
|
||||||
|
weight double precision NOT NULL,
|
||||||
|
CONSTRAINT grades_check CHECK ((score <= max_score)),
|
||||||
|
CONSTRAINT grades_max_score_check CHECK ((max_score > (0)::double precision)),
|
||||||
|
CONSTRAINT grades_score_check CHECK ((score >= (0)::double precision)),
|
||||||
|
CONSTRAINT grades_weight_check CHECK ((weight > (0)::double precision))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: slot_tags_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
-- Name: grades_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
CREATE SEQUENCE public.slot_tags_id_seq
|
CREATE SEQUENCE public.grades_id_seq
|
||||||
AS integer
|
AS integer
|
||||||
START WITH 1
|
START WITH 1
|
||||||
INCREMENT BY 1
|
INCREMENT BY 1
|
||||||
|
|
@ -86,10 +130,99 @@ CREATE SEQUENCE public.slot_tags_id_seq
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: slot_tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
-- Name: grades_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
ALTER SEQUENCE public.slot_tags_id_seq OWNED BY public.slot_tags.id;
|
ALTER SEQUENCE public.grades_id_seq OWNED BY public.grades.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.schema_migrations (
|
||||||
|
version character varying NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.tags (
|
||||||
|
id integer CONSTRAINT slot_tags_id_not_null NOT NULL,
|
||||||
|
name jsonb CONSTRAINT slot_tags_name_not_null NOT NULL,
|
||||||
|
color text CONSTRAINT slot_tags_color_not_null NOT NULL,
|
||||||
|
scopes text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
slug text,
|
||||||
|
CONSTRAINT tags_scopes_check CHECK ((scopes <@ ARRAY['slot'::text, 'task'::text, 'file'::text]))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.tags_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.tags_id_seq OWNED BY public.tags.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: task_tags; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.task_tags (
|
||||||
|
task_id integer NOT NULL,
|
||||||
|
tag_id integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.tasks (
|
||||||
|
id integer NOT NULL,
|
||||||
|
course_id integer NOT NULL,
|
||||||
|
parent_id integer,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
links text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
deadline timestamp with time zone,
|
||||||
|
done boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.tasks_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.tasks_id_seq OWNED BY public.tasks.id;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|
@ -128,6 +261,13 @@ CREATE SEQUENCE public.time_slots_id_seq
|
||||||
ALTER SEQUENCE public.time_slots_id_seq OWNED BY public.time_slots.id;
|
ALTER SEQUENCE public.time_slots_id_seq OWNED BY public.time_slots.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files id; Type: DEFAULT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.course_files ALTER COLUMN id SET DEFAULT nextval('public.course_files_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: -
|
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -136,10 +276,24 @@ ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.cour
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: slot_tags id; Type: DEFAULT; Schema: public; Owner: -
|
-- Name: grades id; Type: DEFAULT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
ALTER TABLE ONLY public.slot_tags ALTER COLUMN id SET DEFAULT nextval('public.slot_tags_id_seq'::regclass);
|
ALTER TABLE ONLY public.grades ALTER COLUMN id SET DEFAULT nextval('public.grades_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags id; Type: DEFAULT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tags ALTER COLUMN id SET DEFAULT nextval('public.tags_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks id; Type: DEFAULT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|
@ -149,6 +303,14 @@ ALTER TABLE ONLY public.slot_tags ALTER COLUMN id SET DEFAULT nextval('public.sl
|
||||||
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
|
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files course_files_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.course_files
|
||||||
|
ADD CONSTRAINT course_files_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -157,6 +319,22 @@ ALTER TABLE ONLY public.courses
|
||||||
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_tags file_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.file_tags
|
||||||
|
ADD CONSTRAINT file_tags_pkey PRIMARY KEY (file_id, tag_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: grades grades_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.grades
|
||||||
|
ADD CONSTRAINT grades_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -166,11 +344,35 @@ ALTER TABLE ONLY public.schema_migrations
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: slot_tags slot_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
ALTER TABLE ONLY public.slot_tags
|
ALTER TABLE ONLY public.tags
|
||||||
ADD CONSTRAINT slot_tags_pkey PRIMARY KEY (id);
|
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tags tags_slug_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tags
|
||||||
|
ADD CONSTRAINT tags_slug_key UNIQUE (slug);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: task_tags task_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.task_tags
|
||||||
|
ADD CONSTRAINT task_tags_pkey PRIMARY KEY (task_id, tag_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tasks
|
||||||
|
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|
@ -181,6 +383,27 @@ ALTER TABLE ONLY public.time_slots
|
||||||
ADD CONSTRAINT time_slots_pkey PRIMARY KEY (id);
|
ADD CONSTRAINT time_slots_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX course_files_course_id_idx ON public.course_files USING btree (course_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: grades_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX grades_course_id_idx ON public.grades USING btree (course_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX tasks_course_id_idx ON public.tasks USING btree (course_id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: time_slots_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
-- Name: time_slots_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -188,6 +411,70 @@ ALTER TABLE ONLY public.time_slots
|
||||||
CREATE INDEX time_slots_course_id_idx ON public.time_slots USING btree (course_id);
|
CREATE INDEX time_slots_course_id_idx ON public.time_slots USING btree (course_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files course_files_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.course_files
|
||||||
|
ADD CONSTRAINT course_files_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_tags file_tags_file_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.file_tags
|
||||||
|
ADD CONSTRAINT file_tags_file_id_fkey FOREIGN KEY (file_id) REFERENCES public.course_files(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: file_tags file_tags_tag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.file_tags
|
||||||
|
ADD CONSTRAINT file_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: grades grades_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.grades
|
||||||
|
ADD CONSTRAINT grades_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: task_tags task_tags_tag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.task_tags
|
||||||
|
ADD CONSTRAINT task_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: task_tags task_tags_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.task_tags
|
||||||
|
ADD CONSTRAINT task_tags_task_id_fkey FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks tasks_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tasks
|
||||||
|
ADD CONSTRAINT tasks_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tasks tasks_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tasks
|
||||||
|
ADD CONSTRAINT tasks_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.tasks(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: time_slots time_slots_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
-- Name: time_slots time_slots_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -201,7 +488,7 @@ ALTER TABLE ONLY public.time_slots
|
||||||
--
|
--
|
||||||
|
|
||||||
ALTER TABLE ONLY public.time_slots
|
ALTER TABLE ONLY public.time_slots
|
||||||
ADD CONSTRAINT time_slots_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.slot_tags(id);
|
ADD CONSTRAINT time_slots_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|
@ -216,4 +503,6 @@ ALTER TABLE ONLY public.time_slots
|
||||||
--
|
--
|
||||||
|
|
||||||
INSERT INTO public.schema_migrations (version) VALUES
|
INSERT INTO public.schema_migrations (version) VALUES
|
||||||
('20260928120000');
|
('20260928120000'),
|
||||||
|
('20260928150000'),
|
||||||
|
('20260929090000');
|
||||||
|
|
|
||||||
37
frontend/src/components/ConfirmDeleteDialog.vue
Normal file
37
frontend/src/components/ConfirmDeleteDialog.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** "Do you really want to delete...?" confirmation, `confirm` is emitted on approval */
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
defineProps<{ title: string; description?: string }>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
const emit = defineEmits<{ confirm: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialog v-model:open="open">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription v-if="description">{{ description }}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
class="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
@click="emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ $t('common.delete') }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</template>
|
||||||
253
frontend/src/components/calendar/CoursesCalendar.vue
Normal file
253
frontend/src/components/calendar/CoursesCalendar.vue
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Month calendar of the deadlines of every course. On a phone the days only show dots:
|
||||||
|
* the tasks of the selected day are listed under the calendar, on every screen.
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { CalendarCheck, ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { useCalendarTasks } from '@/services/api/tasks'
|
||||||
|
import { dayKey, monthWeeks, weeksPeriod } from '@/utils/calendar'
|
||||||
|
import { isPast } from '@/utils/dates'
|
||||||
|
import type { CalendarTask } from '@/utils/types'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
|
||||||
|
/** Tasks shown in a day cell on large screens, the others are counted */
|
||||||
|
const MAX_PER_DAY = 3
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const month = ref(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||||
|
const selectedDay = ref(dayKey(today))
|
||||||
|
|
||||||
|
const weeks = computed(() => monthWeeks(month.value.getFullYear(), month.value.getMonth()))
|
||||||
|
const {
|
||||||
|
data: tasks,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useCalendarTasks(() => weeksPeriod(weeks.value))
|
||||||
|
|
||||||
|
/** Tasks of each day, already sorted by deadline by the backend */
|
||||||
|
const tasksByDay = computed(() => {
|
||||||
|
const byDay = new Map<string, CalendarTask[]>()
|
||||||
|
for (const task of tasks.value ?? []) {
|
||||||
|
const key = dayKey(new Date(task.deadline))
|
||||||
|
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
||||||
|
}
|
||||||
|
return byDay
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTasks = computed(() => tasksByDay.value.get(selectedDay.value) ?? [])
|
||||||
|
|
||||||
|
const monthLabel = computed(() =>
|
||||||
|
new Intl.DateTimeFormat(locale.value, { month: 'long', year: 'numeric' }).format(month.value),
|
||||||
|
)
|
||||||
|
const weekdayLabels = computed(() =>
|
||||||
|
weeks.value[0]!.map((day) =>
|
||||||
|
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(day),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
const [year, m, d] = selectedDay.value.split('-').map(Number)
|
||||||
|
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'full' }).format(
|
||||||
|
new Date(year!, m! - 1, d),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function moveMonth(offset: number) {
|
||||||
|
const next = new Date(month.value.getFullYear(), month.value.getMonth() + offset, 1)
|
||||||
|
month.value = next
|
||||||
|
selectedDay.value = dayKey(
|
||||||
|
next.getMonth() === today.getMonth() && next.getFullYear() === today.getFullYear()
|
||||||
|
? today
|
||||||
|
: next,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
month.value = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
selectedDay.value = dayKey(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskColor(task: CalendarTask) {
|
||||||
|
return tagsById.value.get(task.tag_ids[0] ?? -1)?.color ?? 'var(--primary)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLate(task: CalendarTask) {
|
||||||
|
return !task.done && isPast(task.deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
function time(task: CalendarTask) {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(
|
||||||
|
new Date(task.deadline),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskLink(task: CalendarTask) {
|
||||||
|
return { name: 'course', params: { id: task.course_id }, query: { tab: 'tasks' } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="mr-auto text-lg font-semibold capitalize">{{ monthLabel }}</h2>
|
||||||
|
<Button variant="outline" size="sm" @click="goToday">{{ $t('calendar.today') }}</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('calendar.previous')"
|
||||||
|
@click="moveMonth(-1)"
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
<span class="sr-only">{{ $t('calendar.previous') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon-sm" :title="$t('calendar.next')" @click="moveMonth(1)">
|
||||||
|
<ChevronRight />
|
||||||
|
<span class="sr-only">{{ $t('calendar.next') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-xl border bg-card transition-opacity"
|
||||||
|
:class="{ 'opacity-60': isPending }"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-7 border-b">
|
||||||
|
<div
|
||||||
|
v-for="label in weekdayLabels"
|
||||||
|
:key="label"
|
||||||
|
class="py-2 text-center text-xs font-medium text-muted-foreground capitalize"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(week, index) in weeks"
|
||||||
|
:key="index"
|
||||||
|
class="grid grid-cols-7 [&:not(:last-child)]:border-b"
|
||||||
|
>
|
||||||
|
<!-- Not a <button>: it holds links -->
|
||||||
|
<div
|
||||||
|
v-for="day in week"
|
||||||
|
:key="dayKey(day)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex min-h-14 cursor-pointer min-w-0 flex-col gap-1 border-l p-1 text-left transition-colors first:border-l-0 hover:bg-accent/40 md:min-h-28 md:p-1.5"
|
||||||
|
:class="{
|
||||||
|
'bg-muted/40 text-muted-foreground': day.getMonth() !== month.getMonth(),
|
||||||
|
'ring-2 ring-primary ring-inset': dayKey(day) === selectedDay,
|
||||||
|
}"
|
||||||
|
:aria-pressed="dayKey(day) === selectedDay"
|
||||||
|
@click="selectedDay = dayKey(day)"
|
||||||
|
@keydown.enter.space.prevent="selectedDay = dayKey(day)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex size-6 items-center justify-center self-center rounded-full text-xs tabular-nums md:self-start"
|
||||||
|
:class="{
|
||||||
|
'bg-primary font-semibold text-primary-foreground': dayKey(day) === dayKey(today),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ day.getDate() }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Phone: one dot per task -->
|
||||||
|
<span class="flex flex-wrap justify-center gap-0.5 md:hidden">
|
||||||
|
<span
|
||||||
|
v-for="task in tasksByDay.get(dayKey(day)) ?? []"
|
||||||
|
:key="task.task_id"
|
||||||
|
class="size-1.5 rounded-full"
|
||||||
|
:class="{ 'opacity-40': task.done }"
|
||||||
|
:style="{ backgroundColor: taskColor(task) }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Large screens: the first tasks of the day -->
|
||||||
|
<span class="hidden min-w-0 flex-col gap-0.5 md:flex">
|
||||||
|
<RouterLink
|
||||||
|
v-for="task in (tasksByDay.get(dayKey(day)) ?? []).slice(0, MAX_PER_DAY)"
|
||||||
|
:key="task.task_id"
|
||||||
|
:to="taskLink(task)"
|
||||||
|
class="truncate rounded border-l-2 px-1 py-0.5 text-xs hover:underline"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through': task.done,
|
||||||
|
'font-medium text-destructive': isLate(task),
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
borderLeftColor: taskColor(task),
|
||||||
|
backgroundColor: `color-mix(in oklab, ${taskColor(task)} 12%, transparent)`,
|
||||||
|
}"
|
||||||
|
:title="`${task.course_code ?? task.course_name} · ${task.name}`"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<span v-if="task.course_code" class="font-mono text-[0.625rem] opacity-70">
|
||||||
|
{{ task.course_code }}
|
||||||
|
</span>
|
||||||
|
{{ task.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-if="(tasksByDay.get(dayKey(day))?.length ?? 0) > MAX_PER_DAY"
|
||||||
|
class="px-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ $t('calendar.more', tasksByDay.get(dayKey(day))!.length - MAX_PER_DAY) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="isError" class="text-sm text-destructive">
|
||||||
|
{{ $t('calendar.load-error') }}
|
||||||
|
<Button variant="link" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base first-letter:uppercase">{{ selectedLabel }}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
v-if="!selectedTasks.length"
|
||||||
|
class="flex flex-col items-center gap-2 py-4 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<CalendarCheck class="size-6" />
|
||||||
|
{{ $t('calendar.nothing') }}
|
||||||
|
</div>
|
||||||
|
<ul v-else class="flex flex-col gap-3">
|
||||||
|
<li v-for="task in selectedTasks" :key="task.task_id">
|
||||||
|
<RouterLink
|
||||||
|
:to="taskLink(task)"
|
||||||
|
class="flex flex-col gap-1 border-l-2 pl-3 hover:underline"
|
||||||
|
:style="{ borderLeftColor: taskColor(task) }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="{ 'text-muted-foreground line-through': task.done }"
|
||||||
|
>
|
||||||
|
{{ task.name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ time(task) }} · {{ task.course_name }}
|
||||||
|
<template v-if="task.course_code">({{ task.course_code }})</template>
|
||||||
|
<span v-if="isLate(task)" class="font-medium text-destructive">
|
||||||
|
· {{ $t('tasks.late') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
170
frontend/src/components/course/CourseFilesTab.vue
Normal file
170
frontend/src/components/course/CourseFilesTab.vue
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Every file of the course in one list, with their tags (slides, notes...). For now the
|
||||||
|
* only files are the lecture notes written in the app; uploads come with the NAS.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { FileText, FileWarning, FolderOpen, Pencil, Plus, Trash2, Upload } from '@lucide/vue'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { useCourseFiles, useLectureNoteMutations } from '@/services/api/course-files'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { formatDate } from '@/utils/dates'
|
||||||
|
import type { CourseFile } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ courseId: number }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.courseId)
|
||||||
|
const { remove } = useLectureNoteMutations(() => props.courseId)
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const deletedFile = ref<CourseFile>()
|
||||||
|
|
||||||
|
function askDelete(file: CourseFile) {
|
||||||
|
deletedFile.value = file
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deletedFile.value) return
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(deletedFile.value.id)
|
||||||
|
toast.success(t('files.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<CardTitle class="text-base">{{ $t('files.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('files.description') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<!-- A disabled button gets no pointer event: the tooltip is on its wrapper -->
|
||||||
|
<span tabindex="0">
|
||||||
|
<Button size="sm" variant="outline" disabled>
|
||||||
|
<Upload />
|
||||||
|
{{ $t('files.upload') }}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ $t('files.coming-soon') }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Button size="sm" as-child>
|
||||||
|
<RouterLink :to="{ name: 'course-note', params: { id: courseId, noteId: 'new' } }">
|
||||||
|
<Plus />
|
||||||
|
{{ $t('notes.new') }}
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="isPending" class="flex flex-col gap-2">
|
||||||
|
<Skeleton v-for="i in 3" :key="i" class="h-12" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isError" class="flex flex-col items-center gap-2 py-4 text-sm">
|
||||||
|
{{ $t('files.load-error') }}
|
||||||
|
<Button variant="outline" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!files?.length"
|
||||||
|
class="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<FolderOpen class="size-8" />
|
||||||
|
{{ $t('files.empty') }}
|
||||||
|
</div>
|
||||||
|
<ul v-else class="-mx-2 flex flex-col">
|
||||||
|
<li
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
class="relative flex items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="file.kind === 'note' && !file.has_pdf ? FileWarning : FileText"
|
||||||
|
class="size-5 shrink-0"
|
||||||
|
:class="
|
||||||
|
file.kind === 'note' && !file.has_pdf ? 'text-destructive' : 'text-muted-foreground'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'course-note', params: { id: courseId, noteId: file.id } }"
|
||||||
|
class="truncate text-sm font-medium after:absolute after:inset-0 after:content-['']"
|
||||||
|
>
|
||||||
|
{{ file.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<template v-for="tagId in file.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ $t('files.updated-on', { date: formatDate(file.updated_at, locale) }) }}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
v-if="file.kind === 'note' && !file.has_pdf"
|
||||||
|
variant="outline"
|
||||||
|
class="border-destructive/50 text-destructive"
|
||||||
|
>
|
||||||
|
{{ $t('notes.no-pdf') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative z-10 flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="file.kind === 'note'"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('common.edit')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{
|
||||||
|
name: 'course-note',
|
||||||
|
params: { id: courseId, noteId: file.id },
|
||||||
|
query: { edit: '1' },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
:title="$t('common.delete')"
|
||||||
|
@click="askDelete(file)"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('files.delete-title', { name: deletedFile?.name })"
|
||||||
|
:description="$t('files.delete-description')"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
171
frontend/src/components/course/CourseGradesTab.vue
Normal file
171
frontend/src/components/course/CourseGradesTab.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** Grades of the course and their weighted average out of 6, computed by the backend */
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { GraduationCap, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import GradeFormDialog from '@/components/course/GradeFormDialog.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { useCourseGrades, useGradeMutations } from '@/services/api/grades'
|
||||||
|
import type { Grade } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ courseId: number }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { data, isPending, isError, refetch } = useCourseGrades(() => props.courseId)
|
||||||
|
const { remove } = useGradeMutations(() => props.courseId)
|
||||||
|
|
||||||
|
/** Swiss passing grade */
|
||||||
|
const PASSING = 4
|
||||||
|
|
||||||
|
function number(value: number) {
|
||||||
|
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const editedGrade = ref<Grade>()
|
||||||
|
|
||||||
|
function openForm(grade?: Grade) {
|
||||||
|
editedGrade.value = grade
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const deletedGrade = ref<Grade>()
|
||||||
|
|
||||||
|
function askDelete(grade: Grade) {
|
||||||
|
deletedGrade.value = grade
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deletedGrade.value) return
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(deletedGrade.value.id)
|
||||||
|
toast.success(t('grades.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('grades.delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Skeleton v-if="isPending" class="h-48 w-full rounded-xl" />
|
||||||
|
|
||||||
|
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ $t('grades.load-error') }}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<Card class="flex items-center gap-4 px-5 py-4">
|
||||||
|
<GraduationCap class="size-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ $t('grades.average') }}</p>
|
||||||
|
<p class="text-3xl font-semibold tabular-nums">
|
||||||
|
<span
|
||||||
|
v-if="data.average !== null && data.average !== undefined"
|
||||||
|
:class="
|
||||||
|
data.average >= PASSING
|
||||||
|
? 'text-green-600 dark:text-green-500'
|
||||||
|
: 'text-destructive'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ number(data.average) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted-foreground">–</span>
|
||||||
|
<span class="text-lg font-normal text-muted-foreground"> / 6</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Button @click="openForm()">
|
||||||
|
<Plus />
|
||||||
|
{{ $t('grades.new') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="!data.grades.length" class="flex flex-col items-center gap-1 p-8 text-center">
|
||||||
|
<CardTitle class="text-base">{{ $t('grades.empty') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('grades.empty-hint') }}</CardDescription>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else class="overflow-hidden p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{{ $t('grades.form.name') }}</TableHead>
|
||||||
|
<TableHead class="text-right">{{ $t('grades.score') }}</TableHead>
|
||||||
|
<TableHead class="text-right">{{ $t('grades.swiss') }}</TableHead>
|
||||||
|
<TableHead class="text-right">{{ $t('grades.form.weight') }}</TableHead>
|
||||||
|
<TableHead class="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="grade in data.grades" :key="grade.id">
|
||||||
|
<TableCell class="font-medium">{{ grade.name }}</TableCell>
|
||||||
|
<TableCell class="text-right tabular-nums">
|
||||||
|
{{ number(grade.score) }} / {{ number(grade.max_score) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
class="text-right font-medium tabular-nums"
|
||||||
|
:class="{ 'text-destructive': grade.swiss_grade < PASSING }"
|
||||||
|
>
|
||||||
|
{{ number(grade.swiss_grade) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right tabular-nums">{{ number(grade.weight) }}</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('common.edit')"
|
||||||
|
@click="openForm(grade)"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
:title="$t('common.delete')"
|
||||||
|
@click="askDelete(grade)"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<p class="border-t px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
{{ $t('grades.total-weight', { total: number(data.total_weight) }) }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<GradeFormDialog v-model:open="formOpen" :course-id="courseId" :grade="editedGrade" />
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('grades.delete-title', { name: deletedGrade?.name })"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
65
frontend/src/components/course/CourseScheduleTab.vue
Normal file
65
frontend/src/components/course/CourseScheduleTab.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** Weekly slots of the course. They are added from the schedule page. */
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||||
|
import { useDeleteSlot } from '@/services/api/courses'
|
||||||
|
import { toHourMinutes } from '@/utils/timetable'
|
||||||
|
import type { Course, TimeSlot } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ course: Course }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const deleteSlot = useDeleteSlot()
|
||||||
|
|
||||||
|
const courses = computed(() => [props.course])
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const deletedSlot = ref<TimeSlot>()
|
||||||
|
const deletedSlotLabel = computed(() => {
|
||||||
|
const slot = deletedSlot.value
|
||||||
|
if (!slot) return ''
|
||||||
|
return `${t(`weekdays.${slot.weekday}`)} ${toHourMinutes(slot.start_time)}–${toHourMinutes(slot.end_time)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function askDeleteSlot(slot: TimeSlot) {
|
||||||
|
deletedSlot.value = slot
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteSlot() {
|
||||||
|
if (!deletedSlot.value) return
|
||||||
|
try {
|
||||||
|
await deleteSlot.mutateAsync({ courseId: props.course.id, slotId: deletedSlot.value.id })
|
||||||
|
toast.success(t('schedule.slot-deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('schedule.slot-delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Same layout as the schedule page: the page keeps the width of the screen and the
|
||||||
|
timetable scrolls horizontally inside it on a phone -->
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ course.slots.length ? $t('course.schedule.slots-hint') : $t('course.schedule.no-slot') }}
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'schedule' }"
|
||||||
|
class="text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{{ $t('nav.schedule') }}
|
||||||
|
</RouterLink>
|
||||||
|
</p>
|
||||||
|
<WeekTimetable :courses="courses" deletable @delete="askDeleteSlot" />
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('schedule.delete-slot-title', { slot: deletedSlotLabel })"
|
||||||
|
@confirm="confirmDeleteSlot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
269
frontend/src/components/course/CourseTasksTab.vue
Normal file
269
frontend/src/components/course/CourseTasksTab.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Homework, exams and anything to do for the course, with their subtasks. The backend
|
||||||
|
* sorts them (not done first, then by deadline) and nests the subtasks.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { CalendarClock, ClipboardList, ExternalLink, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
import TaskFormDialog from '@/components/course/TaskFormDialog.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { toTaskBody, useCourseTasks, useTaskMutations } from '@/services/api/tasks'
|
||||||
|
import { formatDateTime, isPast } from '@/utils/dates'
|
||||||
|
import type { Task } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ courseId: number }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
const { data, isPending, isError, refetch } = useCourseTasks(() => props.courseId)
|
||||||
|
const { create, update, remove } = useTaskMutations(() => props.courseId)
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const editedTask = ref<Task>()
|
||||||
|
const parentId = ref<number>()
|
||||||
|
|
||||||
|
function openForm(task?: Task, parent?: Task) {
|
||||||
|
editedTask.value = task
|
||||||
|
parentId.value = parent?.id
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDone(task: Task, done: boolean) {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ id: task.id, body: toTaskBody(task, { done }) })
|
||||||
|
} catch {
|
||||||
|
toast.error(t('tasks.save-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name typed in the "add a subtask" field of each task */
|
||||||
|
const newSubtasks = ref<Record<number, string>>({})
|
||||||
|
|
||||||
|
async function addSubtask(parent: Task) {
|
||||||
|
const name = newSubtasks.value[parent.id]?.trim()
|
||||||
|
if (!name) return
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({ parent_id: parent.id, name, done: false, tag_ids: [], links: [] })
|
||||||
|
newSubtasks.value[parent.id] = ''
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('tasks.save-error'), {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const deletedTask = ref<Task>()
|
||||||
|
|
||||||
|
function askDelete(task: Task) {
|
||||||
|
deletedTask.value = task
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deletedTask.value) return
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(deletedTask.value.id)
|
||||||
|
toast.success(t('tasks.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('tasks.delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLate(task: Task) {
|
||||||
|
return !task.done && !!task.deadline && isPast(task.deadline)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button @click="openForm()">
|
||||||
|
<Plus />
|
||||||
|
{{ $t('tasks.new') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isPending" class="flex flex-col gap-3">
|
||||||
|
<Skeleton v-for="i in 3" :key="i" class="h-24 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-else-if="isError" class="mx-auto flex max-w-md flex-col items-center gap-3 p-6">
|
||||||
|
<CardTitle class="text-base">{{ $t('tasks.load-error') }}</CardTitle>
|
||||||
|
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else-if="!data?.tasks.length" class="flex flex-col items-center gap-1 p-8 text-center">
|
||||||
|
<ClipboardList class="mb-2 size-8 text-muted-foreground" />
|
||||||
|
<CardTitle class="text-base">{{ $t('tasks.empty') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('tasks.empty-hint') }}</CardDescription>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Card
|
||||||
|
v-for="task in data.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="flex flex-col gap-3 p-4"
|
||||||
|
:class="{ 'opacity-60': task.done }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
class="mt-1"
|
||||||
|
:model-value="task.done"
|
||||||
|
:aria-label="$t('tasks.done')"
|
||||||
|
@update:model-value="setDone(task, $event === true)"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-medium" :class="{ 'line-through': task.done }">{{
|
||||||
|
task.name
|
||||||
|
}}</span>
|
||||||
|
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="task.deadline"
|
||||||
|
class="flex items-center gap-1 text-xs"
|
||||||
|
:class="isLate(task) ? 'font-medium text-destructive' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
<CalendarClock class="size-3.5" />
|
||||||
|
{{ formatDateTime(task.deadline, locale) }}
|
||||||
|
<template v-if="isLate(task)">· {{ $t('tasks.late') }}</template>
|
||||||
|
</span>
|
||||||
|
<p v-if="task.description" class="text-sm whitespace-pre-line text-muted-foreground">
|
||||||
|
{{ task.description }}
|
||||||
|
</p>
|
||||||
|
<ul v-if="task.links.length" class="flex flex-col gap-0.5">
|
||||||
|
<li v-for="link in task.links" :key="link" class="min-w-0">
|
||||||
|
<a
|
||||||
|
:href="link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex max-w-full items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink class="size-3.5 shrink-0" />
|
||||||
|
<span class="truncate">{{ link }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('common.edit')"
|
||||||
|
@click="openForm(task)"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
:title="$t('common.delete')"
|
||||||
|
@click="askDelete(task)"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtasks -->
|
||||||
|
<div class="ml-7 flex flex-col gap-1 border-l pl-3">
|
||||||
|
<div
|
||||||
|
v-for="subtask in task.subtasks"
|
||||||
|
:key="subtask.id"
|
||||||
|
class="group flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:model-value="subtask.done"
|
||||||
|
:aria-label="$t('tasks.done')"
|
||||||
|
@update:model-value="setDone(subtask, $event === true)"
|
||||||
|
/>
|
||||||
|
<span :class="{ 'text-muted-foreground line-through': subtask.done }">
|
||||||
|
{{ subtask.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="subtask.deadline"
|
||||||
|
class="text-xs"
|
||||||
|
:class="isLate(subtask) ? 'font-medium text-destructive' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ formatDateTime(subtask.deadline, locale) }}
|
||||||
|
</span>
|
||||||
|
<template v-for="tagId in subtask.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="ml-auto flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7"
|
||||||
|
:title="$t('common.edit')"
|
||||||
|
@click="openForm(subtask)"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7 text-destructive hover:text-destructive"
|
||||||
|
:title="$t('common.delete')"
|
||||||
|
@click="askDelete(subtask)"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="flex items-center gap-2" @submit.prevent="addSubtask(task)">
|
||||||
|
<Plus class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="newSubtasks[task.id]"
|
||||||
|
class="h-8 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||||
|
:placeholder="$t('tasks.add-subtask')"
|
||||||
|
:aria-label="$t('tasks.add-subtask')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="newSubtasks[task.id]?.trim()"
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="create.isPending.value"
|
||||||
|
>
|
||||||
|
{{ $t('common.add') }}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<TaskFormDialog
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:course-id="courseId"
|
||||||
|
:task="editedTask"
|
||||||
|
:parent-id="parentId"
|
||||||
|
/>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('tasks.delete-title', { name: deletedTask?.name })"
|
||||||
|
:description="deletedTask?.subtasks.length ? $t('tasks.delete-description') : undefined"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
147
frontend/src/components/course/GradeFormDialog.vue
Normal file
147
frontend/src/components/course/GradeFormDialog.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** Adds a grade to a course, or edits `grade` when given */
|
||||||
|
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 { useGradeMutations } from '@/services/api/grades'
|
||||||
|
import type { Grade } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ courseId: number; grade?: Grade }>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { save } = useGradeMutations(() => props.courseId)
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
// Strings: an empty field is not a 0
|
||||||
|
const score = ref('')
|
||||||
|
const maxScore = ref('')
|
||||||
|
const weight = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
name.value = props.grade?.name ?? ''
|
||||||
|
score.value = props.grade ? String(props.grade.score) : ''
|
||||||
|
maxScore.value = props.grade ? String(props.grade.max_score) : '6'
|
||||||
|
weight.value = props.grade ? String(props.grade.weight) : ''
|
||||||
|
submitted.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Accepts "4,5" as well as "4.5" */
|
||||||
|
function parse(value: string): number {
|
||||||
|
return value.trim() === '' ? NaN : Number(value.replace(',', '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rules as the backend, checked here to point at the faulty field
|
||||||
|
const errors = computed(() => {
|
||||||
|
const [s, max, w] = [parse(score.value), parse(maxScore.value), parse(weight.value)]
|
||||||
|
return {
|
||||||
|
name: !name.value.trim(),
|
||||||
|
score: !(max > 0) || !(s >= 0 && s <= max),
|
||||||
|
weight: !(w > 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
if (Object.values(errors.value).some(Boolean)) return
|
||||||
|
try {
|
||||||
|
await save.mutateAsync({
|
||||||
|
id: props.grade?.id,
|
||||||
|
body: {
|
||||||
|
name: name.value.trim(),
|
||||||
|
score: parse(score.value),
|
||||||
|
max_score: parse(maxScore.value),
|
||||||
|
weight: parse(weight.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success(props.grade ? t('grades.updated') : t('grades.created'))
|
||||||
|
open.value = false
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('grades.save-error'), {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ grade ? $t('grades.edit') : $t('grades.new') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ $t('grades.form.description') }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form id="grade-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
||||||
|
<div class="col-span-2 flex flex-col gap-2">
|
||||||
|
<Label for="grade-name">{{ $t('grades.form.name') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="grade-name"
|
||||||
|
v-model="name"
|
||||||
|
:placeholder="$t('grades.form.name-placeholder')"
|
||||||
|
:aria-invalid="submitted && errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="grade-score">{{ $t('grades.form.score') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="grade-score"
|
||||||
|
v-model="score"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="5"
|
||||||
|
:aria-invalid="submitted && errors.score"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="grade-max">{{ $t('grades.form.max-score') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="grade-max"
|
||||||
|
v-model="maxScore"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="6"
|
||||||
|
:aria-invalid="submitted && errors.score"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="submitted && errors.score" class="col-span-2 -mt-2 text-sm text-destructive">
|
||||||
|
{{ $t('grades.form.errors.score') }}
|
||||||
|
</p>
|
||||||
|
<div class="col-span-2 flex flex-col gap-2">
|
||||||
|
<Label for="grade-weight">{{ $t('grades.form.weight') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="grade-weight"
|
||||||
|
v-model="weight"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="30"
|
||||||
|
:aria-invalid="submitted && errors.weight"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ $t('grades.form.weight-hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||||
|
<Button type="submit" form="grade-form" :disabled="save.isPending.value">
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
185
frontend/src/components/course/MarkdownEditor.vue
Normal file
185
frontend/src/components/course/MarkdownEditor.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Markdown textarea with a toolbar for what the course template supports: headings,
|
||||||
|
* lists, code, math, tables and the definition / theorem / example boxes
|
||||||
|
* (`::: {.defbox title="..."}`, turned into tcolorbox by `box-filter.lua`).
|
||||||
|
*/
|
||||||
|
import { ref, type Component } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
BookMarked,
|
||||||
|
Code,
|
||||||
|
FileCode,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Highlighter,
|
||||||
|
Italic,
|
||||||
|
Lightbulb,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Sigma,
|
||||||
|
SquareFunction,
|
||||||
|
Table,
|
||||||
|
Sparkles,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { applyEdit, block, prefixLines, wrap, type Edit } from '@/utils/markdown-editor'
|
||||||
|
|
||||||
|
const content = defineModel<string>({ required: true })
|
||||||
|
const emit = defineEmits<{ save: [] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const textarea = ref<HTMLTextAreaElement>()
|
||||||
|
|
||||||
|
type Action = (selection: { text: string; start: number; end: number }) => Edit
|
||||||
|
|
||||||
|
interface Tool {
|
||||||
|
/** i18n key under `notes.editor` */
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
action: Action
|
||||||
|
shortcut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADING = /^#{1,6} /
|
||||||
|
const LIST = /^(\s*)([-*] |\d+\. )/
|
||||||
|
|
||||||
|
/** Box of the template, titled with the translated label of the tool */
|
||||||
|
function box(kind: string, label: string): Action {
|
||||||
|
return (s) =>
|
||||||
|
block(
|
||||||
|
s,
|
||||||
|
`::: {.${kind} title="${t(`notes.editor.${label}`)}"}\n`,
|
||||||
|
'\n:::',
|
||||||
|
t('notes.editor.box-content'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: Tool[][] = [
|
||||||
|
[
|
||||||
|
{ label: 'h1', icon: Heading1, action: (s) => prefixLines(s, () => '# ', HEADING) },
|
||||||
|
{ label: 'h2', icon: Heading2, action: (s) => prefixLines(s, () => '## ', HEADING) },
|
||||||
|
{ label: 'h3', icon: Heading3, action: (s) => prefixLines(s, () => '### ', HEADING) },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'bold',
|
||||||
|
icon: Bold,
|
||||||
|
shortcut: 'Ctrl+B',
|
||||||
|
action: (s) => wrap(s, '**', '**', t('notes.editor.text')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'italic',
|
||||||
|
icon: Italic,
|
||||||
|
shortcut: 'Ctrl+I',
|
||||||
|
action: (s) => wrap(s, '*', '*', t('notes.editor.text')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'highlight',
|
||||||
|
icon: Highlighter,
|
||||||
|
action: (s) => wrap(s, '\\hword{', '}', t('notes.editor.text')),
|
||||||
|
},
|
||||||
|
{ label: 'code', icon: Code, action: (s) => wrap(s, '`', '`', 'code') },
|
||||||
|
{
|
||||||
|
label: 'link',
|
||||||
|
icon: Link,
|
||||||
|
action: (s) => wrap(s, '[', '](https://)', t('notes.editor.text')),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'bullets', icon: List, action: (s) => prefixLines(s, () => '- ', LIST) },
|
||||||
|
{
|
||||||
|
label: 'numbered',
|
||||||
|
icon: ListOrdered,
|
||||||
|
action: (s) => prefixLines(s, (i) => `${i + 1}. `, LIST),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'math', icon: Sigma, action: (s) => wrap(s, '$', '$', 'x^2') },
|
||||||
|
{
|
||||||
|
label: 'math-block',
|
||||||
|
icon: SquareFunction,
|
||||||
|
action: (s) => block(s, '$$\n', '\n$$', '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'code-block',
|
||||||
|
icon: FileCode,
|
||||||
|
action: (s) => block(s, '```c\n', '\n```', 'int main() {\n return 0;\n}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'table',
|
||||||
|
icon: Table,
|
||||||
|
action: (s) => block(s, '', '\n|:--|:--:|--:|\n| 1 | 2 | 3 |', '| A | B | C |'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'definition', icon: BookMarked, action: box('defbox', 'definition') },
|
||||||
|
{ label: 'theorem', icon: Sparkles, action: box('theobox', 'theorem') },
|
||||||
|
{ label: 'example', icon: Lightbulb, action: box('exbox', 'example') },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
function run(action: Action) {
|
||||||
|
const el = textarea.value
|
||||||
|
if (!el) return
|
||||||
|
applyEdit(el, action({ text: el.value, start: el.selectionStart, end: el.selectionEnd }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
const el = event.currentTarget as HTMLTextAreaElement
|
||||||
|
const mod = event.ctrlKey || event.metaKey
|
||||||
|
if (mod && event.key === 's') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('save')
|
||||||
|
} else if (mod && event.key === 'b') {
|
||||||
|
event.preventDefault()
|
||||||
|
run(groups[1]![0]!.action)
|
||||||
|
} else if (mod && event.key === 'i') {
|
||||||
|
event.preventDefault()
|
||||||
|
run(groups[1]![1]!.action)
|
||||||
|
} else if (event.key === 'Tab' && !event.shiftKey && el.selectionStart === el.selectionEnd) {
|
||||||
|
// Indents (nested lists) instead of leaving the editor
|
||||||
|
event.preventDefault()
|
||||||
|
applyEdit(el, { from: el.selectionStart, to: el.selectionEnd, insert: ' ', select: [2, 2] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div class="flex flex-wrap items-center gap-0.5 border-b bg-muted/40 p-1">
|
||||||
|
<template v-for="(group, index) in groups" :key="index">
|
||||||
|
<Separator v-if="index > 0" orientation="vertical" class="mx-0.5 !h-5" />
|
||||||
|
<Tooltip v-for="tool in group" :key="tool.label">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
:aria-label="$t(`notes.editor.${tool.label}`)"
|
||||||
|
@click="run(tool.action)"
|
||||||
|
>
|
||||||
|
<component :is="tool.icon" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{{ $t(`notes.editor.${tool.label}`) }}
|
||||||
|
<span v-if="tool.shortcut" class="ml-1 opacity-70">{{ tool.shortcut }}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref="textarea"
|
||||||
|
v-model="content"
|
||||||
|
class="min-h-[60svh] flex-1 resize-none bg-transparent p-4 font-mono text-sm leading-relaxed outline-none"
|
||||||
|
spellcheck="true"
|
||||||
|
:placeholder="$t('notes.editor.placeholder')"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
frontend/src/components/course/TagBadge.vue
Normal file
18
frontend/src/components/course/TagBadge.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tag } from '@/utils/types'
|
||||||
|
|
||||||
|
defineProps<{ tag: Tag }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: `color-mix(in oklab, ${tag.color} 15%, transparent)`,
|
||||||
|
color: `color-mix(in oklab, ${tag.color} 75%, var(--foreground))`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="size-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
219
frontend/src/components/course/TaskFormDialog.vue
Normal file
219
frontend/src/components/course/TaskFormDialog.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Creates a task (or a subtask of `parentId`), or edits `task` when given. A task needs
|
||||||
|
* at least one tag, a subtask may have none.
|
||||||
|
*/
|
||||||
|
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 { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { useTaskMutations } from '@/services/api/tasks'
|
||||||
|
import { fromDateTimeInput, toDateTimeInput } from '@/utils/dates'
|
||||||
|
import type { Task, TaskBody } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ courseId: number; task?: Task; parentId?: number }>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { data: tags } = useTags('task')
|
||||||
|
const { create, update } = useTaskMutations(() => props.courseId)
|
||||||
|
|
||||||
|
const isSubtask = computed(() => (props.task ? props.task.parent_id != null : !!props.parentId))
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const tagIds = ref<number[]>([])
|
||||||
|
const deadline = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const links = ref<string[]>([])
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
name.value = props.task?.name ?? ''
|
||||||
|
tagIds.value = [...(props.task?.tag_ids ?? [])]
|
||||||
|
deadline.value = props.task?.deadline ? toDateTimeInput(props.task.deadline) : ''
|
||||||
|
description.value = props.task?.description ?? ''
|
||||||
|
links.value = [...(props.task?.links ?? [])]
|
||||||
|
submitted.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
tagIds.value = tagIds.value.includes(id)
|
||||||
|
? tagIds.value.filter((tagId) => tagId !== id)
|
||||||
|
: [...tagIds.value, id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rules as the backend, checked here to point at the faulty field
|
||||||
|
const errors = computed(() => ({
|
||||||
|
name: !name.value.trim(),
|
||||||
|
tags: !isSubtask.value && tagIds.value.length === 0,
|
||||||
|
links: links.value.some((link) => !/^https?:\/\//.test(link.trim())),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isPending = computed(() => create.isPending.value || update.isPending.value)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
if (Object.values(errors.value).some(Boolean)) return
|
||||||
|
|
||||||
|
const body: TaskBody = {
|
||||||
|
name: name.value.trim(),
|
||||||
|
description: description.value.trim() || null,
|
||||||
|
links: links.value.map((link) => link.trim()),
|
||||||
|
deadline: fromDateTimeInput(deadline.value),
|
||||||
|
done: props.task?.done ?? false,
|
||||||
|
tag_ids: tagIds.value,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (props.task) {
|
||||||
|
await update.mutateAsync({ id: props.task.id, body })
|
||||||
|
toast.success(t('tasks.updated'))
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({ ...body, parent_id: props.parentId ?? null })
|
||||||
|
toast.success(t('tasks.created'))
|
||||||
|
}
|
||||||
|
open.value = false
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('tasks.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>
|
||||||
|
{{ task ? $t('tasks.edit') : isSubtask ? $t('tasks.new-subtask') : $t('tasks.new') }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{{ $t('tasks.form.description') }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form id="task-form" class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="task-name">{{ $t('tasks.form.name') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="task-name"
|
||||||
|
v-model="name"
|
||||||
|
:placeholder="$t('tasks.form.name-placeholder')"
|
||||||
|
:aria-invalid="submitted && errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="mb-2 text-sm font-medium">
|
||||||
|
{{ $t('tasks.form.tags') }}
|
||||||
|
<span v-if="isSubtask" class="font-normal text-muted-foreground">
|
||||||
|
({{ $t('courses.form.optional') }})
|
||||||
|
</span>
|
||||||
|
</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors"
|
||||||
|
:class="
|
||||||
|
tagIds.includes(tag.id) ? 'font-medium' : 'text-muted-foreground hover:bg-accent'
|
||||||
|
"
|
||||||
|
:style="
|
||||||
|
tagIds.includes(tag.id)
|
||||||
|
? {
|
||||||
|
borderColor: tag.color,
|
||||||
|
backgroundColor: `color-mix(in oklab, ${tag.color} 15%, transparent)`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:aria-pressed="tagIds.includes(tag.id)"
|
||||||
|
@click="toggleTag(tag.id)"
|
||||||
|
>
|
||||||
|
<span class="size-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="submitted && errors.tags" class="text-sm text-destructive">
|
||||||
|
{{ $t('tasks.form.errors.tags') }}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="task-deadline">
|
||||||
|
{{ $t('tasks.form.deadline') }}
|
||||||
|
<span class="font-normal text-muted-foreground">
|
||||||
|
({{ $t('courses.form.optional') }})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="task-deadline" v-model="deadline" type="datetime-local" class="w-fit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="task-description">
|
||||||
|
{{ $t('tasks.form.details') }}
|
||||||
|
<span class="font-normal text-muted-foreground">
|
||||||
|
({{ $t('courses.form.optional') }})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea id="task-description" v-model="description" rows="3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="task-links">
|
||||||
|
{{ $t('tasks.form.links') }}
|
||||||
|
<span class="font-normal text-muted-foreground">
|
||||||
|
({{ $t('courses.form.optional') }})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<TagsInput
|
||||||
|
v-model="links"
|
||||||
|
add-on-blur
|
||||||
|
add-on-paste
|
||||||
|
:aria-invalid="submitted && errors.links"
|
||||||
|
class="aria-invalid:border-destructive"
|
||||||
|
>
|
||||||
|
<TagsInputItem v-for="link in links" :key="link" :value="link" class="max-w-full">
|
||||||
|
<TagsInputItemText class="truncate" />
|
||||||
|
<TagsInputItemDelete />
|
||||||
|
</TagsInputItem>
|
||||||
|
<TagsInputInput id="task-links" :placeholder="$t('tasks.form.links-placeholder')" />
|
||||||
|
</TagsInput>
|
||||||
|
<p v-if="submitted && errors.links" 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="task-form" :disabled="isPending">
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Creates a course, or edits `course` when given. Slots are not edited here but by
|
* 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.
|
* selecting a time range in the schedule.
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
TagsInputItemDelete,
|
TagsInputItemDelete,
|
||||||
TagsInputItemText,
|
TagsInputItemText,
|
||||||
} from '@/components/ui/tags-input'
|
} from '@/components/ui/tags-input'
|
||||||
import { toCourseBody, useCreateCourse, useUpdateCourse } from '@/services/api/courses'
|
import { useCreateCourse, useUpdateCourse } from '@/services/api/courses'
|
||||||
import type { Course, CourseBody } from '@/utils/types'
|
import type { Course, CourseBody } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ course?: Course }>()
|
const props = defineProps<{ course?: Course }>()
|
||||||
|
|
@ -78,7 +78,6 @@ async function submit() {
|
||||||
code: code.value.trim() || null,
|
code: code.value.trim() || null,
|
||||||
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
|
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
|
||||||
main_page_url: mainPageUrl.value.trim() || null,
|
main_page_url: mainPageUrl.value.trim() || null,
|
||||||
slots: props.course ? toCourseBody(props.course).slots : [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/** Adds a slot to a course, prefilled with the time range selected in the schedule */
|
||||||
* 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 { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
@ -25,8 +22,8 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { toCourseBody, useCourses, useUpdateCourse } from '@/services/api/courses'
|
import { useAddSlot, useCourses } from '@/services/api/courses'
|
||||||
import { useSlotTags } from '@/services/api/slot-tags'
|
import { useTags } from '@/services/api/tags'
|
||||||
import {
|
import {
|
||||||
DAY_END,
|
DAY_END,
|
||||||
DAY_START,
|
DAY_START,
|
||||||
|
|
@ -41,8 +38,8 @@ const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { data: courses } = useCourses()
|
const { data: courses } = useCourses()
|
||||||
const { data: tags } = useSlotTags()
|
const { data: tags } = useTags('slot')
|
||||||
const updateCourse = useUpdateCourse()
|
const addSlot = useAddSlot()
|
||||||
|
|
||||||
const courseId = ref<number>()
|
const courseId = ref<number>()
|
||||||
const tagId = ref<number>()
|
const tagId = ref<number>()
|
||||||
|
|
@ -85,20 +82,19 @@ const errors = computed(() => {
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
const course = courses.value?.find((c) => c.id === courseId.value)
|
if (errors.value.course || errors.value.time) return
|
||||||
if (errors.value.course || errors.value.time || !course || tagId.value === undefined) return
|
if (courseId.value === undefined || tagId.value === undefined) return
|
||||||
|
|
||||||
const slots = [
|
try {
|
||||||
...toCourseBody(course).slots,
|
await addSlot.mutateAsync({
|
||||||
{
|
courseId: courseId.value,
|
||||||
|
body: {
|
||||||
tag_id: tagId.value,
|
tag_id: tagId.value,
|
||||||
weekday: weekday.value,
|
weekday: weekday.value,
|
||||||
start_time: toApiTime(start.value),
|
start_time: toApiTime(start.value),
|
||||||
end_time: toApiTime(end.value),
|
end_time: toApiTime(end.value),
|
||||||
},
|
},
|
||||||
]
|
})
|
||||||
try {
|
|
||||||
await updateCourse.mutateAsync({ id: course.id, body: toCourseBody(course, slots) })
|
|
||||||
toast.success(t('schedule.slot-created'))
|
toast.success(t('schedule.slot-created'))
|
||||||
open.value = false
|
open.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -195,7 +191,7 @@ async function submit() {
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||||
<Button type="submit" form="slot-form" :disabled="updateCourse.isPending.value">
|
<Button type="submit" form="slot-form" :disabled="addSlot.isPending.value">
|
||||||
{{ $t('common.save') }}
|
{{ $t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
256
frontend/src/components/timetable/WeekTimetable.vue
Normal file
256
frontend/src/components/timetable/WeekTimetable.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Typical week (no dates): monday to friday, 8:00 to 19:00, with the slots of `courses`.
|
||||||
|
* Slots that overlap are drawn side by side.
|
||||||
|
* - `selectable`: a time range can be selected with the pointer, emitted as `select`
|
||||||
|
* - `linkToCourse`: clicking a slot opens the page of its course
|
||||||
|
* - `deletable`: each slot gets a button emitting `delete` (not with `linkToCourse`)
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import {
|
||||||
|
DAY_END_HOUR,
|
||||||
|
DAY_START_HOUR,
|
||||||
|
WEEKDAYS,
|
||||||
|
fromMinutes,
|
||||||
|
layoutOverlapping,
|
||||||
|
toHourMinutes,
|
||||||
|
toMinutes,
|
||||||
|
type SlotSelection,
|
||||||
|
} from '@/utils/timetable'
|
||||||
|
import type { Course, Tag, TimeSlot } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
courses: Course[]
|
||||||
|
selectable?: boolean
|
||||||
|
linkToCourse?: boolean
|
||||||
|
deletable?: boolean
|
||||||
|
/** Css height of the day columns */
|
||||||
|
height?: string
|
||||||
|
}>(),
|
||||||
|
{ height: '55rem' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [selection: SlotSelection]
|
||||||
|
delete: [slot: TimeSlot, course: Course]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
|
||||||
|
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?: Tag
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positioned blocks of each weekday */
|
||||||
|
const days = computed(() => {
|
||||||
|
const blocks: Block[] = props.courses.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 (!props.selectable || 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSelection() {
|
||||||
|
const range = selectedRange.value
|
||||||
|
const weekday = selection.value?.weekday
|
||||||
|
selection.value = undefined
|
||||||
|
if (!range || weekday === undefined) return
|
||||||
|
emit('select', { weekday, start: fromMinutes(range.start), end: fromMinutes(range.end) })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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" :style="{ height }">
|
||||||
|
<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 -->
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.weekday"
|
||||||
|
class="relative border-l"
|
||||||
|
:class="{ 'cursor-cell select-none': selectable }"
|
||||||
|
:style="{ height }"
|
||||||
|
@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>
|
||||||
|
<component
|
||||||
|
:is="linkToCourse ? RouterLink : 'div'"
|
||||||
|
:to="linkToCourse ? { name: 'course', params: { id: block.course.id } } : undefined"
|
||||||
|
class="group absolute flex 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"
|
||||||
|
:class="{ 'cursor-pointer': linkToCourse, 'cursor-default': !linkToCourse }"
|
||||||
|
:style="blockStyle(block, lane, lanes)"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<span class="line-clamp-2 pr-4 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>
|
||||||
|
<button
|
||||||
|
v-if="deletable"
|
||||||
|
type="button"
|
||||||
|
class="absolute top-0.5 right-0.5 rounded p-0.5 text-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/15 [@media(hover:none)]:opacity-100 hover:text-destructive focus-visible:opacity-100"
|
||||||
|
:title="$t('schedule.delete-slot')"
|
||||||
|
@click.stop.prevent="emit('delete', block.slot, block.course)"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
<span class="sr-only">{{ $t('schedule.delete-slot') }}</span>
|
||||||
|
</button>
|
||||||
|
</component>
|
||||||
|
</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>
|
||||||
33
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
33
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Check } from '@lucide/vue'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="grid place-content-center text-current">
|
||||||
|
<slot>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
frontend/src/components/ui/checkbox/index.ts
Normal file
1
frontend/src/components/ui/checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Checkbox } from './Checkbox.vue'
|
||||||
15
frontend/src/components/ui/tabs/Tabs.vue
Normal file
15
frontend/src/components/ui/tabs/Tabs.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||||
|
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<TabsRootProps>()
|
||||||
|
const emits = defineEmits<TabsRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</TabsRoot>
|
||||||
|
</template>
|
||||||
25
frontend/src/components/ui/tabs/TabsContent.vue
Normal file
25
frontend/src/components/ui/tabs/TabsContent.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsContent } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsContent>
|
||||||
|
</template>
|
||||||
25
frontend/src/components/ui/tabs/TabsList.vue
Normal file
25
frontend/src/components/ui/tabs/TabsList.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsListProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsList } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsList
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsList>
|
||||||
|
</template>
|
||||||
29
frontend/src/components/ui/tabs/TabsTrigger.vue
Normal file
29
frontend/src/components/ui/tabs/TabsTrigger.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsTriggerProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsTrigger
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="truncate">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</template>
|
||||||
4
frontend/src/components/ui/tabs/index.ts
Normal file
4
frontend/src/components/ui/tabs/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as Tabs } from './Tabs.vue'
|
||||||
|
export { default as TabsContent } from './TabsContent.vue'
|
||||||
|
export { default as TabsList } from './TabsList.vue'
|
||||||
|
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||||
1083
frontend/src/lib/api.d.ts
vendored
1083
frontend/src/lib/api.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,7 @@ common:
|
||||||
delete: Delete
|
delete: Delete
|
||||||
edit: Edit
|
edit: Edit
|
||||||
retry: Retry
|
retry: Retry
|
||||||
|
add: Add
|
||||||
courses:
|
courses:
|
||||||
new: New course
|
new: New course
|
||||||
edit: Edit course
|
edit: Edit course
|
||||||
|
|
@ -70,8 +71,14 @@ courses:
|
||||||
course:
|
course:
|
||||||
back: Back to courses
|
back: Back to courses
|
||||||
not-found: This course does not exist.
|
not-found: This course does not exist.
|
||||||
slots: Slots
|
tabs:
|
||||||
no-slot: No slot. Select a time range in the timetable to add one.
|
files: Lectures
|
||||||
|
tasks: Deadlines
|
||||||
|
schedule: Timetable
|
||||||
|
grades: Grades
|
||||||
|
schedule:
|
||||||
|
slots-hint: 'Delete a slot with its cross. To add one, select a time range in the'
|
||||||
|
no-slot: 'No slot. Select a time range in the'
|
||||||
schedule:
|
schedule:
|
||||||
empty: No slot to show.
|
empty: No slot to show.
|
||||||
empty-hint: Click or drag on the timetable to add a slot.
|
empty-hint: Click or drag on the timetable to add a slot.
|
||||||
|
|
@ -86,7 +93,129 @@ schedule:
|
||||||
slot-save-error: Unable to add the slot
|
slot-save-error: Unable to add the slot
|
||||||
slot-delete-error: Unable to delete the slot
|
slot-delete-error: Unable to delete the slot
|
||||||
delete-slot: Delete the slot
|
delete-slot: Delete the slot
|
||||||
|
delete-slot-title: Do you really want to delete the slot of {slot}?
|
||||||
form:
|
form:
|
||||||
course: Course
|
course: Course
|
||||||
|
grades:
|
||||||
|
new: Add a grade
|
||||||
|
edit: Edit the grade
|
||||||
|
created: Grade added
|
||||||
|
updated: Grade updated
|
||||||
|
deleted: Grade deleted
|
||||||
|
save-error: Unable to save the grade
|
||||||
|
delete-error: Unable to delete the grade
|
||||||
|
load-error: Unable to load the grades.
|
||||||
|
average: Course average
|
||||||
|
empty: No grade yet.
|
||||||
|
empty-hint: Add your grades to follow your average.
|
||||||
|
score: Score
|
||||||
|
swiss: Grade /6
|
||||||
|
total-weight: 'Total weight: {total}. Weights are relative to each other, they do not need to add up to 100.'
|
||||||
|
delete-title: Do you really want to delete the grade “{name}”?
|
||||||
|
form:
|
||||||
|
description: On any scale, it is converted to a grade out of 6.
|
||||||
|
name: Assessment
|
||||||
|
name-placeholder: Midterm
|
||||||
|
score: Score
|
||||||
|
max-score: Maximal score
|
||||||
|
weight: Weight
|
||||||
|
weight-hint: What this grade counts for in the total, e.g. 30 for 30 %.
|
||||||
|
errors:
|
||||||
|
score: The score must be between 0 and the maximal score.
|
||||||
|
tasks:
|
||||||
|
new: New task
|
||||||
|
new-subtask: New subtask
|
||||||
|
edit: Edit the task
|
||||||
|
created: Task added
|
||||||
|
updated: Task updated
|
||||||
|
deleted: Task deleted
|
||||||
|
save-error: Unable to save the task
|
||||||
|
delete-error: Unable to delete the task
|
||||||
|
load-error: Unable to load the tasks.
|
||||||
|
empty: No task yet.
|
||||||
|
empty-hint: Add your homework, exams and things to do for this course.
|
||||||
|
done: Done
|
||||||
|
late: late
|
||||||
|
add-subtask: Add a subtask…
|
||||||
|
delete-title: Do you really want to delete “{name}”?
|
||||||
|
delete-description: Its subtasks will be deleted too.
|
||||||
|
form:
|
||||||
|
description: Homework, an exam or anything else to do.
|
||||||
|
name: Name
|
||||||
|
name-placeholder: Exercise set 3
|
||||||
|
tags: Tags
|
||||||
|
deadline: Deadline
|
||||||
|
details: Description
|
||||||
|
links: Links
|
||||||
|
links-placeholder: https://…, then Enter
|
||||||
|
errors:
|
||||||
|
tags: Pick at least one tag.
|
||||||
|
notes:
|
||||||
|
new: New note
|
||||||
|
no-pdf: Pdf unavailable
|
||||||
|
no-pdf-hint: The last typesetting failed. Fix the note to generate the pdf.
|
||||||
|
not-found: This note does not exist.
|
||||||
|
saved: Note saved
|
||||||
|
saved-without-pdf: Note saved, but the pdf could not be generated
|
||||||
|
save-error: Unable to save the note
|
||||||
|
deleted: Note deleted
|
||||||
|
delete-error: Unable to delete the note
|
||||||
|
default-title: 'Lecture {n} Notes: '
|
||||||
|
default-content: "# Introduction\n\n"
|
||||||
|
title-label: Title
|
||||||
|
title-placeholder: 'Lecture 1 Notes: Memory subsystem'
|
||||||
|
rendering: Typesetting…
|
||||||
|
download: Download
|
||||||
|
pdf-error: 'LaTeX error: the pdf could not be generated'
|
||||||
|
preview-hint: Save to generate the pdf, it will be shown here.
|
||||||
|
leave-confirm: Your changes are not saved. Leave anyway?
|
||||||
|
errors:
|
||||||
|
title: The title is required.
|
||||||
|
editor:
|
||||||
|
placeholder: Write your note in markdown…
|
||||||
|
text: text
|
||||||
|
box-content: Content
|
||||||
|
h1: Section
|
||||||
|
h2: Subsection
|
||||||
|
h3: Subsubsection
|
||||||
|
bold: Bold
|
||||||
|
italic: Italic
|
||||||
|
highlight: Highlight a word
|
||||||
|
code: Code
|
||||||
|
link: Link
|
||||||
|
bullets: Bullet list
|
||||||
|
numbered: Numbered list
|
||||||
|
math: Formula
|
||||||
|
math-block: Centered formula
|
||||||
|
code-block: Code block
|
||||||
|
table: Table
|
||||||
|
definition: Definition
|
||||||
|
theorem: Theorem
|
||||||
|
example: Example
|
||||||
|
files:
|
||||||
|
title: Course files
|
||||||
|
description: Slides, lecture notes and documents, soon stored on your NAS.
|
||||||
|
upload: Upload
|
||||||
|
coming-soon: File uploads are coming soon.
|
||||||
|
empty: No file yet. Start with a lecture note.
|
||||||
|
load-error: Unable to load the files.
|
||||||
|
updated-on: updated on {date}
|
||||||
|
deleted: File deleted
|
||||||
|
delete-error: Unable to delete the file
|
||||||
|
delete-title: Do you really want to delete “{name}”?
|
||||||
|
delete-description: The file will be permanently deleted.
|
||||||
|
calendar:
|
||||||
|
tabs:
|
||||||
|
courses: Courses
|
||||||
|
associations: Associations
|
||||||
|
today: Today
|
||||||
|
previous: Previous month
|
||||||
|
next: Next month
|
||||||
|
more: '+{n} more'
|
||||||
|
nothing: Nothing due that day.
|
||||||
|
load-error: Unable to load the deadlines.
|
||||||
|
associations:
|
||||||
|
title: Association tasks
|
||||||
|
coming-soon: The tasks of your associations will show up here. Associations are coming soon.
|
||||||
placeholder:
|
placeholder:
|
||||||
coming-soon: This page is coming soon.
|
coming-soon: This page is coming soon.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ common:
|
||||||
delete: Supprimer
|
delete: Supprimer
|
||||||
edit: Modifier
|
edit: Modifier
|
||||||
retry: Réessayer
|
retry: Réessayer
|
||||||
|
add: Ajouter
|
||||||
courses:
|
courses:
|
||||||
new: Nouveau cours
|
new: Nouveau cours
|
||||||
edit: Modifier le cours
|
edit: Modifier le cours
|
||||||
|
|
@ -70,8 +71,14 @@ courses:
|
||||||
course:
|
course:
|
||||||
back: Retour aux cours
|
back: Retour aux cours
|
||||||
not-found: Ce cours n'existe pas.
|
not-found: Ce cours n'existe pas.
|
||||||
slots: Créneaux
|
tabs:
|
||||||
no-slot: Aucun créneau. Sélectionne une plage horaire dans l'emploi du temps pour en ajouter.
|
files: Cours
|
||||||
|
tasks: Échéances
|
||||||
|
schedule: Emploi du temps
|
||||||
|
grades: Notes
|
||||||
|
schedule:
|
||||||
|
slots-hint: 'Supprime un créneau avec sa croix. Pour en ajouter, sélectionne une plage horaire dans'
|
||||||
|
no-slot: 'Aucun créneau. Sélectionne une plage horaire dans'
|
||||||
schedule:
|
schedule:
|
||||||
empty: Aucun créneau à afficher.
|
empty: Aucun créneau à afficher.
|
||||||
empty-hint: Clique ou glisse sur le calendrier pour ajouter un créneau.
|
empty-hint: Clique ou glisse sur le calendrier pour ajouter un créneau.
|
||||||
|
|
@ -86,7 +93,129 @@ schedule:
|
||||||
slot-save-error: Impossible d'ajouter le créneau
|
slot-save-error: Impossible d'ajouter le créneau
|
||||||
slot-delete-error: Impossible de supprimer le créneau
|
slot-delete-error: Impossible de supprimer le créneau
|
||||||
delete-slot: Supprimer le créneau
|
delete-slot: Supprimer le créneau
|
||||||
|
delete-slot-title: Voulez-vous vraiment supprimer le créneau du {slot} ?
|
||||||
form:
|
form:
|
||||||
course: Cours
|
course: Cours
|
||||||
|
grades:
|
||||||
|
new: Ajouter une note
|
||||||
|
edit: Modifier la note
|
||||||
|
created: Note ajoutée
|
||||||
|
updated: Note modifiée
|
||||||
|
deleted: Note supprimée
|
||||||
|
save-error: Impossible d'enregistrer la note
|
||||||
|
delete-error: Impossible de supprimer la note
|
||||||
|
load-error: Impossible de charger les notes.
|
||||||
|
average: Moyenne du cours
|
||||||
|
empty: Aucune note pour le moment.
|
||||||
|
empty-hint: Ajoute tes notes pour suivre ta moyenne.
|
||||||
|
score: Résultat
|
||||||
|
swiss: Note /6
|
||||||
|
total-weight: "Poids total : {total}. Les poids sont relatifs entre eux, ils n'ont pas besoin de faire 100."
|
||||||
|
delete-title: Voulez-vous vraiment supprimer la note « {name} » ?
|
||||||
|
form:
|
||||||
|
description: Sur n'importe quelle échelle, elle est convertie sur 6.
|
||||||
|
name: Évaluation
|
||||||
|
name-placeholder: Examen intermédiaire
|
||||||
|
score: Note obtenue
|
||||||
|
max-score: Note maximale
|
||||||
|
weight: Poids
|
||||||
|
weight-hint: Ce que vaut cette note dans le total, par exemple 30 pour 30 %.
|
||||||
|
errors:
|
||||||
|
score: La note obtenue doit être entre 0 et la note maximale.
|
||||||
|
tasks:
|
||||||
|
new: Nouvelle tâche
|
||||||
|
new-subtask: Nouvelle sous-tâche
|
||||||
|
edit: Modifier la tâche
|
||||||
|
created: Tâche ajoutée
|
||||||
|
updated: Tâche modifiée
|
||||||
|
deleted: Tâche supprimée
|
||||||
|
save-error: Impossible d'enregistrer la tâche
|
||||||
|
delete-error: Impossible de supprimer la tâche
|
||||||
|
load-error: Impossible de charger les tâches.
|
||||||
|
empty: Aucune tâche pour le moment.
|
||||||
|
empty-hint: Ajoute tes devoirs, examens et choses à faire pour ce cours.
|
||||||
|
done: Terminée
|
||||||
|
late: en retard
|
||||||
|
add-subtask: Ajouter une sous-tâche…
|
||||||
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
|
delete-description: Ses sous-tâches seront aussi supprimées.
|
||||||
|
form:
|
||||||
|
description: Un devoir, un examen ou toute autre chose à faire.
|
||||||
|
name: Nom
|
||||||
|
name-placeholder: Série 3
|
||||||
|
tags: Étiquettes
|
||||||
|
deadline: Échéance
|
||||||
|
details: Description
|
||||||
|
links: Liens
|
||||||
|
links-placeholder: https://…, puis Entrée
|
||||||
|
errors:
|
||||||
|
tags: Choisis au moins une étiquette.
|
||||||
|
notes:
|
||||||
|
new: Nouvelle note
|
||||||
|
no-pdf: PDF indisponible
|
||||||
|
no-pdf-hint: La dernière mise en page a échoué. Corrige la note pour générer le PDF.
|
||||||
|
not-found: Cette note n'existe pas.
|
||||||
|
saved: Note enregistrée
|
||||||
|
saved-without-pdf: Note enregistrée, mais le PDF n'a pas pu être généré
|
||||||
|
save-error: Impossible d'enregistrer la note
|
||||||
|
deleted: Note supprimée
|
||||||
|
delete-error: Impossible de supprimer la note
|
||||||
|
default-title: 'Lecture {n} Notes: '
|
||||||
|
default-content: "# Introduction\n\n"
|
||||||
|
title-label: Titre
|
||||||
|
title-placeholder: 'Lecture 1 Notes: Memory subsystem'
|
||||||
|
rendering: Mise en page…
|
||||||
|
download: Télécharger
|
||||||
|
pdf-error: "Erreur LaTeX : le PDF n'a pas pu être généré"
|
||||||
|
preview-hint: Enregistre pour générer le PDF, il s'affichera ici.
|
||||||
|
leave-confirm: Tes modifications ne sont pas enregistrées. Quitter quand même ?
|
||||||
|
errors:
|
||||||
|
title: Le titre est obligatoire.
|
||||||
|
editor:
|
||||||
|
placeholder: Écris ta note en markdown…
|
||||||
|
text: texte
|
||||||
|
box-content: Contenu
|
||||||
|
h1: Titre de section
|
||||||
|
h2: Sous-section
|
||||||
|
h3: Sous-sous-section
|
||||||
|
bold: Gras
|
||||||
|
italic: Italique
|
||||||
|
highlight: Surligner un mot
|
||||||
|
code: Code
|
||||||
|
link: Lien
|
||||||
|
bullets: Liste à puces
|
||||||
|
numbered: Liste numérotée
|
||||||
|
math: Formule
|
||||||
|
math-block: Formule centrée
|
||||||
|
code-block: Bloc de code
|
||||||
|
table: Tableau
|
||||||
|
definition: Définition
|
||||||
|
theorem: Théorème
|
||||||
|
example: Exemple
|
||||||
|
files:
|
||||||
|
title: Fichiers du cours
|
||||||
|
description: Slides, notes de cours et documents, bientôt stockés sur ton NAS.
|
||||||
|
upload: Importer
|
||||||
|
coming-soon: L'import de fichiers arrive bientôt.
|
||||||
|
empty: Aucun fichier pour le moment. Commence par une note de cours.
|
||||||
|
load-error: Impossible de charger les fichiers.
|
||||||
|
updated-on: modifié le {date}
|
||||||
|
deleted: Fichier supprimé
|
||||||
|
delete-error: Impossible de supprimer le fichier
|
||||||
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
|
delete-description: Le fichier sera supprimé définitivement.
|
||||||
|
calendar:
|
||||||
|
tabs:
|
||||||
|
courses: Cours
|
||||||
|
associations: Associatif
|
||||||
|
today: Aujourd'hui
|
||||||
|
previous: Mois précédent
|
||||||
|
next: Mois suivant
|
||||||
|
more: '+{n} autre | +{n} autres'
|
||||||
|
nothing: Rien à rendre ce jour-là.
|
||||||
|
load-error: Impossible de charger les échéances.
|
||||||
|
associations:
|
||||||
|
title: Tâches associatives
|
||||||
|
coming-soon: Les tâches de tes associations apparaîtront ici. Les associations arrivent bientôt.
|
||||||
placeholder:
|
placeholder:
|
||||||
coming-soon: Cette page arrive bientôt.
|
coming-soon: Cette page arrive bientôt.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,17 @@ const router = createRouter({
|
||||||
props: (route) => ({ id: Number(route.params.id) }),
|
props: (route) => ({ id: Number(route.params.id) }),
|
||||||
meta: { title: 'nav.courses', section: 'courses' },
|
meta: { title: 'nav.courses', section: 'courses' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'course-note',
|
||||||
|
// `new` to write a new note
|
||||||
|
path: '/courses/:id(\\d+)/notes/:noteId(\\d+|new)',
|
||||||
|
component: () => import('@/views/LectureNoteView.vue'),
|
||||||
|
props: (route) => ({
|
||||||
|
id: Number(route.params.id),
|
||||||
|
noteId: route.params.noteId === 'new' ? undefined : Number(route.params.noteId),
|
||||||
|
}),
|
||||||
|
meta: { title: 'nav.courses', section: 'courses' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'pomodoro',
|
name: 'pomodoro',
|
||||||
path: '/pomodoro',
|
path: '/pomodoro',
|
||||||
|
|
|
||||||
95
frontend/src/services/api/course-files.ts
Normal file
95
frontend/src/services/api/course-files.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
import type { CourseFile, LectureNoteBody } from '@/utils/types'
|
||||||
|
import { getClient } from './client'
|
||||||
|
|
||||||
|
function filesKey(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return ['course', courseId, 'files']
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteKey(id: MaybeRefOrGetter<number | undefined>) {
|
||||||
|
return ['note', id]
|
||||||
|
}
|
||||||
|
|
||||||
|
function unexpected(status: number): never {
|
||||||
|
throw new Error(`Unexpected status code received: ${status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Url of the pdf, changing on every save so the viewer never shows a stale one */
|
||||||
|
export function lectureNotePdfUrl(note: CourseFile) {
|
||||||
|
return `/api/notes/${note.id}/pdf?v=${encodeURIComponent(note.updated_at)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files of a course (lecture notes...), without their content */
|
||||||
|
export function useCourseFiles(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: filesKey(courseId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/courses/{id}/files', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note with its markdown, disabled while `id` is undefined (new note) */
|
||||||
|
export function useLectureNote(id: MaybeRefOrGetter<number | undefined>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: noteKey(id),
|
||||||
|
enabled: () => toValue(id) !== undefined,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/notes/{id}', {
|
||||||
|
params: { path: { id: toValue(id)! } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLectureNoteMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
/** Creates the note without `id`. The pdf error, if any, is in the result. */
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id?: number; body: LectureNoteBody }) => {
|
||||||
|
if (id === undefined) {
|
||||||
|
const { data, response } = await getClient().POST('/api/courses/{id}/notes', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.CREATED && data ? data : unexpected(response.status)
|
||||||
|
}
|
||||||
|
const { data, response } = await getClient().PUT('/api/notes/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess: ({ note }, { body }) => {
|
||||||
|
queryClient.setQueryData<CourseFile[]>(filesKey(toValue(courseId)), (notes) =>
|
||||||
|
notes ? [...notes.filter((n) => n.id !== note.id), note] : undefined,
|
||||||
|
)
|
||||||
|
queryClient.setQueryData(noteKey(note.id), { ...note, content: body.content })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { response } = await getClient().DELETE('/api/notes/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.NO_CONTENT ? id : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess: (id) => {
|
||||||
|
queryClient.setQueryData<CourseFile[]>(filesKey(toValue(courseId)), (notes) =>
|
||||||
|
notes?.filter((n) => n.id !== id),
|
||||||
|
)
|
||||||
|
queryClient.removeQueries({ queryKey: noteKey(id) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { save, remove }
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,26 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
import type { Course, CourseBody, TimeSlotBody } from '@/utils/types'
|
import type { Course, CourseBody, TimeSlotBody } from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
const COURSES_KEY = ['courses']
|
const COURSES_KEY = ['courses']
|
||||||
|
|
||||||
|
export function courseKey(id: MaybeRefOrGetter<number>) {
|
||||||
|
return ['course', id]
|
||||||
|
}
|
||||||
|
|
||||||
function sortByName(courses: Course[]) {
|
function sortByName(courses: Course[]) {
|
||||||
return [...courses].sort((a, b) => a.name.localeCompare(b.name))
|
return [...courses].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Puts a course returned by the backend in both the list and its own cache */
|
||||||
* Body that keeps `course` as is, except its slots when `slots` is given. Needed because
|
function storeCourse(queryClient: QueryClient, course: Course) {
|
||||||
* the update replaces the whole course, slots included.
|
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
||||||
*/
|
courses ? sortByName([...courses.filter((c) => c.id !== course.id), course]) : undefined,
|
||||||
export function toCourseBody(course: Course, slots?: TimeSlotBody[]): CourseBody {
|
)
|
||||||
return {
|
queryClient.setQueryData(courseKey(course.id), course)
|
||||||
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() {
|
export function useCourses() {
|
||||||
|
|
@ -44,6 +36,25 @@ export function useCourses() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCourse(id: MaybeRefOrGetter<number>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: courseKey(id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/courses/{id}', {
|
||||||
|
params: { path: { id: toValue(id) } },
|
||||||
|
})
|
||||||
|
if (response.status === HttpStatus.OK && data) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
|
},
|
||||||
|
// Shown right away when coming from the list, refreshed in the background
|
||||||
|
placeholderData: () =>
|
||||||
|
queryClient.getQueryData<Course[]>(COURSES_KEY)?.find((c) => c.id === toValue(id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateCourse() {
|
export function useCreateCourse() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
@ -54,11 +65,7 @@ export function useCreateCourse() {
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
},
|
},
|
||||||
onSuccess: (course) => {
|
onSuccess: (course) => storeCourse(queryClient, course),
|
||||||
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
|
||||||
sortByName([...(courses ?? []), course]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,11 +82,7 @@ export function useUpdateCourse() {
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
},
|
},
|
||||||
onSuccess: (course) => {
|
onSuccess: (course) => storeCourse(queryClient, course),
|
||||||
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
|
||||||
sortByName((courses ?? []).map((c) => (c.id === course.id ? course : c))),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +102,40 @@ export function useDeleteCourse() {
|
||||||
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
||||||
(courses ?? []).filter((c) => c.id !== id),
|
(courses ?? []).filter((c) => c.id !== id),
|
||||||
)
|
)
|
||||||
|
queryClient.removeQueries({ queryKey: courseKey(id) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAddSlot() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ courseId, body }: { courseId: number; body: TimeSlotBody }) => {
|
||||||
|
const { data, response } = await getClient().POST('/api/courses/{id}/slots', {
|
||||||
|
params: { path: { id: courseId } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (response.status === HttpStatus.OK && data) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
|
},
|
||||||
|
onSuccess: (course) => storeCourse(queryClient, course),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSlot() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ courseId, slotId }: { courseId: number; slotId: number }) => {
|
||||||
|
const { data, response } = await getClient().DELETE('/api/courses/{id}/slots/{slot_id}', {
|
||||||
|
params: { path: { id: courseId, slot_id: slotId } },
|
||||||
|
})
|
||||||
|
if (response.status === HttpStatus.OK && data) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
|
},
|
||||||
|
onSuccess: (course) => storeCourse(queryClient, course),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
62
frontend/src/services/api/grades.ts
Normal file
62
frontend/src/services/api/grades.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
import type { CourseGrades, GradeBody } from '@/utils/types'
|
||||||
|
import { getClient } from './client'
|
||||||
|
|
||||||
|
// Every mutation answers with all the grades of the course and the new average, which
|
||||||
|
// replace the cache: no refetch needed.
|
||||||
|
|
||||||
|
function gradesKey(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return ['course', courseId, 'grades']
|
||||||
|
}
|
||||||
|
|
||||||
|
function unexpected(status: number): never {
|
||||||
|
throw new Error(`Unexpected status code received: ${status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCourseGrades(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: gradesKey(courseId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/courses/{id}/grades', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create (no `id`), update or delete the grades of one course */
|
||||||
|
export function useGradeMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const onSuccess = (grades: CourseGrades) =>
|
||||||
|
queryClient.setQueryData(gradesKey(toValue(courseId)), grades)
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id?: number; body: GradeBody }) => {
|
||||||
|
const { data, response } =
|
||||||
|
id === undefined
|
||||||
|
? await getClient().POST('/api/courses/{id}/grades', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
: await getClient().PUT('/api/grades/{id}', { params: { path: { id } }, body })
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data, response } = await getClient().DELETE('/api/grades/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { save, remove }
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,22 @@ import { useQuery } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
import { HttpStatus } from 'http-status-ts'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { SlotTag } from '@/utils/types'
|
import type { Tag, TagScope } from '@/utils/types'
|
||||||
import { i18n } from '../i18n'
|
import { i18n } from '../i18n'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
/** Kinds of time slots (lecture, exercises, lab...), with their name in the current locale */
|
/**
|
||||||
export function useSlotTags() {
|
* Tags (lecture, exam, slides...) with their name in the current locale. Every tag, or
|
||||||
|
* only the ones that can label `scope` (to choose the tags of a task...).
|
||||||
|
*/
|
||||||
|
export function useTags(scope?: TagScope) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['slot-tags'],
|
queryKey: ['tags', scope],
|
||||||
staleTime: 3600 * 1000,
|
staleTime: 3600 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, response } = await getClient().GET('/api/slot-tags')
|
const { data, response } = await getClient().GET('/api/tags', {
|
||||||
|
params: { query: { scope } },
|
||||||
|
})
|
||||||
if (response.status === HttpStatus.OK && data) {
|
if (response.status === HttpStatus.OK && data) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +27,7 @@ export function useSlotTags() {
|
||||||
|
|
||||||
const locale = i18n.locale
|
const locale = i18n.locale
|
||||||
|
|
||||||
const tags = computed<SlotTag[]>(
|
const tags = computed<Tag[]>(
|
||||||
() =>
|
() =>
|
||||||
query.data.value?.map((tag) => ({
|
query.data.value?.map((tag) => ({
|
||||||
...tag,
|
...tag,
|
||||||
101
frontend/src/services/api/tasks.ts
Normal file
101
frontend/src/services/api/tasks.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
import type { CourseTasks, CreateTaskBody, Task, TaskBody } from '@/utils/types'
|
||||||
|
import { getClient } from './client'
|
||||||
|
|
||||||
|
// Every mutation answers with all the tasks of the course (nested, sorted) and the
|
||||||
|
// deadlines, which replace the cache: no refetch needed.
|
||||||
|
|
||||||
|
function tasksKey(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return ['course', courseId, 'tasks']
|
||||||
|
}
|
||||||
|
|
||||||
|
function unexpected(status: number): never {
|
||||||
|
throw new Error(`Unexpected status code received: ${status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body replacing `task` by itself, with `changes` applied */
|
||||||
|
export function toTaskBody(task: Task, changes: Partial<TaskBody> = {}): TaskBody {
|
||||||
|
return {
|
||||||
|
name: task.name,
|
||||||
|
description: task.description,
|
||||||
|
links: task.links,
|
||||||
|
deadline: task.deadline,
|
||||||
|
done: task.done,
|
||||||
|
tag_ids: task.tag_ids,
|
||||||
|
...changes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCourseTasks(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: tasksKey(courseId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/courses/{id}/tasks', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tasks and subtasks of every course with a deadline in `[from, to[` (ISO dates), for
|
||||||
|
* the calendar
|
||||||
|
*/
|
||||||
|
export function useCalendarTasks(period: MaybeRefOrGetter<{ from: string; to: string }>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendar-tasks', period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/tasks', {
|
||||||
|
params: { query: toValue(period) },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const onSuccess = (tasks: CourseTasks) => {
|
||||||
|
queryClient.setQueryData(tasksKey(toValue(courseId)), tasks)
|
||||||
|
// Refetched when the calendar is shown again
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-tasks'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (body: CreateTaskBody) => {
|
||||||
|
const { data, response } = await getClient().POST('/api/courses/{id}/tasks', {
|
||||||
|
params: { path: { id: toValue(courseId) } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id: number; body: TaskBody }) => {
|
||||||
|
const { data, response } = await getClient().PUT('/api/tasks/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data, response } = await getClient().DELETE('/api/tasks/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { create, update, remove }
|
||||||
|
}
|
||||||
39
frontend/src/utils/calendar.ts
Normal file
39
frontend/src/utils/calendar.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/** Month grid of the calendar, in local time. Weeks start on monday. */
|
||||||
|
|
||||||
|
/** "2026-10-05", in local time: the key of a day */
|
||||||
|
export function dayKey(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monday of the week of `date`, at midnight */
|
||||||
|
function startOfWeek(date: Date): Date {
|
||||||
|
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const offset = (day.getDay() + 6) % 7
|
||||||
|
day.setDate(day.getDate() - offset)
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weeks covering the month (`month` from 0), from the monday before its first day to
|
||||||
|
* the sunday after its last one: 4 to 6 weeks of 7 days
|
||||||
|
*/
|
||||||
|
export function monthWeeks(year: number, month: number): Date[][] {
|
||||||
|
const first = startOfWeek(new Date(year, month, 1))
|
||||||
|
const last = new Date(year, month + 1, 0)
|
||||||
|
const weeks: Date[][] = []
|
||||||
|
const day = new Date(first)
|
||||||
|
while (day <= last || weeks.length === 0 || weeks.at(-1)!.length < 7) {
|
||||||
|
if (!weeks.length || weeks.at(-1)!.length === 7) weeks.push([])
|
||||||
|
weeks.at(-1)!.push(new Date(day))
|
||||||
|
day.setDate(day.getDate() + 1)
|
||||||
|
}
|
||||||
|
return weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[from, to[` of the weeks, as ISO dates for the api */
|
||||||
|
export function weeksPeriod(weeks: Date[][]): { from: string; to: string } {
|
||||||
|
const end = new Date(weeks.at(-1)!.at(-1)!)
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
return { from: weeks[0]![0]!.toISOString(), to: end.toISOString() }
|
||||||
|
}
|
||||||
49
frontend/src/utils/dates.ts
Normal file
49
frontend/src/utils/dates.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/** ISO date -> "2026-10-10T22:00" in local time, the format of `<input type="datetime-local">` */
|
||||||
|
export function toDateTimeInput(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value of `<input type="datetime-local">` (local time) -> ISO date, `null` when empty */
|
||||||
|
export function fromDateTimeInput(value: string): string | null {
|
||||||
|
return value ? new Date(value).toISOString() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "ven. 10 oct., 22:00" */
|
||||||
|
export function formatDateTime(iso: string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "10 oct. 2026" */
|
||||||
|
export function formatDate(iso: string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "dans 3 jours", "il y a 2 heures"... relative to now */
|
||||||
|
export function formatRelative(iso: string, locale: string): string {
|
||||||
|
const seconds = (new Date(iso).getTime() - Date.now()) / 1000
|
||||||
|
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['day', 86400],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60],
|
||||||
|
]
|
||||||
|
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
||||||
|
for (const [unit, size] of units) {
|
||||||
|
if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit)
|
||||||
|
}
|
||||||
|
return format.format(0, 'minute')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPast(iso: string): boolean {
|
||||||
|
return new Date(iso).getTime() < Date.now()
|
||||||
|
}
|
||||||
97
frontend/src/utils/markdown-editor.ts
Normal file
97
frontend/src/utils/markdown-editor.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* Edits of the markdown editor (toolbar and shortcuts). Each helper computes an `Edit`
|
||||||
|
* from the text and the selection; `applyEdit` performs it on the textarea through
|
||||||
|
* `insertText`, so that ctrl+z still works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Edit {
|
||||||
|
/** Range of the text replaced by `insert` */
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
insert: string
|
||||||
|
/** Selection after the edit, relative to `from` */
|
||||||
|
select: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
text: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `**selection**`, or the placeholder selected when nothing is */
|
||||||
|
export function wrap(
|
||||||
|
{ text, start, end }: Selection,
|
||||||
|
before: string,
|
||||||
|
after: string,
|
||||||
|
placeholder: string,
|
||||||
|
): Edit {
|
||||||
|
const content = text.slice(start, end) || placeholder
|
||||||
|
return {
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
insert: before + content + after,
|
||||||
|
select: [before.length, before.length + content.length],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds `prefix(index)` to every line touched by the selection, after removing what
|
||||||
|
* `replaced` matches (e.g. an existing heading marker)
|
||||||
|
*/
|
||||||
|
export function prefixLines(
|
||||||
|
{ text, start, end }: Selection,
|
||||||
|
prefix: (index: number) => string,
|
||||||
|
replaced: RegExp,
|
||||||
|
): Edit {
|
||||||
|
const from = text.lastIndexOf('\n', start - 1) + 1
|
||||||
|
const lineEnd = text.indexOf('\n', end)
|
||||||
|
const to = lineEnd === -1 ? text.length : lineEnd
|
||||||
|
const insert = text
|
||||||
|
.slice(from, to)
|
||||||
|
.split('\n')
|
||||||
|
.map((line, index) => prefix(index) + line.replace(replaced, ''))
|
||||||
|
.join('\n')
|
||||||
|
return { from, to, insert, select: [insert.length, insert.length] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block on its own lines (code, box, table...): `before + content + after`, where the
|
||||||
|
* content is the selection or the placeholder, which ends up selected
|
||||||
|
*/
|
||||||
|
export function block(
|
||||||
|
{ text, start, end }: Selection,
|
||||||
|
before: string,
|
||||||
|
after: string,
|
||||||
|
placeholder: string,
|
||||||
|
): Edit {
|
||||||
|
const content = text.slice(start, end) || placeholder
|
||||||
|
const textBefore = text.slice(0, start)
|
||||||
|
const textAfter = text.slice(end)
|
||||||
|
const lead =
|
||||||
|
textBefore === '' || textBefore.endsWith('\n\n')
|
||||||
|
? ''
|
||||||
|
: textBefore.endsWith('\n')
|
||||||
|
? '\n'
|
||||||
|
: '\n\n'
|
||||||
|
const trail = textAfter.startsWith('\n\n') ? '' : textAfter.startsWith('\n') ? '\n' : '\n\n'
|
||||||
|
const offset = lead.length + before.length
|
||||||
|
return {
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
insert: lead + before + content + after + trail,
|
||||||
|
select: [offset, offset + content.length],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyEdit(textarea: HTMLTextAreaElement, edit: Edit) {
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(edit.from, edit.to)
|
||||||
|
// Deprecated but the only way to keep the undo history of a textarea
|
||||||
|
const done = document.execCommand('insertText', false, edit.insert)
|
||||||
|
if (!done) {
|
||||||
|
textarea.setRangeText(edit.insert, edit.from, edit.to, 'end')
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
}
|
||||||
|
textarea.setSelectionRange(edit.from + edit.select[0], edit.from + edit.select[1])
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
import type { components } from '@/lib/api'
|
import type { components } from '@/lib/api'
|
||||||
|
|
||||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
||||||
export type LocalizedString = components['schemas']['LocalizedString']
|
type Schemas = components['schemas']
|
||||||
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 type LocalizedString = Schemas['LocalizedString']
|
||||||
export interface SlotTag {
|
export type Course = Schemas['Course']
|
||||||
|
export type CourseBody = Schemas['CourseBody']
|
||||||
|
export type TimeSlot = Schemas['TimeSlot']
|
||||||
|
export type TimeSlotBody = Schemas['TimeSlotBody']
|
||||||
|
export type Grade = Schemas['Grade']
|
||||||
|
export type GradeBody = Schemas['GradeBody']
|
||||||
|
export type CourseGrades = Schemas['CourseGrades']
|
||||||
|
export type Task = Schemas['Task']
|
||||||
|
export type TaskBody = Schemas['TaskBody']
|
||||||
|
export type CreateTaskBody = Schemas['CreateTaskBody']
|
||||||
|
export type CourseTasks = Schemas['CourseTasks']
|
||||||
|
export type CalendarTask = Schemas['CalendarTask']
|
||||||
|
export type CourseFile = Schemas['CourseFile']
|
||||||
|
export type TagScope = Schemas['TagScope']
|
||||||
|
export type LectureNoteBody = Schemas['LectureNoteBody']
|
||||||
|
export type LectureNoteContent = Schemas['LectureNoteContent']
|
||||||
|
export type SavedLectureNote = Schemas['SavedLectureNote']
|
||||||
|
|
||||||
|
/** Tag with its name resolved in the current locale */
|
||||||
|
export interface Tag {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,41 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
/** Deadlines of every course in a month calendar, and (soon) the ones of the associations */
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
import CoursesCalendar from '@/components/calendar/CoursesCalendar.vue'
|
||||||
|
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const TABS = ['courses', 'associations'] as const
|
||||||
|
type Tab = (typeof TABS)[number]
|
||||||
|
|
||||||
|
const tab = computed<Tab>({
|
||||||
|
get: () => TABS.find((tab) => tab === route.query.tab) ?? 'courses',
|
||||||
|
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagePlaceholder title="nav.calendar" />
|
<Tabs v-model="tab" class="mx-auto max-w-6xl gap-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger v-for="name in TABS" :key="name" :value="name">
|
||||||
|
{{ $t(`calendar.tabs.${name}`) }}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="courses" class="min-w-0">
|
||||||
|
<CoursesCalendar />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="associations">
|
||||||
|
<Card class="mx-auto flex max-w-md flex-col items-center gap-2 p-8 text-center">
|
||||||
|
<Users class="mb-2 size-8 text-muted-foreground" />
|
||||||
|
<CardTitle class="text-base">{{ $t('calendar.associations.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('calendar.associations.coming-soon') }}</CardDescription>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,38 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** Personal page of a course. For now: its details and its weekly slots. */
|
/** Personal page of a course: its files, deadlines, timetable and grades */
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { ArrowLeft, ExternalLink } from '@lucide/vue'
|
||||||
import { ArrowLeft, ExternalLink, Trash2 } from '@lucide/vue'
|
|
||||||
|
|
||||||
|
import CourseFilesTab from '@/components/course/CourseFilesTab.vue'
|
||||||
|
import CourseGradesTab from '@/components/course/CourseGradesTab.vue'
|
||||||
|
import CourseScheduleTab from '@/components/course/CourseScheduleTab.vue'
|
||||||
|
import CourseTasksTab from '@/components/course/CourseTasksTab.vue'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toCourseBody, useCourses, useUpdateCourse } from '@/services/api/courses'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useSlotTags } from '@/services/api/slot-tags'
|
import { useCourse } from '@/services/api/courses'
|
||||||
import { toHourMinutes } from '@/utils/timetable'
|
|
||||||
import type { TimeSlot } from '@/utils/types'
|
|
||||||
|
|
||||||
const props = defineProps<{ id: number }>()
|
const props = defineProps<{ id: number }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const route = useRoute()
|
||||||
const { data: courses, isPending, isError, refetch } = useCourses()
|
const router = useRouter()
|
||||||
const { tagsById } = useSlotTags()
|
const { data: course, isPending, isError, refetch } = useCourse(() => props.id)
|
||||||
const updateCourse = useUpdateCourse()
|
|
||||||
|
|
||||||
const course = computed(() => courses.value?.find((c) => c.id === props.id))
|
const TABS = ['files', 'tasks', 'schedule', 'grades'] as const
|
||||||
|
type Tab = (typeof TABS)[number]
|
||||||
|
|
||||||
async function removeSlot(slot: TimeSlot) {
|
// In the url, so that coming back from a note opens the files again
|
||||||
if (!course.value) return
|
const tab = computed<Tab>({
|
||||||
const slots = course.value.slots.filter((s) => s.id !== slot.id)
|
get: () => TABS.find((tab) => tab === route.query.tab) ?? 'files',
|
||||||
try {
|
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-4">
|
<div class="mx-auto flex max-w-6xl flex-col gap-4">
|
||||||
<Button variant="ghost" class="self-start" as-child>
|
<Button variant="ghost" class="self-start" as-child>
|
||||||
<RouterLink :to="{ name: 'courses' }">
|
<RouterLink :to="{ name: 'courses' }">
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
|
|
@ -58,60 +44,51 @@ async function removeSlot(slot: TimeSlot) {
|
||||||
|
|
||||||
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
|
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ isError ? $t('courses.load-error') : $t('course.not-found') }}</CardTitle>
|
<CardTitle>{{ $t('course.not-found') }}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter v-if="isError" class="justify-center">
|
<CardFooter class="justify-center">
|
||||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-else>
|
<template v-else>
|
||||||
<CardHeader>
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle class="text-2xl">{{ course.name }}</CardTitle>
|
<h2 class="text-2xl font-semibold">{{ course.name }}</h2>
|
||||||
<Badge v-if="course.code" variant="secondary" class="font-mono">
|
<Badge v-if="course.code" variant="secondary" class="font-mono">
|
||||||
{{ course.code }}
|
{{ course.code }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{{ course.teachers.join(', ') }}</CardDescription>
|
<p class="text-sm text-muted-foreground">{{ course.teachers.join(', ') }}</p>
|
||||||
<Button v-if="course.main_page_url" variant="link" class="self-start px-0" as-child>
|
</div>
|
||||||
|
<Button v-if="course.main_page_url" variant="outline" as-child>
|
||||||
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
|
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink />
|
<ExternalLink />
|
||||||
{{ $t('courses.main-page') }}
|
{{ $t('courses.main-page') }}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</header>
|
||||||
|
|
||||||
<CardContent class="flex flex-col gap-2">
|
<Tabs v-model="tab" class="gap-4">
|
||||||
<h2 class="text-sm font-medium">{{ $t('course.slots') }}</h2>
|
<TabsList class="w-full justify-start overflow-x-auto sm:w-auto">
|
||||||
<ul v-if="course.slots.length" class="flex flex-col gap-1 text-sm">
|
<TabsTrigger v-for="name in TABS" :key="name" :value="name">
|
||||||
<li v-for="slot in course.slots" :key="slot.id" class="flex items-center gap-2">
|
{{ $t(`course.tabs.${name}`) }}
|
||||||
<span
|
</TabsTrigger>
|
||||||
class="size-2 shrink-0 rounded-full"
|
</TabsList>
|
||||||
:style="{ backgroundColor: tagsById.get(slot.tag_id)?.color }"
|
<TabsContent value="files">
|
||||||
/>
|
<CourseFilesTab :course-id="course.id" />
|
||||||
<span class="w-24 shrink-0">{{ $t(`weekdays.${slot.weekday}`) }}</span>
|
</TabsContent>
|
||||||
<span class="tabular-nums text-muted-foreground">
|
<TabsContent value="tasks">
|
||||||
{{ toHourMinutes(slot.start_time) }}–{{ toHourMinutes(slot.end_time) }}
|
<CourseTasksTab :course-id="course.id" />
|
||||||
</span>
|
</TabsContent>
|
||||||
<span class="ml-auto text-muted-foreground">
|
<TabsContent value="schedule" class="min-w-0">
|
||||||
{{ tagsById.get(slot.tag_id)?.name }}
|
<CourseScheduleTab :course="course" />
|
||||||
</span>
|
</TabsContent>
|
||||||
<Button
|
<TabsContent value="grades">
|
||||||
variant="ghost"
|
<CourseGradesTab :course-id="course.id" />
|
||||||
size="icon-sm"
|
</TabsContent>
|
||||||
class="text-destructive hover:text-destructive"
|
</Tabs>
|
||||||
:title="$t('schedule.delete-slot')"
|
</template>
|
||||||
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
264
frontend/src/views/LectureNoteView.vue
Normal file
264
frontend/src/views/LectureNoteView.vue
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A lecture note: its pdf, or the markdown editor. Saving renders the pdf again and
|
||||||
|
* shows it; when LaTeX fails the editor stays open with the error.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { ArrowLeft, Download, FileWarning, Loader2, Pencil, Save, Trash2, X } from '@lucide/vue'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import MarkdownEditor from '@/components/course/MarkdownEditor.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useCourse } from '@/services/api/courses'
|
||||||
|
import {
|
||||||
|
lectureNotePdfUrl,
|
||||||
|
useLectureNote,
|
||||||
|
useLectureNoteMutations,
|
||||||
|
useCourseFiles,
|
||||||
|
} from '@/services/api/course-files'
|
||||||
|
import type { CourseFile } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ id: number; noteId?: number }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: course } = useCourse(() => props.id)
|
||||||
|
const { data: files } = useCourseFiles(() => props.id)
|
||||||
|
const noteCount = computed(() => files.value?.filter((file) => file.kind === 'note').length)
|
||||||
|
const { data: note, isPending, isError } = useLectureNote(() => props.noteId)
|
||||||
|
const { save, remove } = useLectureNoteMutations(() => props.id)
|
||||||
|
|
||||||
|
const isNew = computed(() => props.noteId === undefined)
|
||||||
|
const editing = ref(isNew.value || route.query.edit === '1')
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const pdfError = ref<string>()
|
||||||
|
|
||||||
|
/** Content last loaded or saved, to warn before losing changes */
|
||||||
|
const saved = ref({ title: '', content: '' })
|
||||||
|
const dirty = computed(
|
||||||
|
() =>
|
||||||
|
editing.value && (title.value !== saved.value.title || content.value !== saved.value.content),
|
||||||
|
)
|
||||||
|
|
||||||
|
function reset(values: { title: string; content: string }) {
|
||||||
|
title.value = values.title
|
||||||
|
content.value = values.content
|
||||||
|
saved.value = { ...values }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New note: numbered like `mdpdf create` does
|
||||||
|
watch(
|
||||||
|
[isNew, noteCount],
|
||||||
|
([isNew, count]) => {
|
||||||
|
if (isNew && !saved.value.title) {
|
||||||
|
reset({
|
||||||
|
title: t('notes.default-title', { n: (count ?? 0) + 1 }),
|
||||||
|
content: t('notes.default-content'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
note,
|
||||||
|
(note) => {
|
||||||
|
if (note && !dirty.value) reset({ title: note.name, content: note.content })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const backTo = computed(() => ({
|
||||||
|
name: 'course',
|
||||||
|
params: { id: props.id },
|
||||||
|
query: { tab: 'files' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.error(t('notes.errors.title'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await save.mutateAsync({
|
||||||
|
id: props.noteId,
|
||||||
|
body: { title: title.value, content: content.value },
|
||||||
|
})
|
||||||
|
saved.value = { title: title.value, content: content.value }
|
||||||
|
pdfError.value = result.pdf_error ?? undefined
|
||||||
|
if (isNew.value) {
|
||||||
|
await router.replace({
|
||||||
|
name: 'course-note',
|
||||||
|
params: { id: props.id, noteId: result.note.id },
|
||||||
|
query: result.pdf_error ? { edit: '1' } : {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.pdf_error) {
|
||||||
|
toast.warning(t('notes.saved-without-pdf'))
|
||||||
|
} else {
|
||||||
|
toast.success(t('notes.saved'))
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('notes.save-error'), {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (note.value) reset({ title: note.value.name, content: note.value.content })
|
||||||
|
pdfError.value = undefined
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (props.noteId === undefined) return
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(props.noteId)
|
||||||
|
toast.success(t('notes.deleted'))
|
||||||
|
editing.value = false
|
||||||
|
await router.push(backTo.value)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsaved changes: ask before leaving the page or closing the tab
|
||||||
|
onBeforeRouteLeave(() => !dirty.value || window.confirm(t('notes.leave-confirm')))
|
||||||
|
useEventListener(window, 'beforeunload', (event: BeforeUnloadEvent) => {
|
||||||
|
if (dirty.value) event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function pdfUrl(note: CourseFile) {
|
||||||
|
return lectureNotePdfUrl(note)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="ghost" as-child>
|
||||||
|
<RouterLink :to="backTo">
|
||||||
|
<ArrowLeft />
|
||||||
|
{{ course?.name ?? $t('course.back') }}
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap gap-2">
|
||||||
|
<template v-if="editing">
|
||||||
|
<Button v-if="!isNew" variant="outline" @click="cancel">
|
||||||
|
<X />
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="save.isPending.value" :title="'Ctrl+S'" @click="submit">
|
||||||
|
<Loader2 v-if="save.isPending.value" class="animate-spin" />
|
||||||
|
<Save v-else />
|
||||||
|
{{ save.isPending.value ? $t('notes.rendering') : $t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="note">
|
||||||
|
<Button v-if="note.has_pdf" variant="outline" as-child>
|
||||||
|
<a :href="pdfUrl(note)" :download="`${note.name}.pdf`">
|
||||||
|
<Download />
|
||||||
|
{{ $t('notes.download') }}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
@click="deleteOpen = true"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{{ $t('common.delete') }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="editing = true">
|
||||||
|
<Pencil />
|
||||||
|
{{ $t('common.edit') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton v-if="!isNew && isPending" class="h-[70svh] w-full rounded-xl" />
|
||||||
|
|
||||||
|
<Card v-else-if="!isNew && (isError || !note)" class="mx-auto max-w-md text-center">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ $t('notes.not-found') }}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Editor, next to the last pdf on large screens -->
|
||||||
|
<div v-else-if="editing" class="grid gap-4 xl:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
v-model="title"
|
||||||
|
class="h-11 text-lg font-medium"
|
||||||
|
:placeholder="$t('notes.title-placeholder')"
|
||||||
|
:aria-label="$t('notes.title-label')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="pdfError"
|
||||||
|
class="rounded-lg border border-destructive/50 bg-destructive/5 p-3 text-sm"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p class="mb-2 flex items-center gap-2 font-medium text-destructive">
|
||||||
|
<FileWarning class="size-4" />
|
||||||
|
{{ $t('notes.pdf-error') }}
|
||||||
|
</p>
|
||||||
|
<pre class="max-h-48 overflow-auto text-xs whitespace-pre-wrap">{{ pdfError }}</pre>
|
||||||
|
</div>
|
||||||
|
<MarkdownEditor v-model="content" @save="submit" />
|
||||||
|
</div>
|
||||||
|
<div class="hidden xl:block">
|
||||||
|
<iframe
|
||||||
|
v-if="note?.has_pdf"
|
||||||
|
:src="pdfUrl(note)"
|
||||||
|
:title="note.name"
|
||||||
|
class="h-full min-h-[70svh] w-full rounded-lg border bg-muted"
|
||||||
|
/>
|
||||||
|
<Card v-else class="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||||
|
<CardDescription>{{ $t('notes.preview-hint') }}</CardDescription>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pdf -->
|
||||||
|
<template v-else-if="note">
|
||||||
|
<iframe
|
||||||
|
v-if="note.has_pdf"
|
||||||
|
:src="pdfUrl(note)"
|
||||||
|
:title="note.name"
|
||||||
|
class="h-[80svh] w-full rounded-lg border bg-muted"
|
||||||
|
/>
|
||||||
|
<Card v-else class="mx-auto flex max-w-md flex-col items-center gap-3 p-6 text-center">
|
||||||
|
<FileWarning class="size-8 text-destructive" />
|
||||||
|
<CardTitle class="text-base">{{ $t('notes.no-pdf') }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t('notes.no-pdf-hint') }}</CardDescription>
|
||||||
|
<Button @click="editing = true">
|
||||||
|
<Pencil />
|
||||||
|
{{ $t('common.edit') }}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('files.delete-title', { name: note?.name })"
|
||||||
|
:description="$t('files.delete-description')"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Typical week (no dates): monday to friday, 8:00 to 19:00. Slots that overlap are
|
* Timetable of every course. A slot is added by selecting a time range, and clicking a
|
||||||
* drawn side by side. A slot is added by selecting a time range with the pointer, and
|
* slot opens the page of its course.
|
||||||
* clicking a slot opens the page of its course.
|
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
@ -10,147 +9,31 @@ import { toast } from 'vue-sonner'
|
||||||
import { CalendarClock } from '@lucide/vue'
|
import { CalendarClock } from '@lucide/vue'
|
||||||
|
|
||||||
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
|
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
|
||||||
|
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
import { useCourses } from '@/services/api/courses'
|
import { useCourses } from '@/services/api/courses'
|
||||||
import { useSlotTags } from '@/services/api/slot-tags'
|
import { useTags } from '@/services/api/tags'
|
||||||
import {
|
import type { SlotSelection } from '@/utils/timetable'
|
||||||
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 { t } = useI18n()
|
||||||
const { data: courses, isPending, isError, refetch } = useCourses()
|
const { data: courses, isPending, isError, refetch } = useCourses()
|
||||||
const { data: tags, tagsById } = useSlotTags()
|
const { data: tags } = useTags('slot')
|
||||||
|
|
||||||
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(() =>
|
const slotCount = computed(() =>
|
||||||
(courses.value ?? []).reduce((count, course) => count + course.slots.length, 0),
|
(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 slotFormOpen = ref(false)
|
||||||
const slotSelection = ref<SlotSelection>()
|
const slotSelection = ref<SlotSelection>()
|
||||||
|
|
||||||
function endSelection() {
|
function onSelect(selection: SlotSelection) {
|
||||||
const range = selectedRange.value
|
|
||||||
const weekday = selection.value?.weekday
|
|
||||||
selection.value = undefined
|
|
||||||
if (!range || weekday === undefined) return
|
|
||||||
|
|
||||||
if (!courses.value?.length) {
|
if (!courses.value?.length) {
|
||||||
toast.info(t('schedule.no-course'))
|
toast.info(t('schedule.no-course'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slotSelection.value = { weekday, start: fromMinutes(range.start), end: fromMinutes(range.end) }
|
slotSelection.value = selection
|
||||||
slotFormOpen.value = true
|
slotFormOpen.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -192,92 +75,7 @@ function endSelection() {
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-xl border bg-card">
|
<WeekTimetable :courses="courses ?? []" selectable link-to-course @select="onSelect" />
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" />
|
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" />
|
||||||
|
|
|
||||||
168
src/api/course_files.rs
Normal file
168
src/api/course_files.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
//! Files of a course. For now only the lecture notes: markdown saved in the storage and
|
||||||
|
//! rendered to pdf on every save.
|
||||||
|
|
||||||
|
use aide::{
|
||||||
|
axum::{
|
||||||
|
ApiRouter,
|
||||||
|
routing::{get_with, post_with},
|
||||||
|
},
|
||||||
|
transform::TransformOperation,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::Path,
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
helpers::{desc, error_response},
|
||||||
|
models::{CoursePath, LectureNoteBody, LectureNotePath},
|
||||||
|
},
|
||||||
|
core::{
|
||||||
|
controller::AppController,
|
||||||
|
models::course_file::{CourseFile, LectureNoteContent, SavedLectureNote},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> ApiRouter {
|
||||||
|
ApiRouter::new()
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/files",
|
||||||
|
get_with(get_course_files, get_course_files_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/notes",
|
||||||
|
post_with(create_lecture_note, create_lecture_note_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/notes/{id}",
|
||||||
|
get_with(get_lecture_note, get_lecture_note_docs)
|
||||||
|
.put_with(update_lecture_note, update_lecture_note_docs)
|
||||||
|
.delete_with(delete_lecture_note, delete_lecture_note_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/notes/{id}/pdf",
|
||||||
|
get_with(get_lecture_note_pdf, get_lecture_note_pdf_docs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_course_files(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
) -> Result<Json<Vec<CourseFile>>, (StatusCode, String)> {
|
||||||
|
match controller.get_course_files(path.id).await {
|
||||||
|
Ok(files) => Ok(Json(files)),
|
||||||
|
Err(err) => error_response("get_course_files", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_course_files_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Get the files of a course (lecture notes...), without their content")
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_lecture_note(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
Json(body): Json<LectureNoteBody>,
|
||||||
|
) -> Result<(StatusCode, Json<SavedLectureNote>), (StatusCode, String)> {
|
||||||
|
match controller
|
||||||
|
.create_lecture_note(path.id, body.title, body.content)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(saved) => Ok((StatusCode::CREATED, Json(saved))),
|
||||||
|
Err(err) => error_response("create_lecture_note", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Create a lecture note, tagged `notes`, and render it to pdf")
|
||||||
|
.description("The note is created even when the rendering fails: see `pdf_error`.")
|
||||||
|
.response_with::<201, Json<SavedLectureNote>, _>(desc("The created note"))
|
||||||
|
.response_with::<400, String, _>(desc("The note is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lecture_note(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<LectureNotePath>,
|
||||||
|
) -> Result<Json<LectureNoteContent>, (StatusCode, String)> {
|
||||||
|
match controller.get_lecture_note(path.id).await {
|
||||||
|
Ok(note) => Ok(Json(note)),
|
||||||
|
Err(err) => error_response("get_lecture_note", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Get a lecture note with its markdown content")
|
||||||
|
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_lecture_note(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<LectureNotePath>,
|
||||||
|
Json(body): Json<LectureNoteBody>,
|
||||||
|
) -> Result<Json<SavedLectureNote>, (StatusCode, String)> {
|
||||||
|
match controller
|
||||||
|
.update_lecture_note(path.id, body.title, body.content)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(saved) => Ok(Json(saved)),
|
||||||
|
Err(err) => error_response("update_lecture_note", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Replace a lecture note and render it to pdf again")
|
||||||
|
.description("The note is saved even when the rendering fails: see `pdf_error`.")
|
||||||
|
.response_with::<400, String, _>(desc("The note is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_lecture_note(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<LectureNotePath>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
match controller.delete_lecture_note(path.id).await {
|
||||||
|
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||||
|
Err(err) => error_response("delete_lecture_note", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Delete a lecture note and its files")
|
||||||
|
.response_with::<204, (), _>(desc("The note was deleted"))
|
||||||
|
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lecture_note_pdf(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<LectureNotePath>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
match controller.get_lecture_note_pdf(path.id).await {
|
||||||
|
Ok(pdf) => Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/pdf"),
|
||||||
|
(header::CACHE_CONTROL, "no-cache"),
|
||||||
|
],
|
||||||
|
pdf,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
Err(err) => error_response("get_lecture_note_pdf", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lecture_note_pdf_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Course files")
|
||||||
|
.summary("Get the pdf of a lecture note")
|
||||||
|
.response_with::<404, String, _>(desc(
|
||||||
|
"The note does not exist, or its last rendering failed",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use aide::{
|
use aide::{
|
||||||
axum::{
|
axum::{
|
||||||
ApiRouter,
|
ApiRouter,
|
||||||
routing::{get_with, put_with},
|
routing::{delete_with, get_with, post_with},
|
||||||
},
|
},
|
||||||
transform::TransformOperation,
|
transform::TransformOperation,
|
||||||
};
|
};
|
||||||
|
|
@ -9,32 +9,38 @@ use axum::{Json, extract::Path, http::StatusCode};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
helpers::{desc, unexpected_error},
|
helpers::{desc, error_response},
|
||||||
models::{CourseBody, CoursePath},
|
models::{CourseBody, CoursePath, SlotPath, TimeSlotBody},
|
||||||
},
|
|
||||||
core::{
|
|
||||||
controller::{AppController, ControllerError},
|
|
||||||
models::course::Course,
|
|
||||||
},
|
},
|
||||||
|
core::{controller::AppController, models::course::Course},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> ApiRouter {
|
pub fn routes() -> ApiRouter {
|
||||||
ApiRouter::new()
|
ApiRouter::new()
|
||||||
.api_route(
|
.api_route(
|
||||||
"/",
|
"/api/courses",
|
||||||
get_with(get_courses, get_courses_docs).post_with(create_course, create_course_docs),
|
get_with(get_courses, get_courses_docs).post_with(create_course, create_course_docs),
|
||||||
)
|
)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/{id}",
|
"/api/courses/{id}",
|
||||||
put_with(update_course, update_course_docs)
|
get_with(get_course, get_course_docs)
|
||||||
|
.put_with(update_course, update_course_docs)
|
||||||
.delete_with(delete_course, delete_course_docs),
|
.delete_with(delete_course, delete_course_docs),
|
||||||
)
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/slots",
|
||||||
|
post_with(add_slot, add_slot_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/slots/{slot_id}",
|
||||||
|
delete_with(delete_slot, delete_slot_docs),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_courses(controller: AppController) -> Result<Json<Vec<Course>>, (StatusCode, String)> {
|
async fn get_courses(controller: AppController) -> Result<Json<Vec<Course>>, (StatusCode, String)> {
|
||||||
match controller.get_courses().await {
|
match controller.get_courses().await {
|
||||||
Ok(courses) => Ok(Json(courses)),
|
Ok(courses) => Ok(Json(courses)),
|
||||||
Err(err) => unexpected_error("get_courses", err),
|
Err(err) => error_response("get_courses", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,20 +49,35 @@ fn get_courses_docs(op: TransformOperation) -> TransformOperation {
|
||||||
.summary("Get every course with its time slots")
|
.summary("Get every course with its time slots")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_course(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
|
match controller.get_course(path.id).await {
|
||||||
|
Ok(course) => Ok(Json(course)),
|
||||||
|
Err(err) => error_response("get_course", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_course_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Courses")
|
||||||
|
.summary("Get a course with its time slots")
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_course(
|
async fn create_course(
|
||||||
controller: AppController,
|
controller: AppController,
|
||||||
Json(body): Json<CourseBody>,
|
Json(body): Json<CourseBody>,
|
||||||
) -> Result<(StatusCode, Json<Course>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<Course>), (StatusCode, String)> {
|
||||||
match controller.create_course(body.into()).await {
|
match controller.create_course(body.into()).await {
|
||||||
Ok(course) => Ok((StatusCode::CREATED, Json(course))),
|
Ok(course) => Ok((StatusCode::CREATED, Json(course))),
|
||||||
Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
Err(err) => error_response("create_course", err),
|
||||||
Err(err) => unexpected_error("create_course", err),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_course_docs(op: TransformOperation) -> TransformOperation {
|
fn create_course_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Courses")
|
op.tag("Courses")
|
||||||
.summary("Create a course with its time slots")
|
.summary("Create a course, without any time slot")
|
||||||
.response_with::<201, Json<Course>, _>(desc("The created course"))
|
.response_with::<201, Json<Course>, _>(desc("The created course"))
|
||||||
.response_with::<400, String, _>(desc("The course is invalid"))
|
.response_with::<400, String, _>(desc("The course is invalid"))
|
||||||
}
|
}
|
||||||
|
|
@ -68,17 +89,13 @@ async fn update_course(
|
||||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
match controller.update_course(path.id, body.into()).await {
|
match controller.update_course(path.id, body.into()).await {
|
||||||
Ok(course) => Ok(Json(course)),
|
Ok(course) => Ok(Json(course)),
|
||||||
Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
Err(err) => error_response("update_course", err),
|
||||||
Err(err) if err.is_not_found() => {
|
|
||||||
Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
|
|
||||||
}
|
|
||||||
Err(err) => unexpected_error("update_course", err),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_course_docs(op: TransformOperation) -> TransformOperation {
|
fn update_course_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Courses")
|
op.tag("Courses")
|
||||||
.summary("Replace a course and all its time slots")
|
.summary("Replace a course, keeping its time slots")
|
||||||
.response_with::<400, String, _>(desc("The course is invalid"))
|
.response_with::<400, String, _>(desc("The course is invalid"))
|
||||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
}
|
}
|
||||||
|
|
@ -89,16 +106,47 @@ async fn delete_course(
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
match controller.delete_course(path.id).await {
|
match controller.delete_course(path.id).await {
|
||||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||||
Err(err) if err.is_not_found() => {
|
Err(err) => error_response("delete_course", err),
|
||||||
Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
|
|
||||||
}
|
|
||||||
Err(err) => unexpected_error("delete_course", err),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_course_docs(op: TransformOperation) -> TransformOperation {
|
fn delete_course_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Courses")
|
op.tag("Courses")
|
||||||
.summary("Delete a course and its time slots")
|
.summary("Delete a course with its slots, grades, tasks, notes and files")
|
||||||
.response_with::<204, (), _>(desc("The course was deleted"))
|
.response_with::<204, (), _>(desc("The course was deleted"))
|
||||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_slot(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
Json(body): Json<TimeSlotBody>,
|
||||||
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
|
match controller.add_slot(path.id, body.into()).await {
|
||||||
|
Ok(course) => Ok(Json(course)),
|
||||||
|
Err(err) => error_response("add_slot", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_slot_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Courses")
|
||||||
|
.summary("Add a weekly time slot to a course, returns the updated course")
|
||||||
|
.response_with::<400, String, _>(desc("The slot is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_slot(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<SlotPath>,
|
||||||
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
|
match controller.delete_slot(path.id, path.slot_id).await {
|
||||||
|
Ok(course) => Ok(Json(course)),
|
||||||
|
Err(err) => error_response("delete_slot", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_slot_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Courses")
|
||||||
|
.summary("Remove a time slot from a course, returns the updated course")
|
||||||
|
.response_with::<404, String, _>(desc("The slot is not in this course"))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
|
||||||
name: "Courses".to_owned(),
|
name: "Courses".to_owned(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
|
.tag(Tag {
|
||||||
|
name: "Grades".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.tag(Tag {
|
||||||
|
name: "Tasks".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.tag(Tag {
|
||||||
|
name: "Course files".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.tag(Tag {
|
.tag(Tag {
|
||||||
name: "misc".to_owned(),
|
name: "misc".to_owned(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
||||||
99
src/api/grades.rs
Normal file
99
src/api/grades.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
//! Grades of a course. Every change returns all the grades of the course with the new
|
||||||
|
//! average, so the page never needs a second request.
|
||||||
|
|
||||||
|
use aide::{
|
||||||
|
axum::{
|
||||||
|
ApiRouter,
|
||||||
|
routing::{get_with, put_with},
|
||||||
|
},
|
||||||
|
transform::TransformOperation,
|
||||||
|
};
|
||||||
|
use axum::{Json, extract::Path, http::StatusCode};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
helpers::{desc, error_response},
|
||||||
|
models::{CoursePath, GradeBody, GradePath},
|
||||||
|
},
|
||||||
|
core::{controller::AppController, models::grade::CourseGrades},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> ApiRouter {
|
||||||
|
ApiRouter::new()
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/grades",
|
||||||
|
get_with(get_course_grades, get_course_grades_docs)
|
||||||
|
.post_with(create_grade, create_grade_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/grades/{id}",
|
||||||
|
put_with(update_grade, update_grade_docs).delete_with(delete_grade, delete_grade_docs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GradesResult = Result<Json<CourseGrades>, (StatusCode, String)>;
|
||||||
|
|
||||||
|
async fn get_course_grades(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
) -> GradesResult {
|
||||||
|
match controller.get_course_grades(path.id).await {
|
||||||
|
Ok(grades) => Ok(Json(grades)),
|
||||||
|
Err(err) => error_response("get_course_grades", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_course_grades_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Grades")
|
||||||
|
.summary("Get the grades of a course and their weighted average out of 6")
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_grade(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
Json(body): Json<GradeBody>,
|
||||||
|
) -> GradesResult {
|
||||||
|
match controller.create_grade(path.id, body.into()).await {
|
||||||
|
Ok(grades) => Ok(Json(grades)),
|
||||||
|
Err(err) => error_response("create_grade", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_grade_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Grades")
|
||||||
|
.summary("Add a grade to a course, returns every grade of the course")
|
||||||
|
.response_with::<400, String, _>(desc("The grade is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_grade(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<GradePath>,
|
||||||
|
Json(body): Json<GradeBody>,
|
||||||
|
) -> GradesResult {
|
||||||
|
match controller.update_grade(path.id, body.into()).await {
|
||||||
|
Ok(grades) => Ok(Json(grades)),
|
||||||
|
Err(err) => error_response("update_grade", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_grade_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Grades")
|
||||||
|
.summary("Replace a grade, returns every grade of its course")
|
||||||
|
.response_with::<400, String, _>(desc("The grade is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The grade does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_grade(controller: AppController, Path(path): Path<GradePath>) -> GradesResult {
|
||||||
|
match controller.delete_grade(path.id).await {
|
||||||
|
Ok(grades) => Ok(Json(grades)),
|
||||||
|
Err(err) => error_response("delete_grade", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_grade_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Grades")
|
||||||
|
.summary("Delete a grade, returns the grades left in its course")
|
||||||
|
.response_with::<404, String, _>(desc("The grade does not exist"))
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use aide::transform::TransformResponse;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::core::controller::ControllerError;
|
use crate::core::{controller::ControllerError, repositories::RepositoryError};
|
||||||
|
|
||||||
/// Last resort branch of a handler `match`: logs the error and answers 500
|
/// Last resort branch of a handler `match`: logs the error and answers 500
|
||||||
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
||||||
|
|
@ -13,6 +13,25 @@ pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (St
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Usual mapping of an error: 400 for the rules of a domain area, 404 for a missing
|
||||||
|
/// entity, 500 for anything else
|
||||||
|
pub fn error_response<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
||||||
|
let invalid = match &err {
|
||||||
|
ControllerError::Course(err) => Some(err.to_string()),
|
||||||
|
ControllerError::Grade(err) => Some(err.to_string()),
|
||||||
|
ControllerError::Task(err) => Some(err.to_string()),
|
||||||
|
ControllerError::LectureNote(err) => Some(err.to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match (invalid, err) {
|
||||||
|
(Some(message), _) => Err((StatusCode::BAD_REQUEST, message)),
|
||||||
|
(None, ControllerError::RepositoryError(err @ RepositoryError::NotFound(_))) => {
|
||||||
|
Err((StatusCode::NOT_FOUND, err.to_string()))
|
||||||
|
}
|
||||||
|
(None, err) => unexpected_error(fn_name, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
|
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
|
||||||
pub fn desc<T>(
|
pub fn desc<T>(
|
||||||
description: &str,
|
description: &str,
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ use tracing::error;
|
||||||
|
|
||||||
use crate::core::controller::AppController;
|
use crate::core::controller::AppController;
|
||||||
|
|
||||||
|
mod course_files;
|
||||||
mod courses;
|
mod courses;
|
||||||
mod docs;
|
mod docs;
|
||||||
|
mod grades;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod models;
|
mod models;
|
||||||
mod slot_tags;
|
mod tags;
|
||||||
|
mod tasks;
|
||||||
|
|
||||||
pub fn get_router(controller: AppController) -> Router {
|
pub fn get_router(controller: AppController) -> Router {
|
||||||
aide::generate::on_error(|err| error!("aide generated error: {err}"));
|
aide::generate::on_error(|err| error!("aide generated error: {err}"));
|
||||||
|
|
@ -38,8 +41,11 @@ pub fn get_router(controller: AppController) -> Router {
|
||||||
|op| op.tag("misc").summary("Get app version"),
|
|op| op.tag("misc").summary("Get app version"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.nest_api_service("/api/courses", courses::routes())
|
.merge(courses::routes())
|
||||||
.nest_api_service("/api/slot-tags", slot_tags::routes())
|
.nest_api_service("/api/tags", tags::routes())
|
||||||
|
.merge(grades::routes())
|
||||||
|
.merge(tasks::routes())
|
||||||
|
.merge(course_files::routes())
|
||||||
.nest_api_service("/api/docs", docs::routes())
|
.nest_api_service("/api/docs", docs::routes())
|
||||||
.finish_api_with(&mut api, docs::api_docs_metadata)
|
.finish_api_with(&mut api, docs::api_docs_metadata)
|
||||||
.layer(Extension(controller))
|
.layer(Extension(controller))
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
//! Request bodies, kept apart from the domain models so the public contract does not
|
//! Request bodies and path parameters, kept apart from the domain models so the public
|
||||||
//! change every time a domain model does.
|
//! contract does not change every time a domain model does.
|
||||||
|
|
||||||
use chrono::NaiveTime;
|
use chrono::{DateTime, NaiveTime, Utc};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::core::models::{
|
use crate::core::models::{
|
||||||
course::{CourseData, CourseId},
|
course::{CourseData, CourseId},
|
||||||
slot_tag::SlotTagId,
|
course_file::CourseFileId,
|
||||||
time_slot::TimeSlotData,
|
grade::{GradeData, GradeId},
|
||||||
|
tag::TagId,
|
||||||
|
task::{TaskData, TaskId},
|
||||||
|
time_slot::{TimeSlotData, TimeSlotId},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize, JsonSchema)]
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
|
@ -16,7 +19,36 @@ pub struct CoursePath {
|
||||||
pub id: CourseId,
|
pub id: CourseId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A course and all its slots. On update, the slots replace the existing ones.
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct SlotPath {
|
||||||
|
/// Id of the course
|
||||||
|
pub id: CourseId,
|
||||||
|
pub slot_id: TimeSlotId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct GradePath {
|
||||||
|
pub id: GradeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct TaskPath {
|
||||||
|
pub id: TaskId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct LectureNotePath {
|
||||||
|
pub id: CourseFileId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Period `[from, to[`, at most 62 days
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct PeriodQuery {
|
||||||
|
pub from: DateTime<Utc>,
|
||||||
|
pub to: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A course, without its slots: they are added and removed one by one
|
||||||
#[derive(Deserialize, JsonSchema)]
|
#[derive(Deserialize, JsonSchema)]
|
||||||
pub struct CourseBody {
|
pub struct CourseBody {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -25,12 +57,11 @@ pub struct CourseBody {
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
/// http(s) url of the main page of the course
|
/// http(s) url of the main page of the course
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
pub slots: Vec<TimeSlotBody>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, JsonSchema)]
|
#[derive(Deserialize, JsonSchema)]
|
||||||
pub struct TimeSlotBody {
|
pub struct TimeSlotBody {
|
||||||
pub tag_id: SlotTagId,
|
pub tag_id: TagId,
|
||||||
/// ISO weekday: 1 = monday ... 5 = friday
|
/// ISO weekday: 1 = monday ... 5 = friday
|
||||||
pub weekday: u8,
|
pub weekday: u8,
|
||||||
/// Between 08:00 and 19:00, before `end_time`
|
/// Between 08:00 and 19:00, before `end_time`
|
||||||
|
|
@ -38,6 +69,49 @@ pub struct TimeSlotBody {
|
||||||
pub end_time: NaiveTime,
|
pub end_time: NaiveTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A grade on any scale, e.g. 20 out of 100
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct GradeBody {
|
||||||
|
pub name: String,
|
||||||
|
/// Between 0 and `max_score`
|
||||||
|
pub score: f64,
|
||||||
|
/// Positive
|
||||||
|
pub max_score: f64,
|
||||||
|
/// Positive, relative to the other grades of the course
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct TaskBody {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// http(s) urls
|
||||||
|
#[serde(default)]
|
||||||
|
pub links: Vec<String>,
|
||||||
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub done: bool,
|
||||||
|
/// At least one for a task, optional for a subtask
|
||||||
|
#[serde(default)]
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct CreateTaskBody {
|
||||||
|
/// Creates a subtask of this task, which must be a task of the same course
|
||||||
|
pub parent_id: Option<TaskId>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub task: TaskBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct LectureNoteBody {
|
||||||
|
/// Subtitle of the pdf, e.g. "Lecture 2 Notes: Memory subsystem"
|
||||||
|
pub title: String,
|
||||||
|
/// Markdown, without front matter: it is generated from the course
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<CourseBody> for CourseData {
|
impl From<CourseBody> for CourseData {
|
||||||
fn from(value: CourseBody) -> Self {
|
fn from(value: CourseBody) -> Self {
|
||||||
CourseData {
|
CourseData {
|
||||||
|
|
@ -45,7 +119,6 @@ impl From<CourseBody> for CourseData {
|
||||||
code: value.code,
|
code: value.code,
|
||||||
teachers: value.teachers,
|
teachers: value.teachers,
|
||||||
main_page_url: value.main_page_url,
|
main_page_url: value.main_page_url,
|
||||||
slots: value.slots.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,3 +133,27 @@ impl From<TimeSlotBody> for TimeSlotData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<GradeBody> for GradeData {
|
||||||
|
fn from(value: GradeBody) -> Self {
|
||||||
|
GradeData {
|
||||||
|
name: value.name,
|
||||||
|
score: value.score,
|
||||||
|
max_score: value.max_score,
|
||||||
|
weight: value.weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TaskBody> for TaskData {
|
||||||
|
fn from(value: TaskBody) -> Self {
|
||||||
|
TaskData {
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
links: value.links,
|
||||||
|
deadline: value.deadline,
|
||||||
|
done: value.done,
|
||||||
|
tag_ids: value.tag_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
use aide::{
|
|
||||||
axum::{ApiRouter, routing::get_with},
|
|
||||||
transform::TransformOperation,
|
|
||||||
};
|
|
||||||
use axum::{Json, http::StatusCode};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::helpers::unexpected_error,
|
|
||||||
core::{controller::AppController, models::slot_tag::SlotTag},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn routes() -> ApiRouter {
|
|
||||||
ApiRouter::new().api_route("/", get_with(get_slot_tags, get_slot_tags_docs))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_slot_tags(
|
|
||||||
controller: AppController,
|
|
||||||
) -> Result<Json<Vec<SlotTag>>, (StatusCode, String)> {
|
|
||||||
match controller.get_slot_tags().await {
|
|
||||||
Ok(tags) => Ok(Json(tags)),
|
|
||||||
Err(err) => unexpected_error("get_slot_tags", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_slot_tags_docs(op: TransformOperation) -> TransformOperation {
|
|
||||||
op.tag("Courses")
|
|
||||||
.summary("Get the kinds of time slots (lecture, exercises, lab...)")
|
|
||||||
}
|
|
||||||
40
src/api/tags.rs
Normal file
40
src/api/tags.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use aide::{
|
||||||
|
axum::{ApiRouter, routing::get_with},
|
||||||
|
transform::TransformOperation,
|
||||||
|
};
|
||||||
|
use axum::{Json, extract::Query, http::StatusCode};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::helpers::error_response,
|
||||||
|
core::{
|
||||||
|
controller::AppController,
|
||||||
|
models::tag::{Tag, TagScope},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> ApiRouter {
|
||||||
|
ApiRouter::new().api_route("/", get_with(get_tags, get_tags_docs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
struct TagsQuery {
|
||||||
|
/// Only the tags that can label this
|
||||||
|
scope: Option<TagScope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tags(
|
||||||
|
controller: AppController,
|
||||||
|
Query(query): Query<TagsQuery>,
|
||||||
|
) -> Result<Json<Vec<Tag>>, (StatusCode, String)> {
|
||||||
|
match controller.get_tags(query.scope).await {
|
||||||
|
Ok(tags) => Ok(Json(tags)),
|
||||||
|
Err(err) => error_response("get_tags", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_tags_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Courses")
|
||||||
|
.summary("Get the tags of the time slots, tasks and files (lecture, exam, slides...)")
|
||||||
|
}
|
||||||
126
src/api/tasks.rs
Normal file
126
src/api/tasks.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
//! Tasks of a course. Every change returns all the tasks of the course, nested and
|
||||||
|
//! sorted, with the deadlines: the page never needs a second request.
|
||||||
|
|
||||||
|
use aide::{
|
||||||
|
axum::{
|
||||||
|
ApiRouter,
|
||||||
|
routing::{get_with, put_with},
|
||||||
|
},
|
||||||
|
transform::TransformOperation,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
helpers::{desc, error_response},
|
||||||
|
models::{CoursePath, CreateTaskBody, PeriodQuery, TaskBody, TaskPath},
|
||||||
|
},
|
||||||
|
core::{
|
||||||
|
controller::AppController,
|
||||||
|
models::task::{CalendarTask, CourseTasks},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> ApiRouter {
|
||||||
|
ApiRouter::new()
|
||||||
|
.api_route(
|
||||||
|
"/api/tasks",
|
||||||
|
get_with(get_calendar_tasks, get_calendar_tasks_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/courses/{id}/tasks",
|
||||||
|
get_with(get_course_tasks, get_course_tasks_docs)
|
||||||
|
.post_with(create_task, create_task_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/tasks/{id}",
|
||||||
|
put_with(update_task, update_task_docs).delete_with(delete_task, delete_task_docs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_calendar_tasks(
|
||||||
|
controller: AppController,
|
||||||
|
Query(period): Query<PeriodQuery>,
|
||||||
|
) -> Result<Json<Vec<CalendarTask>>, (StatusCode, String)> {
|
||||||
|
match controller.get_calendar_tasks(period.from, period.to).await {
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("get_calendar_tasks", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_calendar_tasks_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Get the tasks and subtasks of every course with a deadline in a period")
|
||||||
|
.response_with::<400, String, _>(desc("The period is invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasksResult = Result<Json<CourseTasks>, (StatusCode, String)>;
|
||||||
|
|
||||||
|
async fn get_course_tasks(controller: AppController, Path(path): Path<CoursePath>) -> TasksResult {
|
||||||
|
match controller.get_course_tasks(path.id).await {
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("get_course_tasks", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_course_tasks_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Get the tasks of a course with their subtasks")
|
||||||
|
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_task(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<CoursePath>,
|
||||||
|
Json(body): Json<CreateTaskBody>,
|
||||||
|
) -> TasksResult {
|
||||||
|
match controller
|
||||||
|
.create_task(path.id, body.parent_id, body.task.into())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("create_task", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_task_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Add a task (or a subtask) to a course, returns every task of the course")
|
||||||
|
.response_with::<400, String, _>(desc("The task is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The course or the parent task does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_task(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<TaskPath>,
|
||||||
|
Json(body): Json<TaskBody>,
|
||||||
|
) -> TasksResult {
|
||||||
|
match controller.update_task(path.id, body.into()).await {
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("update_task", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_task_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Replace a task, returns every task of its course")
|
||||||
|
.response_with::<400, String, _>(desc("The task is invalid"))
|
||||||
|
.response_with::<404, String, _>(desc("The task does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_task(controller: AppController, Path(path): Path<TaskPath>) -> TasksResult {
|
||||||
|
match controller.delete_task(path.id).await {
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("delete_task", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_task_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Delete a task and its subtasks, returns the tasks left in its course")
|
||||||
|
.response_with::<404, String, _>(desc("The task does not exist"))
|
||||||
|
}
|
||||||
229
src/core/controller/course_files.rs
Normal file
229
src/core/controller/course_files.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//! Files of a course. For now only the lecture notes: markdown written in the app,
|
||||||
|
//! rendered to pdf with the course template, and tagged `notes`.
|
||||||
|
//!
|
||||||
|
//! Both files of a note live in the storage, next to the other files of the course:
|
||||||
|
//! `courses/{course}/notes/{note}.md` and `.pdf`. The `.md` holds the front matter
|
||||||
|
//! expected by the template, so it can also be rendered by hand with `mdpdf`. The
|
||||||
|
//! editor only sees what comes after it.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
controller::{ControllerError, courses::course_dir},
|
||||||
|
models::{
|
||||||
|
course::{Course, CourseId},
|
||||||
|
course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
|
||||||
|
tag::NOTES_TAG,
|
||||||
|
},
|
||||||
|
repositories::{RepositoryError, pdf_renderer::RenderError},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Values of the front matter that do not depend on the note
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct NotesSettings {
|
||||||
|
pub student_name: Option<String>,
|
||||||
|
pub student_id: Option<String>,
|
||||||
|
/// Absolute path of the logo
|
||||||
|
pub logo: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_path(note: &CourseFile) -> String {
|
||||||
|
format!("{}/notes/{}.md", course_dir(note.course_id), note.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_path(note: &CourseFile) -> String {
|
||||||
|
format!("{}/notes/{}.pdf", course_dir(note.course_id), note.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::AppController {
|
||||||
|
pub async fn get_course_files(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
) -> Result<Vec<CourseFile>, ControllerError> {
|
||||||
|
// 404 rather than an empty list for a course that does not exist
|
||||||
|
self.db.get_course(course_id).await?;
|
||||||
|
self.db
|
||||||
|
.get_course_files(course_id)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NotFound` if the file does not exist or is not a note
|
||||||
|
async fn get_note(&self, id: CourseFileId) -> Result<CourseFile, ControllerError> {
|
||||||
|
let file = self.db.get_course_file(id).await?;
|
||||||
|
if file.kind != FileKind::Note {
|
||||||
|
return Err(RepositoryError::NotFound(format!("lecture note {id}")).into());
|
||||||
|
}
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lecture_note(
|
||||||
|
&self,
|
||||||
|
id: CourseFileId,
|
||||||
|
) -> Result<LectureNoteContent, ControllerError> {
|
||||||
|
let note = self.get_note(id).await?;
|
||||||
|
let content = match self.storage.read_file(&markdown_path(¬e)).await {
|
||||||
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
|
// Lost file: the note can still be rewritten
|
||||||
|
Err(RepositoryError::NotFound(_)) => String::new(),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
Ok(LectureNoteContent {
|
||||||
|
file: note,
|
||||||
|
content: strip_front_matter(&content).to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NotFound` if the note does not exist or its last rendering failed
|
||||||
|
pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result<Vec<u8>, ControllerError> {
|
||||||
|
let note = self.get_note(id).await?;
|
||||||
|
self.storage
|
||||||
|
.read_file(&pdf_path(¬e))
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_lecture_note(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<SavedLectureNote, ControllerError> {
|
||||||
|
let title = validate_title(title)?;
|
||||||
|
let course = self.db.get_course(course_id).await?;
|
||||||
|
let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
|
||||||
|
let note = self
|
||||||
|
.db
|
||||||
|
.create_lecture_note(course_id, title.clone(), &[notes_tag])
|
||||||
|
.await?;
|
||||||
|
self.save_lecture_note(&course, note, title, content).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_lecture_note(
|
||||||
|
&self,
|
||||||
|
id: CourseFileId,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<SavedLectureNote, ControllerError> {
|
||||||
|
let title = validate_title(title)?;
|
||||||
|
let note = self.get_note(id).await?;
|
||||||
|
let course = self.db.get_course(note.course_id).await?;
|
||||||
|
self.save_lecture_note(&course, note, title, content).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
|
||||||
|
let note = self.get_note(id).await?;
|
||||||
|
self.db.delete_course_file(id).await?;
|
||||||
|
self.storage.delete_file(&markdown_path(¬e)).await?;
|
||||||
|
self.storage.delete_file(&pdf_path(¬e)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the markdown, then renders it. The markdown is kept even when the
|
||||||
|
/// rendering fails, and the error is returned to be shown in the editor.
|
||||||
|
async fn save_lecture_note(
|
||||||
|
&self,
|
||||||
|
course: &Course,
|
||||||
|
note: CourseFile,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<SavedLectureNote, ControllerError> {
|
||||||
|
let markdown = format!("{}{}", self.front_matter(course, &title), content);
|
||||||
|
self.storage
|
||||||
|
.write_file(&markdown_path(¬e), markdown.as_bytes())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pdf_error = match self.renderer.render(&markdown).await {
|
||||||
|
Ok(pdf) => {
|
||||||
|
self.storage.write_file(&pdf_path(¬e), &pdf).await?;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// No stale pdf that would not match the markdown anymore
|
||||||
|
self.storage.delete_file(&pdf_path(¬e)).await?;
|
||||||
|
Some(match err {
|
||||||
|
RenderError::Failed(log) => log,
|
||||||
|
RenderError::Internal(message) => {
|
||||||
|
error!("Unable to render lecture note {}: {message}", note.id);
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = self
|
||||||
|
.db
|
||||||
|
.update_lecture_note(note.id, title, pdf_error.is_none())
|
||||||
|
.await?;
|
||||||
|
Ok(SavedLectureNote { note, pdf_error })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Front matter read by `template.tex`, e.g. `course: "CS-477 -- Advanced OS"`
|
||||||
|
fn front_matter(&self, course: &Course, title: &str) -> String {
|
||||||
|
let course_title = match &course.code {
|
||||||
|
Some(code) => format!("{code} -- {}", course.name),
|
||||||
|
None => course.name.clone(),
|
||||||
|
};
|
||||||
|
let fields = [
|
||||||
|
("student-name", self.notes.student_name.as_deref()),
|
||||||
|
("student-id", self.notes.student_id.as_deref()),
|
||||||
|
("logo", self.notes.logo.as_deref()),
|
||||||
|
("course", Some(course_title.as_str())),
|
||||||
|
("subtitle", Some(title)),
|
||||||
|
];
|
||||||
|
let mut front_matter = String::from("---\n");
|
||||||
|
for (key, value) in fields {
|
||||||
|
if let Some(value) = value {
|
||||||
|
front_matter.push_str(&format!("{key}: {}\n", yaml_string(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
front_matter.push_str("---\n\n");
|
||||||
|
front_matter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A json string is a valid double quoted yaml scalar: quotes and backslashes escaped
|
||||||
|
fn yaml_string(value: &str) -> String {
|
||||||
|
serde_json::to_string(value).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of a markdown file without its front matter
|
||||||
|
fn strip_front_matter(markdown: &str) -> &str {
|
||||||
|
markdown
|
||||||
|
.strip_prefix("---\n")
|
||||||
|
.and_then(|rest| rest.split_once("\n---\n"))
|
||||||
|
.map(|(_, body)| body.trim_start_matches('\n'))
|
||||||
|
.unwrap_or(markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_title(title: String) -> Result<String, ControllerError> {
|
||||||
|
let title = title.trim().to_owned();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(LectureNotesControllerError::EmptyTitle.into());
|
||||||
|
}
|
||||||
|
Ok(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum LectureNotesControllerError {
|
||||||
|
#[error("The title of the note is empty")]
|
||||||
|
EmptyTitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_front_matter() {
|
||||||
|
let markdown = "---\ncourse: \"X\"\nsubtitle: \"Y\"\n---\n\n# Intro\n---\nText";
|
||||||
|
assert_eq!(strip_front_matter(markdown), "# Intro\n---\nText");
|
||||||
|
assert_eq!(strip_front_matter("# No front matter"), "# No front matter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_yaml() {
|
||||||
|
assert_eq!(yaml_string(r#"Notes: "memory""#), r#""Notes: \"memory\"""#);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,11 @@ use chrono::NaiveTime;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
controller::ControllerError,
|
controller::{ControllerError, is_http_url, non_blank},
|
||||||
models::{
|
models::{
|
||||||
course::{Course, CourseData, CourseId},
|
course::{Course, CourseData, CourseId},
|
||||||
slot_tag::SlotTagId,
|
tag::{TagId, TagScope},
|
||||||
|
time_slot::{TimeSlotData, TimeSlotId},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,13 +19,22 @@ const LAST_WEEKDAY: u8 = 5;
|
||||||
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
|
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
|
||||||
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
|
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
|
||||||
|
|
||||||
|
/// Directory of the files of a course in the storage
|
||||||
|
pub(crate) fn course_dir(id: CourseId) -> String {
|
||||||
|
format!("courses/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
impl super::AppController {
|
impl super::AppController {
|
||||||
pub async fn get_courses(&self) -> Result<Vec<Course>, ControllerError> {
|
pub async fn get_courses(&self) -> Result<Vec<Course>, ControllerError> {
|
||||||
self.db.get_courses().await.map_err(Into::into)
|
self.db.get_courses().await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_course(&self, id: CourseId) -> Result<Course, ControllerError> {
|
||||||
|
self.db.get_course(id).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_course(&self, course: CourseData) -> Result<Course, ControllerError> {
|
pub async fn create_course(&self, course: CourseData) -> Result<Course, ControllerError> {
|
||||||
let course = self.validate_course(course).await?;
|
let course = validate_course(course)?;
|
||||||
self.db.create_course(course).await.map_err(Into::into)
|
self.db.create_course(course).await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,24 +43,55 @@ impl super::AppController {
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
) -> Result<Course, ControllerError> {
|
) -> Result<Course, ControllerError> {
|
||||||
let course = self.validate_course(course).await?;
|
let course = validate_course(course)?;
|
||||||
self.db.update_course(id, course).await.map_err(Into::into)
|
self.db.update_course(id, course).await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the course with everything attached to it, its files included
|
||||||
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
|
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
|
||||||
self.db.delete_course(id).await.map_err(Into::into)
|
self.db.delete_course(id).await?;
|
||||||
|
self.storage.delete_dir(&course_dir(id)).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trims the texts, turns blank optional fields into `None`, and checks the rules
|
pub async fn add_slot(
|
||||||
/// of a course and its slots
|
&self,
|
||||||
async fn validate_course(&self, course: CourseData) -> Result<CourseData, ControllerError> {
|
course_id: CourseId,
|
||||||
|
slot: TimeSlotData,
|
||||||
|
) -> Result<Course, ControllerError> {
|
||||||
|
if !(FIRST_WEEKDAY..=LAST_WEEKDAY).contains(&slot.weekday) {
|
||||||
|
return Err(CoursesControllerError::InvalidWeekday(slot.weekday).into());
|
||||||
|
}
|
||||||
|
if slot.start_time >= slot.end_time
|
||||||
|
|| slot.start_time < DAY_START
|
||||||
|
|| slot.end_time > DAY_END
|
||||||
|
{
|
||||||
|
return Err(CoursesControllerError::InvalidSlotTime.into());
|
||||||
|
}
|
||||||
|
self.check_tags(&[slot.tag_id], TagScope::Slot).await?;
|
||||||
|
self.db.add_slot(course_id, slot).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_slot(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
slot_id: TimeSlotId,
|
||||||
|
) -> Result<Course, ControllerError> {
|
||||||
|
self.db
|
||||||
|
.delete_slot(course_id, slot_id)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims the texts, turns blank optional fields into `None`, and checks the rules
|
||||||
|
/// of a course
|
||||||
|
fn validate_course(course: CourseData) -> Result<CourseData, ControllerError> {
|
||||||
let name = course.name.trim().to_owned();
|
let name = course.name.trim().to_owned();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(CoursesControllerError::EmptyName.into());
|
return Err(CoursesControllerError::EmptyName.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = non_blank(course.code);
|
|
||||||
|
|
||||||
let teachers: Vec<String> = course
|
let teachers: Vec<String> = course
|
||||||
.teachers
|
.teachers
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -62,42 +103,19 @@ impl super::AppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
let main_page_url = non_blank(course.main_page_url);
|
let main_page_url = non_blank(course.main_page_url);
|
||||||
if let Some(url) = &main_page_url
|
if main_page_url
|
||||||
&& !(url.starts_with("http://") || url.starts_with("https://"))
|
.as_deref()
|
||||||
|
.is_some_and(|url| !is_http_url(url))
|
||||||
{
|
{
|
||||||
return Err(CoursesControllerError::InvalidMainPageUrl.into());
|
return Err(CoursesControllerError::InvalidMainPageUrl.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tags = self.db.get_slot_tags().await?;
|
|
||||||
for slot in &course.slots {
|
|
||||||
if !(FIRST_WEEKDAY..=LAST_WEEKDAY).contains(&slot.weekday) {
|
|
||||||
return Err(CoursesControllerError::InvalidWeekday(slot.weekday).into());
|
|
||||||
}
|
|
||||||
if slot.start_time >= slot.end_time
|
|
||||||
|| slot.start_time < DAY_START
|
|
||||||
|| slot.end_time > DAY_END
|
|
||||||
{
|
|
||||||
return Err(CoursesControllerError::InvalidSlotTime.into());
|
|
||||||
}
|
|
||||||
if !tags.iter().any(|tag| tag.id == slot.tag_id) {
|
|
||||||
return Err(CoursesControllerError::UnknownSlotTag(slot.tag_id).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CourseData {
|
Ok(CourseData {
|
||||||
name,
|
name,
|
||||||
code,
|
code: non_blank(course.code),
|
||||||
teachers,
|
teachers,
|
||||||
main_page_url,
|
main_page_url,
|
||||||
slots: course.slots,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn non_blank(value: Option<String>) -> Option<String> {
|
|
||||||
value
|
|
||||||
.map(|value| value.trim().to_owned())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors specific to this domain area, turned into status codes by the api
|
/// Errors specific to this domain area, turned into status codes by the api
|
||||||
|
|
@ -113,6 +131,6 @@ pub enum CoursesControllerError {
|
||||||
InvalidWeekday(u8),
|
InvalidWeekday(u8),
|
||||||
#[error("A slot must start before it ends, between 8:00 and 19:00")]
|
#[error("A slot must start before it ends, between 8:00 and 19:00")]
|
||||||
InvalidSlotTime,
|
InvalidSlotTime,
|
||||||
#[error("Unknown slot tag {0}")]
|
#[error("Unknown tag {0}")]
|
||||||
UnknownSlotTag(SlotTagId),
|
UnknownTag(TagId),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/core/controller/grades.rs
Normal file
142
src/core/controller/grades.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
//! Grades of a course and their weighted average, in the swiss system (1 to 6).
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
controller::ControllerError,
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
grade::{CourseGrades, Grade, GradeData, GradeId, to_swiss},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl super::AppController {
|
||||||
|
pub async fn get_course_grades(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
) -> Result<CourseGrades, ControllerError> {
|
||||||
|
// 404 rather than an empty list for a course that does not exist
|
||||||
|
self.db.get_course(course_id).await?;
|
||||||
|
self.course_grades(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns every grade of the course, the new one included
|
||||||
|
pub async fn create_grade(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
grade: GradeData,
|
||||||
|
) -> Result<CourseGrades, ControllerError> {
|
||||||
|
let grade = validate_grade(grade)?;
|
||||||
|
self.db.get_course(course_id).await?;
|
||||||
|
self.db.create_grade(course_id, grade).await?;
|
||||||
|
self.course_grades(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns every grade of the course of the grade
|
||||||
|
pub async fn update_grade(
|
||||||
|
&self,
|
||||||
|
id: GradeId,
|
||||||
|
grade: GradeData,
|
||||||
|
) -> Result<CourseGrades, ControllerError> {
|
||||||
|
let grade = validate_grade(grade)?;
|
||||||
|
let course_id = self.db.get_grade_course(id).await?;
|
||||||
|
self.db.update_grade(id, grade).await?;
|
||||||
|
self.course_grades(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the grades left in the course of the grade
|
||||||
|
pub async fn delete_grade(&self, id: GradeId) -> Result<CourseGrades, ControllerError> {
|
||||||
|
let course_id = self.db.get_grade_course(id).await?;
|
||||||
|
self.db.delete_grade(id).await?;
|
||||||
|
self.course_grades(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn course_grades(&self, course_id: CourseId) -> Result<CourseGrades, ControllerError> {
|
||||||
|
let grades = self.db.get_grades(course_id).await?;
|
||||||
|
Ok(summarize(grades))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weighted average of the grades: each one counts for `weight / total_weight`, so the
|
||||||
|
/// weights do not need to add up to 100
|
||||||
|
fn summarize(grades: Vec<Grade>) -> CourseGrades {
|
||||||
|
let total_weight: f64 = grades.iter().map(|grade| grade.weight).sum();
|
||||||
|
let average = (total_weight > 0.0).then(|| {
|
||||||
|
let weighted: f64 = grades
|
||||||
|
.iter()
|
||||||
|
.map(|grade| grade.weight * grade.score / grade.max_score)
|
||||||
|
.sum();
|
||||||
|
to_swiss(weighted / total_weight)
|
||||||
|
});
|
||||||
|
CourseGrades {
|
||||||
|
grades,
|
||||||
|
average,
|
||||||
|
total_weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grade(grade: GradeData) -> Result<GradeData, ControllerError> {
|
||||||
|
let name = grade.name.trim().to_owned();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(GradesControllerError::EmptyName.into());
|
||||||
|
}
|
||||||
|
let numbers = [grade.score, grade.max_score, grade.weight];
|
||||||
|
if numbers.iter().any(|number| !number.is_finite()) {
|
||||||
|
return Err(GradesControllerError::NotANumber.into());
|
||||||
|
}
|
||||||
|
if grade.max_score <= 0.0 || grade.score < 0.0 || grade.score > grade.max_score {
|
||||||
|
return Err(GradesControllerError::InvalidScore.into());
|
||||||
|
}
|
||||||
|
if grade.weight <= 0.0 {
|
||||||
|
return Err(GradesControllerError::InvalidWeight.into());
|
||||||
|
}
|
||||||
|
Ok(GradeData { name, ..grade })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum GradesControllerError {
|
||||||
|
#[error("The name of the grade is empty")]
|
||||||
|
EmptyName,
|
||||||
|
#[error("Scores and weight must be numbers")]
|
||||||
|
NotANumber,
|
||||||
|
#[error("The score must be between 0 and the maximal score, which must be positive")]
|
||||||
|
InvalidScore,
|
||||||
|
#[error("The weight must be positive")]
|
||||||
|
InvalidWeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn grade(score: f64, max_score: f64, weight: f64) -> Grade {
|
||||||
|
Grade {
|
||||||
|
id: 0,
|
||||||
|
name: String::new(),
|
||||||
|
score,
|
||||||
|
max_score,
|
||||||
|
weight,
|
||||||
|
swiss_grade: to_swiss(score / max_score),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn average_is_weighted_and_scale_independent() {
|
||||||
|
// 5/6 counting for 40, 20/100 for 60: 0.4 * 5/6 + 0.6 * 0.2 = 0.4533...
|
||||||
|
let summary = summarize(vec![grade(5.0, 6.0, 40.0), grade(20.0, 100.0, 60.0)]);
|
||||||
|
let expected = 1.0 + 5.0 * (0.4 * 5.0 / 6.0 + 0.6 * 0.2);
|
||||||
|
assert!((summary.average.unwrap() - expected).abs() < 1e-9);
|
||||||
|
assert_eq!(summary.total_weight, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_grade_no_average() {
|
||||||
|
assert!(summarize(Vec::new()).average.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_marks_is_a_six() {
|
||||||
|
assert_eq!(to_swiss(1.0), 6.0);
|
||||||
|
assert_eq!(to_swiss(0.0), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,27 +10,51 @@ use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
controller::courses::CoursesControllerError,
|
controller::{
|
||||||
repositories::{DatabaseRepository, RepositoryError},
|
course_files::{LectureNotesControllerError, NotesSettings},
|
||||||
|
courses::CoursesControllerError,
|
||||||
|
grades::GradesControllerError,
|
||||||
|
tasks::TasksControllerError,
|
||||||
|
},
|
||||||
|
repositories::{
|
||||||
|
DatabaseRepository, RepositoryError, pdf_renderer::PdfRenderer,
|
||||||
|
storage_repository::StorageRepository,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod course_files;
|
||||||
pub mod courses;
|
pub mod courses;
|
||||||
pub mod slot_tags;
|
pub mod grades;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod tasks;
|
||||||
|
|
||||||
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
|
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppController {
|
pub struct AppController {
|
||||||
db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
|
db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
|
||||||
|
storage: Arc<dyn StorageRepository>,
|
||||||
|
renderer: Arc<dyn PdfRenderer>,
|
||||||
|
notes: Arc<NotesSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppController {
|
impl AppController {
|
||||||
pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
|
pub fn new(
|
||||||
Self { db }
|
db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
|
||||||
|
storage: Arc<dyn StorageRepository>,
|
||||||
|
renderer: Arc<dyn PdfRenderer>,
|
||||||
|
notes: NotesSettings,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
storage,
|
||||||
|
renderer,
|
||||||
|
notes: Arc::new(notes),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every error the api may have to translate into a status code.
|
/// Every error the api may have to translate into a status code.
|
||||||
/// Domain specific errors are nested (`Course`), so each area keeps its own enum.
|
/// Domain specific errors are nested (`Course`...), so each area keeps its own enum.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum ControllerError {
|
pub enum ControllerError {
|
||||||
|
|
@ -41,14 +65,21 @@ pub enum ControllerError {
|
||||||
|
|
||||||
#[error("Course specific error: {0}")]
|
#[error("Course specific error: {0}")]
|
||||||
Course(#[from] CoursesControllerError),
|
Course(#[from] CoursesControllerError),
|
||||||
|
#[error("Grade specific error: {0}")]
|
||||||
|
Grade(#[from] GradesControllerError),
|
||||||
|
#[error("Task specific error: {0}")]
|
||||||
|
Task(#[from] TasksControllerError),
|
||||||
|
#[error("Lecture note specific error: {0}")]
|
||||||
|
LectureNote(#[from] LectureNotesControllerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControllerError {
|
/// Trims a text, `None` when it is blank
|
||||||
/// Handy in the handlers: `Err(err) if err.is_not_found() => 404`
|
pub(crate) fn non_blank(value: Option<String>) -> Option<String> {
|
||||||
pub fn is_not_found(&self) -> bool {
|
value
|
||||||
matches!(
|
.map(|value| value.trim().to_owned())
|
||||||
self,
|
.filter(|value| !value.is_empty())
|
||||||
ControllerError::RepositoryError(RepositoryError::NotFound(_))
|
}
|
||||||
)
|
|
||||||
}
|
pub(crate) fn is_http_url(url: &str) -> bool {
|
||||||
|
url.starts_with("http://") || url.starts_with("https://")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
//! Kinds of time slots (lecture, exercises, lab...).
|
|
||||||
|
|
||||||
use crate::core::{controller::ControllerError, models::slot_tag::SlotTag};
|
|
||||||
|
|
||||||
impl super::AppController {
|
|
||||||
pub async fn get_slot_tags(&self) -> Result<Vec<SlotTag>, ControllerError> {
|
|
||||||
self.db.get_slot_tags().await.map_err(Into::into)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
src/core/controller/tags.rs
Normal file
28
src/core/controller/tags.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//! Tags of the time slots, tasks and course files (lecture, lab, exam, slides...).
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
controller::{ControllerError, courses::CoursesControllerError},
|
||||||
|
models::tag::{Tag, TagId, TagScope},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl super::AppController {
|
||||||
|
pub async fn get_tags(&self, scope: Option<TagScope>) -> Result<Vec<Tag>, ControllerError> {
|
||||||
|
self.db.get_tags(scope).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UnknownTag` if one of the tags does not exist or cannot label `scope`
|
||||||
|
pub(crate) async fn check_tags(
|
||||||
|
&self,
|
||||||
|
tag_ids: &[TagId],
|
||||||
|
scope: TagScope,
|
||||||
|
) -> Result<(), ControllerError> {
|
||||||
|
let tags = self.db.get_tags(Some(scope)).await?;
|
||||||
|
match tag_ids
|
||||||
|
.iter()
|
||||||
|
.find(|id| !tags.iter().any(|tag| tag.id == **id))
|
||||||
|
{
|
||||||
|
Some(id) => Err(CoursesControllerError::UnknownTag(*id).into()),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
287
src/core/controller/tasks.rs
Normal file
287
src/core/controller/tasks.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
//! Tasks of a course (homework, exams, anything to do), with one level of subtasks.
|
||||||
|
|
||||||
|
use std::{cmp::Ordering, collections::HashMap};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
controller::{ControllerError, is_http_url, non_blank},
|
||||||
|
models::{
|
||||||
|
course::Course,
|
||||||
|
course::CourseId,
|
||||||
|
tag::TagScope,
|
||||||
|
task::{CalendarTask, CourseTasks, Task, TaskData, TaskId},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Longest period the calendar can ask for, a month view needs 6 weeks
|
||||||
|
const MAX_CALENDAR_DAYS: i64 = 62;
|
||||||
|
|
||||||
|
impl super::AppController {
|
||||||
|
/// Tasks and subtasks of every course with a deadline in `[from, to[`, the closest
|
||||||
|
/// first
|
||||||
|
pub async fn get_calendar_tasks(
|
||||||
|
&self,
|
||||||
|
from: DateTime<Utc>,
|
||||||
|
to: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<CalendarTask>, ControllerError> {
|
||||||
|
if from >= to || to - from > Duration::days(MAX_CALENDAR_DAYS) {
|
||||||
|
return Err(TasksControllerError::InvalidPeriod(MAX_CALENDAR_DAYS).into());
|
||||||
|
}
|
||||||
|
let tasks = self.db.get_dated_tasks(from, to).await?;
|
||||||
|
let courses = self.db.get_courses().await?;
|
||||||
|
Ok(calendar(tasks, &courses, from, to))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_course_tasks(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
) -> Result<CourseTasks, ControllerError> {
|
||||||
|
// 404 rather than an empty list for a course that does not exist
|
||||||
|
self.db.get_course(course_id).await?;
|
||||||
|
self.course_tasks(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a task to the course, or a subtask to `parent_id`. Returns every task of the
|
||||||
|
/// course.
|
||||||
|
pub async fn create_task(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
parent_id: Option<TaskId>,
|
||||||
|
task: TaskData,
|
||||||
|
) -> Result<CourseTasks, ControllerError> {
|
||||||
|
self.db.get_course(course_id).await?;
|
||||||
|
if let Some(parent_id) = parent_id {
|
||||||
|
let parent = self.db.get_task(parent_id).await?;
|
||||||
|
if parent.course_id != course_id {
|
||||||
|
return Err(TasksControllerError::ParentInOtherCourse.into());
|
||||||
|
}
|
||||||
|
if parent.parent_id.is_some() {
|
||||||
|
return Err(TasksControllerError::NestedSubtask.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let task = self.validate_task(task, parent_id.is_some()).await?;
|
||||||
|
self.db.create_task(course_id, parent_id, task).await?;
|
||||||
|
self.course_tasks(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a task, without moving it. Returns every task of its course.
|
||||||
|
pub async fn update_task(
|
||||||
|
&self,
|
||||||
|
id: TaskId,
|
||||||
|
task: TaskData,
|
||||||
|
) -> Result<CourseTasks, ControllerError> {
|
||||||
|
let existing = self.db.get_task(id).await?;
|
||||||
|
let task = self
|
||||||
|
.validate_task(task, existing.parent_id.is_some())
|
||||||
|
.await?;
|
||||||
|
self.db.update_task(id, task).await?;
|
||||||
|
self.course_tasks(existing.course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a task and its subtasks. Returns the tasks left in its course.
|
||||||
|
pub async fn delete_task(&self, id: TaskId) -> Result<CourseTasks, ControllerError> {
|
||||||
|
let existing = self.db.get_task(id).await?;
|
||||||
|
self.db.delete_task(id).await?;
|
||||||
|
self.course_tasks(existing.course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn course_tasks(&self, course_id: CourseId) -> Result<CourseTasks, ControllerError> {
|
||||||
|
let tasks = self.db.get_tasks(course_id).await?;
|
||||||
|
Ok(organize(tasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims the texts and checks the rules of a task. A task needs at least one tag,
|
||||||
|
/// a subtask may have none.
|
||||||
|
async fn validate_task(
|
||||||
|
&self,
|
||||||
|
task: TaskData,
|
||||||
|
is_subtask: bool,
|
||||||
|
) -> Result<TaskData, ControllerError> {
|
||||||
|
let name = task.name.trim().to_owned();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(TasksControllerError::EmptyName.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let links: Vec<String> = task
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.map(|link| link.trim().to_owned())
|
||||||
|
.filter(|link| !link.is_empty())
|
||||||
|
.collect();
|
||||||
|
if links.iter().any(|link| !is_http_url(link)) {
|
||||||
|
return Err(TasksControllerError::InvalidLink.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tag_ids = task.tag_ids;
|
||||||
|
tag_ids.sort_unstable();
|
||||||
|
tag_ids.dedup();
|
||||||
|
if tag_ids.is_empty() && !is_subtask {
|
||||||
|
return Err(TasksControllerError::NoTag.into());
|
||||||
|
}
|
||||||
|
self.check_tags(&tag_ids, TagScope::Task).await?;
|
||||||
|
|
||||||
|
Ok(TaskData {
|
||||||
|
name,
|
||||||
|
description: non_blank(task.description),
|
||||||
|
links,
|
||||||
|
tag_ids,
|
||||||
|
..task
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not done first, then the closest deadline, the tasks without any last
|
||||||
|
fn task_order(a: &Task, b: &Task) -> Ordering {
|
||||||
|
(a.done, a.deadline.is_none(), a.deadline, a.id).cmp(&(
|
||||||
|
b.done,
|
||||||
|
b.deadline.is_none(),
|
||||||
|
b.deadline,
|
||||||
|
b.id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nests the subtasks in their parent and sorts everything
|
||||||
|
fn organize(flat: Vec<Task>) -> CourseTasks {
|
||||||
|
let (mut tasks, subtasks): (Vec<Task>, Vec<Task>) =
|
||||||
|
flat.into_iter().partition(|task| task.parent_id.is_none());
|
||||||
|
|
||||||
|
let mut subtasks_by_parent: HashMap<TaskId, Vec<Task>> = HashMap::new();
|
||||||
|
for subtask in subtasks {
|
||||||
|
if let Some(parent_id) = subtask.parent_id {
|
||||||
|
subtasks_by_parent
|
||||||
|
.entry(parent_id)
|
||||||
|
.or_default()
|
||||||
|
.push(subtask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for task in &mut tasks {
|
||||||
|
let mut subtasks = subtasks_by_parent.remove(&task.id).unwrap_or_default();
|
||||||
|
subtasks.sort_by(task_order);
|
||||||
|
task.subtasks = subtasks;
|
||||||
|
}
|
||||||
|
tasks.sort_by(task_order);
|
||||||
|
|
||||||
|
CourseTasks { tasks }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tasks with a deadline in the period, named and tagged after their parent for the
|
||||||
|
/// subtasks. `tasks` holds the parents of the subtasks too, whatever their deadline.
|
||||||
|
fn calendar(
|
||||||
|
tasks: Vec<Task>,
|
||||||
|
courses: &[Course],
|
||||||
|
from: DateTime<Utc>,
|
||||||
|
to: DateTime<Utc>,
|
||||||
|
) -> Vec<CalendarTask> {
|
||||||
|
let by_id: HashMap<TaskId, &Task> = tasks.iter().map(|task| (task.id, task)).collect();
|
||||||
|
let courses: HashMap<_, &Course> = courses.iter().map(|course| (course.id, course)).collect();
|
||||||
|
|
||||||
|
let mut result: Vec<CalendarTask> = tasks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|task| {
|
||||||
|
let deadline = task
|
||||||
|
.deadline
|
||||||
|
.filter(|deadline| *deadline >= from && *deadline < to)?;
|
||||||
|
let course = courses.get(&task.course_id)?;
|
||||||
|
let parent = task.parent_id.and_then(|id| by_id.get(&id));
|
||||||
|
Some(CalendarTask {
|
||||||
|
task_id: task.id,
|
||||||
|
course_id: course.id,
|
||||||
|
course_name: course.name.clone(),
|
||||||
|
course_code: course.code.clone(),
|
||||||
|
name: match parent {
|
||||||
|
Some(parent) => format!("{} › {}", parent.name, task.name),
|
||||||
|
None => task.name.clone(),
|
||||||
|
},
|
||||||
|
deadline,
|
||||||
|
done: task.done || parent.is_some_and(|parent| parent.done),
|
||||||
|
tag_ids: match parent {
|
||||||
|
Some(parent) if task.tag_ids.is_empty() => parent.tag_ids.clone(),
|
||||||
|
_ => task.tag_ids.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
result.sort_by_key(|task| (task.deadline, task.task_id));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum TasksControllerError {
|
||||||
|
#[error("The period must end after it starts, and last at most {0} days")]
|
||||||
|
InvalidPeriod(i64),
|
||||||
|
#[error("The name of the task is empty")]
|
||||||
|
EmptyName,
|
||||||
|
#[error("A task needs at least one tag")]
|
||||||
|
NoTag,
|
||||||
|
#[error("Links must be http(s) urls")]
|
||||||
|
InvalidLink,
|
||||||
|
#[error("The parent task belongs to another course")]
|
||||||
|
ParentInOtherCourse,
|
||||||
|
#[error("A subtask cannot have subtasks")]
|
||||||
|
NestedSubtask,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn task(id: TaskId, parent_id: Option<TaskId>, day: Option<u32>, done: bool) -> Task {
|
||||||
|
Task {
|
||||||
|
id,
|
||||||
|
course_id: 1,
|
||||||
|
parent_id,
|
||||||
|
name: format!("task {id}"),
|
||||||
|
description: None,
|
||||||
|
links: Vec::new(),
|
||||||
|
deadline: day.map(|day| Utc.with_ymd_and_hms(2026, 10, day, 12, 0, 0).unwrap()),
|
||||||
|
done,
|
||||||
|
tag_ids: vec![1],
|
||||||
|
subtasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nests_and_sorts() {
|
||||||
|
let result = organize(vec![
|
||||||
|
task(1, None, None, false),
|
||||||
|
task(2, None, Some(20), false),
|
||||||
|
task(3, None, Some(10), true),
|
||||||
|
task(4, Some(2), Some(15), false),
|
||||||
|
task(5, Some(2), Some(5), true),
|
||||||
|
]);
|
||||||
|
let ids: Vec<TaskId> = result.tasks.iter().map(|task| task.id).collect();
|
||||||
|
assert_eq!(ids, [2, 1, 3]);
|
||||||
|
let subtask_ids: Vec<TaskId> = result.tasks[0].subtasks.iter().map(|t| t.id).collect();
|
||||||
|
assert_eq!(subtask_ids, [4, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_names_and_tags_subtasks_after_their_parent() {
|
||||||
|
let course = Course {
|
||||||
|
id: 1,
|
||||||
|
name: "OS".to_owned(),
|
||||||
|
code: None,
|
||||||
|
teachers: Vec::new(),
|
||||||
|
main_page_url: None,
|
||||||
|
slots: Vec::new(),
|
||||||
|
};
|
||||||
|
let from = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
|
||||||
|
let to = Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap();
|
||||||
|
let mut subtask = task(4, Some(2), Some(15), false);
|
||||||
|
subtask.tag_ids.clear();
|
||||||
|
// The parent is out of the period and done: only its subtask is shown, done
|
||||||
|
let mut parent = task(2, None, Some(20), true);
|
||||||
|
parent.deadline = Some(to);
|
||||||
|
parent.tag_ids = vec![7];
|
||||||
|
let result = calendar(vec![parent, subtask], &[course], from, to);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].name, "task 2 › task 4");
|
||||||
|
assert_eq!(result[0].tag_ids, [7]);
|
||||||
|
assert!(result[0].done);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::core::models::time_slot::{TimeSlot, TimeSlotData};
|
use crate::core::models::time_slot::TimeSlot;
|
||||||
|
|
||||||
pub type CourseId = i32;
|
pub type CourseId = i32;
|
||||||
|
|
||||||
|
|
@ -20,12 +20,11 @@ pub struct Course {
|
||||||
pub slots: Vec<TimeSlot>,
|
pub slots: Vec<TimeSlot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content of a course, used to create or replace one
|
/// Content of a course, used to create or replace one. The slots are managed apart.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CourseData {
|
pub struct CourseData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
pub slots: Vec<TimeSlotData>,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
src/core/models/course_file.rs
Normal file
49
src/core/models/course_file.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::core::models::{course::CourseId, tag::TagId};
|
||||||
|
|
||||||
|
pub type CourseFileId = i32;
|
||||||
|
|
||||||
|
/// A file of a course: lecture notes written in the app, or (soon) an uploaded file.
|
||||||
|
/// Only the metadata is in the database, the content is in the storage (the NAS).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct CourseFile {
|
||||||
|
pub id: CourseFileId,
|
||||||
|
pub course_id: CourseId,
|
||||||
|
/// For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf
|
||||||
|
pub name: String,
|
||||||
|
pub kind: FileKind,
|
||||||
|
/// e.g. slides, notes
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
/// For a note: false when the last rendering failed
|
||||||
|
pub has_pdf: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FileKind {
|
||||||
|
/// Markdown written in the app, rendered to pdf with the course template
|
||||||
|
Note,
|
||||||
|
/// Any file uploaded to the storage
|
||||||
|
Upload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A note with its markdown content (without the front matter, added on rendering)
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct LectureNoteContent {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub file: CourseFile,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a save: the markdown is always saved, even when the pdf fails to render
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct SavedLectureNote {
|
||||||
|
pub note: CourseFile,
|
||||||
|
/// End of the pandoc/LaTeX log when the rendering failed
|
||||||
|
pub pdf_error: Option<String>,
|
||||||
|
}
|
||||||
45
src/core/models/grade.rs
Normal file
45
src/core/models/grade.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub type GradeId = i32;
|
||||||
|
|
||||||
|
/// Best grade of the swiss system, grades go from 1 to 6
|
||||||
|
pub const SWISS_MAX: f64 = 6.0;
|
||||||
|
|
||||||
|
/// A grade on any scale: `score` out of `max_score`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Grade {
|
||||||
|
pub id: GradeId,
|
||||||
|
/// e.g. "Midterm"
|
||||||
|
pub name: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub max_score: f64,
|
||||||
|
/// Relative to the other grades of the course
|
||||||
|
pub weight: f64,
|
||||||
|
/// The grade converted to the swiss system (1 to 6)
|
||||||
|
pub swiss_grade: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of a grade, used to create or replace one
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GradeData {
|
||||||
|
pub name: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub max_score: f64,
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every grade of a course, with its weighted average
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct CourseGrades {
|
||||||
|
pub grades: Vec<Grade>,
|
||||||
|
/// Weighted average in the swiss system (1 to 6), `None` without any grade
|
||||||
|
pub average: Option<f64>,
|
||||||
|
/// Sum of the weights, e.g. to check they add up to 100 %
|
||||||
|
pub total_weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `score / max_score` scaled to the swiss system, where 0 is a 1 and full marks a 6
|
||||||
|
pub fn to_swiss(ratio: f64) -> f64 {
|
||||||
|
1.0 + (SWISS_MAX - 1.0) * ratio
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
pub mod course;
|
pub mod course;
|
||||||
|
pub mod course_file;
|
||||||
|
pub mod grade;
|
||||||
pub mod localized_string;
|
pub mod localized_string;
|
||||||
pub mod slot_tag;
|
pub mod tag;
|
||||||
|
pub mod task;
|
||||||
pub mod time_slot;
|
pub mod time_slot;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
use schemars::JsonSchema;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::core::models::localized_string::LocalizedString;
|
|
||||||
|
|
||||||
pub type SlotTagId = i32;
|
|
||||||
|
|
||||||
/// Kind of a time slot: lecture, exercises, lab...
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
|
||||||
pub struct SlotTag {
|
|
||||||
pub id: SlotTagId,
|
|
||||||
pub name: LocalizedString,
|
|
||||||
/// Hex color used to display the slots of this kind, e.g. `#2563eb`
|
|
||||||
pub color: String,
|
|
||||||
}
|
|
||||||
38
src/core/models/tag.rs
Normal file
38
src/core/models/tag.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::core::models::localized_string::LocalizedString;
|
||||||
|
|
||||||
|
pub type TagId = i32;
|
||||||
|
|
||||||
|
/// Stable name of the tag attached to the lecture notes
|
||||||
|
pub const NOTES_TAG: &str = "notes";
|
||||||
|
|
||||||
|
/// Label of time slots, tasks and course files: lecture, lab, exam, slides...
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub id: TagId,
|
||||||
|
pub name: LocalizedString,
|
||||||
|
/// Hex color used to display what is tagged, e.g. `#2563eb`
|
||||||
|
pub color: String,
|
||||||
|
/// What the tag can label
|
||||||
|
pub scopes: Vec<TagScope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TagScope {
|
||||||
|
Slot,
|
||||||
|
Task,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagScope {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TagScope::Slot => "slot",
|
||||||
|
TagScope::Task => "task",
|
||||||
|
TagScope::File => "file",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/core/models/task.rs
Normal file
60
src/core/models/task.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::core::models::{course::CourseId, tag::TagId};
|
||||||
|
|
||||||
|
pub type TaskId = i32;
|
||||||
|
|
||||||
|
/// Homework, exam, or anything to do for a course
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: TaskId,
|
||||||
|
pub course_id: CourseId,
|
||||||
|
/// Task this one is a subtask of
|
||||||
|
pub parent_id: Option<TaskId>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// http(s) urls
|
||||||
|
pub links: Vec<String>,
|
||||||
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
|
pub done: bool,
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
/// Sorted like the tasks. Always empty for a subtask: there is only one level.
|
||||||
|
pub subtasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of a task, used to create or replace one
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TaskData {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub links: Vec<String>,
|
||||||
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
|
pub done: bool,
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every task of a course
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct CourseTasks {
|
||||||
|
/// Tasks with their subtasks: not done first, then by deadline (the ones without
|
||||||
|
/// a deadline last)
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A task or subtask with a deadline, as shown in the calendar
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct CalendarTask {
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub course_id: CourseId,
|
||||||
|
pub course_name: String,
|
||||||
|
pub course_code: Option<String>,
|
||||||
|
/// Name of the task, prefixed by the one of its parent for a subtask
|
||||||
|
pub name: String,
|
||||||
|
pub deadline: DateTime<Utc>,
|
||||||
|
/// Also true for a subtask of a task done
|
||||||
|
pub done: bool,
|
||||||
|
/// Tags of the task, or of its parent for a subtask without any
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use chrono::NaiveTime;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::core::models::slot_tag::SlotTagId;
|
use crate::core::models::tag::TagId;
|
||||||
|
|
||||||
pub type TimeSlotId = i32;
|
pub type TimeSlotId = i32;
|
||||||
|
|
||||||
|
|
@ -10,17 +10,17 @@ pub type TimeSlotId = i32;
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct TimeSlot {
|
pub struct TimeSlot {
|
||||||
pub id: TimeSlotId,
|
pub id: TimeSlotId,
|
||||||
pub tag_id: SlotTagId,
|
pub tag_id: TagId,
|
||||||
/// ISO weekday: 1 = monday ... 5 = friday
|
/// ISO weekday: 1 = monday ... 5 = friday
|
||||||
pub weekday: u8,
|
pub weekday: u8,
|
||||||
pub start_time: NaiveTime,
|
pub start_time: NaiveTime,
|
||||||
pub end_time: NaiveTime,
|
pub end_time: NaiveTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content of a slot, used when creating or replacing a course
|
/// Content of a slot, used when adding one to a course
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TimeSlotData {
|
pub struct TimeSlotData {
|
||||||
pub tag_id: SlotTagId,
|
pub tag_id: TagId,
|
||||||
pub weekday: u8,
|
pub weekday: u8,
|
||||||
pub start_time: NaiveTime,
|
pub start_time: NaiveTime,
|
||||||
pub end_time: NaiveTime,
|
pub end_time: NaiveTime,
|
||||||
|
|
|
||||||
37
src/core/repositories/course_files_repository.rs
Normal file
37
src/core/repositories/course_files_repository.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
course_file::{CourseFile, CourseFileId},
|
||||||
|
tag::TagId,
|
||||||
|
},
|
||||||
|
repositories::RepositoryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CourseFilesRepository {
|
||||||
|
/// Files of a course, the oldest first
|
||||||
|
async fn get_course_files(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
) -> Result<Vec<CourseFile>, RepositoryError>;
|
||||||
|
/// `NotFound` if the file does not exist
|
||||||
|
async fn get_course_file(&self, id: CourseFileId) -> Result<CourseFile, RepositoryError>;
|
||||||
|
async fn create_lecture_note(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
name: String,
|
||||||
|
tag_ids: &[TagId],
|
||||||
|
) -> Result<CourseFile, RepositoryError>;
|
||||||
|
/// Sets the name and `has_pdf`, and bumps `updated_at`. `NotFound` if the note does
|
||||||
|
/// not exist.
|
||||||
|
async fn update_lecture_note(
|
||||||
|
&self,
|
||||||
|
id: CourseFileId,
|
||||||
|
name: String,
|
||||||
|
has_pdf: bool,
|
||||||
|
) -> Result<CourseFile, RepositoryError>;
|
||||||
|
/// `NotFound` if the file does not exist
|
||||||
|
async fn delete_course_file(&self, id: CourseFileId) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
models::course::{Course, CourseData, CourseId},
|
models::{
|
||||||
|
course::{Course, CourseData, CourseId},
|
||||||
|
time_slot::{TimeSlotData, TimeSlotId},
|
||||||
|
},
|
||||||
repositories::RepositoryError,
|
repositories::RepositoryError,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -9,13 +12,29 @@ use crate::core::{
|
||||||
pub trait CoursesRepository {
|
pub trait CoursesRepository {
|
||||||
/// Every course with its slots, sorted by name
|
/// Every course with its slots, sorted by name
|
||||||
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError>;
|
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError>;
|
||||||
|
/// `NotFound` if the course does not exist
|
||||||
|
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError>;
|
||||||
|
/// Creates a course without any slot
|
||||||
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError>;
|
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError>;
|
||||||
/// Replaces the course and all its slots. `NotFound` if the course does not exist.
|
/// Replaces the course, keeping its slots. `NotFound` if the course does not exist.
|
||||||
async fn update_course(
|
async fn update_course(
|
||||||
&self,
|
&self,
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
) -> Result<Course, RepositoryError>;
|
) -> Result<Course, RepositoryError>;
|
||||||
/// Deletes the course and its slots. `NotFound` if the course does not exist.
|
/// Deletes the course and everything attached to it. `NotFound` if it does not exist.
|
||||||
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
|
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
|
||||||
|
|
||||||
|
/// Returns the course with the new slot. `NotFound` if the course does not exist.
|
||||||
|
async fn add_slot(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
slot: TimeSlotData,
|
||||||
|
) -> Result<Course, RepositoryError>;
|
||||||
|
/// Returns the course without the slot. `NotFound` if the slot is not in the course.
|
||||||
|
async fn delete_slot(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
slot_id: TimeSlotId,
|
||||||
|
) -> Result<Course, RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
src/core/repositories/grades_repository.rs
Normal file
26
src/core/repositories/grades_repository.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
grade::{Grade, GradeData, GradeId},
|
||||||
|
},
|
||||||
|
repositories::RepositoryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GradesRepository {
|
||||||
|
/// Grades of a course, in the order they were added
|
||||||
|
async fn get_grades(&self, course_id: CourseId) -> Result<Vec<Grade>, RepositoryError>;
|
||||||
|
/// Course of a grade. `NotFound` if the grade does not exist.
|
||||||
|
async fn get_grade_course(&self, id: GradeId) -> Result<CourseId, RepositoryError>;
|
||||||
|
async fn create_grade(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
grade: GradeData,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
|
/// `NotFound` if the grade does not exist
|
||||||
|
async fn update_grade(&self, id: GradeId, grade: GradeData) -> Result<(), RepositoryError>;
|
||||||
|
/// `NotFound` if the grade does not exist
|
||||||
|
async fn delete_grade(&self, id: GradeId) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
|
|
@ -8,15 +8,31 @@ use async_trait::async_trait;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::core::repositories::{
|
use crate::core::repositories::{
|
||||||
courses_repository::CoursesRepository, slot_tags_repository::SlotTagsRepository,
|
course_files_repository::CourseFilesRepository, courses_repository::CoursesRepository,
|
||||||
|
grades_repository::GradesRepository, tags_repository::TagsRepository,
|
||||||
|
tasks_repository::TasksRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod course_files_repository;
|
||||||
pub mod courses_repository;
|
pub mod courses_repository;
|
||||||
pub mod slot_tags_repository;
|
pub mod grades_repository;
|
||||||
|
pub mod pdf_renderer;
|
||||||
|
pub mod storage_repository;
|
||||||
|
pub mod tags_repository;
|
||||||
|
pub mod tasks_repository;
|
||||||
|
|
||||||
/// Add every new repository trait here so the controller can use it through `db`
|
/// Add every new repository trait here so the controller can use it through `db`
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait DatabaseRepository: CoursesRepository + SlotTagsRepository + Send + Sync {}
|
pub trait DatabaseRepository:
|
||||||
|
CoursesRepository
|
||||||
|
+ TagsRepository
|
||||||
|
+ GradesRepository
|
||||||
|
+ TasksRepository
|
||||||
|
+ CourseFilesRepository
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum RepositoryError {
|
pub enum RepositoryError {
|
||||||
|
|
|
||||||
18
src/core/repositories/pdf_renderer.rs
Normal file
18
src/core/repositories/pdf_renderer.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Turns a markdown document (front matter included) into a pdf
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PdfRenderer: Send + Sync {
|
||||||
|
async fn render(&self, markdown: &str) -> Result<Vec<u8>, RenderError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum RenderError {
|
||||||
|
/// The document does not compile. Holds the end of the log, to show to the user.
|
||||||
|
#[error("rendering failed: {0}")]
|
||||||
|
Failed(String),
|
||||||
|
/// The renderer itself could not run (missing pandoc, i/o error...)
|
||||||
|
#[error("renderer error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::core::{models::slot_tag::SlotTag, repositories::RepositoryError};
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SlotTagsRepository {
|
|
||||||
async fn get_slot_tags(&self) -> Result<Vec<SlotTag>, RepositoryError>;
|
|
||||||
}
|
|
||||||
18
src/core/repositories/storage_repository.rs
Normal file
18
src/core/repositories/storage_repository.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::core::repositories::RepositoryError;
|
||||||
|
|
||||||
|
/// Where the files of the courses are kept (lecture notes, their pdf...). Meant to be the
|
||||||
|
/// NAS; for now a local directory. Paths are relative to the root of the storage and use
|
||||||
|
/// `/` as separator.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait StorageRepository: Send + Sync {
|
||||||
|
/// `NotFound` if the file does not exist
|
||||||
|
async fn read_file(&self, path: &str) -> Result<Vec<u8>, RepositoryError>;
|
||||||
|
/// Creates the missing directories, replaces an existing file
|
||||||
|
async fn write_file(&self, path: &str, content: &[u8]) -> Result<(), RepositoryError>;
|
||||||
|
/// Does nothing if the file does not exist
|
||||||
|
async fn delete_file(&self, path: &str) -> Result<(), RepositoryError>;
|
||||||
|
/// Deletes a directory and everything in it. Does nothing if it does not exist.
|
||||||
|
async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
14
src/core/repositories/tags_repository.rs
Normal file
14
src/core/repositories/tags_repository.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
models::tag::{Tag, TagId, TagScope},
|
||||||
|
repositories::RepositoryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TagsRepository {
|
||||||
|
/// Every tag, or only the ones that can label `scope`
|
||||||
|
async fn get_tags(&self, scope: Option<TagScope>) -> Result<Vec<Tag>, RepositoryError>;
|
||||||
|
/// Id of a tag by its stable name. `NotFound` if there is none.
|
||||||
|
async fn get_tag_id(&self, slug: &str) -> Result<TagId, RepositoryError>;
|
||||||
|
}
|
||||||
35
src/core/repositories/tasks_repository.rs
Normal file
35
src/core/repositories/tasks_repository.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
task::{Task, TaskData, TaskId},
|
||||||
|
},
|
||||||
|
repositories::RepositoryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TasksRepository {
|
||||||
|
/// Every task and subtask of a course, flat: `subtasks` is always empty
|
||||||
|
async fn get_tasks(&self, course_id: CourseId) -> Result<Vec<Task>, RepositoryError>;
|
||||||
|
/// Tasks and subtasks of every course with a deadline in `[from, to[`, and the parents
|
||||||
|
/// of those subtasks. Flat: `subtasks` is always empty.
|
||||||
|
async fn get_dated_tasks(
|
||||||
|
&self,
|
||||||
|
from: DateTime<Utc>,
|
||||||
|
to: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<Task>, RepositoryError>;
|
||||||
|
/// A single task, `subtasks` empty. `NotFound` if the task does not exist.
|
||||||
|
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError>;
|
||||||
|
async fn create_task(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
parent_id: Option<TaskId>,
|
||||||
|
task: TaskData,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
|
/// Replaces the task and its tags. `NotFound` if the task does not exist.
|
||||||
|
async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError>;
|
||||||
|
/// Deletes the task and its subtasks. `NotFound` if the task does not exist.
|
||||||
|
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
30
src/main.rs
30
src/main.rs
|
|
@ -1,10 +1,13 @@
|
||||||
use std::sync::Arc;
|
use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{core::controller::AppController, services::database::SqlxDatabase};
|
use crate::{
|
||||||
|
core::controller::{AppController, course_files::NotesSettings},
|
||||||
|
services::{database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage},
|
||||||
|
};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod core;
|
mod core;
|
||||||
|
|
@ -19,7 +22,7 @@ async fn main() {
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.with(
|
.with(
|
||||||
EnvFilter::try_from_default_env()
|
EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| EnvFilter::new(format!("info, {}=debug", env!("CRATE_NAME")))),
|
.unwrap_or_else(|_| EnvFilter::new(format!("info,{}=debug", env!("CRATE_NAME")))),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|
@ -27,7 +30,26 @@ async fn main() {
|
||||||
.await
|
.await
|
||||||
.expect("Unable to connect to database");
|
.expect("Unable to connect to database");
|
||||||
|
|
||||||
let controller = AppController::new(Arc::new(Box::new(db)));
|
// Absolute: pandoc runs in a temporary directory
|
||||||
|
let template_dir = Path::new(&config.notes.template_dir)
|
||||||
|
.canonicalize()
|
||||||
|
.expect("Unable to find the template directory of the notes");
|
||||||
|
let notes = NotesSettings {
|
||||||
|
student_name: config.notes.student_name.clone(),
|
||||||
|
student_id: config.notes.student_id.clone(),
|
||||||
|
logo: config
|
||||||
|
.notes
|
||||||
|
.logo
|
||||||
|
.as_ref()
|
||||||
|
.map(|logo| template_dir.join(logo).display().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = AppController::new(
|
||||||
|
Arc::new(Box::new(db)),
|
||||||
|
Arc::new(LocalStorage::new(&config.storage_dir)),
|
||||||
|
Arc::new(PandocRenderer::new(template_dir)),
|
||||||
|
notes,
|
||||||
|
);
|
||||||
|
|
||||||
// Anything that is not an api route is served from the built frontend,
|
// Anything that is not an api route is served from the built frontend,
|
||||||
// falling back on index.html so the vue router can handle the path.
|
// falling back on index.html so the vue router can handle the path.
|
||||||
|
|
|
||||||
158
src/services/database/course_files.rs
Normal file
158
src/services/database/course_files.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
course_file::{CourseFile, CourseFileId, FileKind},
|
||||||
|
tag::TagId,
|
||||||
|
},
|
||||||
|
repositories::{RepositoryError, course_files_repository::CourseFilesRepository},
|
||||||
|
},
|
||||||
|
services::database::SqlxDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CourseFileDB {
|
||||||
|
pub id: i32,
|
||||||
|
pub course_id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub tag_ids: Vec<i32>,
|
||||||
|
pub has_pdf: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<CourseFileDB> for CourseFile {
|
||||||
|
type Error = RepositoryError;
|
||||||
|
|
||||||
|
fn try_from(value: CourseFileDB) -> Result<Self, Self::Error> {
|
||||||
|
Ok(CourseFile {
|
||||||
|
id: value.id,
|
||||||
|
course_id: value.course_id,
|
||||||
|
name: value.name,
|
||||||
|
kind: match value.kind.as_str() {
|
||||||
|
"note" => FileKind::Note,
|
||||||
|
"upload" => FileKind::Upload,
|
||||||
|
kind => {
|
||||||
|
return Err(RepositoryError::TypeConversion(format!(
|
||||||
|
"invalid file kind {kind}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tag_ids: value.tag_ids,
|
||||||
|
has_pdf: value.has_pdf,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found(id: CourseFileId) -> RepositoryError {
|
||||||
|
RepositoryError::NotFound(format!("file {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CourseFilesRepository for SqlxDatabase {
|
||||||
|
async fn get_course_files(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
) -> Result<Vec<CourseFile>, RepositoryError> {
|
||||||
|
query_as!(
|
||||||
|
CourseFileDB,
|
||||||
|
r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
|
||||||
|
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
||||||
|
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM course_files f
|
||||||
|
LEFT JOIN file_tags ft ON ft.file_id = f.id
|
||||||
|
WHERE f.course_id = $1
|
||||||
|
GROUP BY f.id
|
||||||
|
ORDER BY f.created_at, f.id"#,
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(TryInto::try_into)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_course_file(&self, id: CourseFileId) -> Result<CourseFile, RepositoryError> {
|
||||||
|
query_as!(
|
||||||
|
CourseFileDB,
|
||||||
|
r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
|
||||||
|
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
||||||
|
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM course_files f
|
||||||
|
LEFT JOIN file_tags ft ON ft.file_id = f.id
|
||||||
|
WHERE f.id = $1
|
||||||
|
GROUP BY f.id"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| not_found(id))?
|
||||||
|
.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_lecture_note(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
name: String,
|
||||||
|
tag_ids: &[TagId],
|
||||||
|
) -> Result<CourseFile, RepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let id = query!(
|
||||||
|
r#"INSERT INTO course_files (course_id, name, kind) VALUES ($1, $2, 'note')
|
||||||
|
RETURNING id"#,
|
||||||
|
course_id,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.id;
|
||||||
|
query!(
|
||||||
|
r#"INSERT INTO file_tags (file_id, tag_id)
|
||||||
|
SELECT $1, tag_id FROM UNNEST($2::INTEGER[]) AS tag_id"#,
|
||||||
|
id,
|
||||||
|
tag_ids,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
self.get_course_file(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_lecture_note(
|
||||||
|
&self,
|
||||||
|
id: CourseFileId,
|
||||||
|
name: String,
|
||||||
|
has_pdf: bool,
|
||||||
|
) -> Result<CourseFile, RepositoryError> {
|
||||||
|
let result = query!(
|
||||||
|
r#"UPDATE course_files SET name = $2, has_pdf = $3, updated_at = now()
|
||||||
|
WHERE id = $1 AND kind = 'note'"#,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_pdf,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
self.get_course_file(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_course_file(&self, id: CourseFileId) -> Result<(), RepositoryError> {
|
||||||
|
let result = query!("DELETE FROM course_files WHERE id = $1", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
//! Courses and their time slots. A course is always read and written together with
|
//! Courses and their time slots. A course is always read together with its slots, but
|
||||||
//! its slots: updating a course replaces all of them, inside a transaction.
|
//! the slots are added and removed one by one.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveTime;
|
use chrono::NaiveTime;
|
||||||
use sqlx::{Postgres, Transaction, query, query_as};
|
use sqlx::{PgExecutor, query, query_as};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{
|
core::{
|
||||||
models::{
|
models::{
|
||||||
course::{Course, CourseData, CourseId},
|
course::{Course, CourseData, CourseId},
|
||||||
time_slot::{TimeSlot, TimeSlotData},
|
time_slot::{TimeSlot, TimeSlotData, TimeSlotId},
|
||||||
},
|
},
|
||||||
repositories::{RepositoryError, courses_repository::CoursesRepository},
|
repositories::{RepositoryError, courses_repository::CoursesRepository},
|
||||||
},
|
},
|
||||||
|
|
@ -64,31 +64,22 @@ impl TryFrom<TimeSlotDB> for TimeSlot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts the slots of a course, returning them sorted like `get_courses` does
|
/// Slots of a course, sorted like `get_courses` does
|
||||||
async fn insert_slots(
|
async fn get_slots(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
executor: impl PgExecutor<'_>,
|
||||||
course_id: CourseId,
|
course_id: CourseId,
|
||||||
slots: Vec<TimeSlotData>,
|
|
||||||
) -> Result<Vec<TimeSlot>, RepositoryError> {
|
) -> Result<Vec<TimeSlot>, RepositoryError> {
|
||||||
let mut inserted = Vec::with_capacity(slots.len());
|
query_as!(
|
||||||
for slot in slots {
|
|
||||||
let row = query_as!(
|
|
||||||
TimeSlotDB,
|
TimeSlotDB,
|
||||||
r#"INSERT INTO time_slots (course_id, tag_id, weekday, start_time, end_time)
|
r#"SELECT id, course_id, tag_id, weekday, start_time, end_time
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
FROM time_slots WHERE course_id = $1 ORDER BY weekday, start_time, id"#,
|
||||||
RETURNING id, course_id, tag_id, weekday, start_time, end_time"#,
|
|
||||||
course_id,
|
course_id,
|
||||||
slot.tag_id,
|
|
||||||
i16::from(slot.weekday),
|
|
||||||
slot.start_time,
|
|
||||||
slot.end_time,
|
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_all(executor)
|
||||||
.await?;
|
.await?
|
||||||
inserted.push(row.try_into()?);
|
.into_iter()
|
||||||
}
|
.map(TryInto::try_into)
|
||||||
inserted.sort_by_key(|slot: &TimeSlot| (slot.weekday, slot.start_time));
|
.collect()
|
||||||
Ok(inserted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -126,9 +117,20 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError> {
|
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let row = query_as!(
|
||||||
|
CourseDB,
|
||||||
|
r#"SELECT id, name, code, teachers, main_page_url FROM courses WHERE id = $1"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
||||||
|
let slots = get_slots(&self.pool, id).await?;
|
||||||
|
Ok(row.with_slots(slots))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError> {
|
||||||
let row = query_as!(
|
let row = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"INSERT INTO courses (name, code, teachers, main_page_url)
|
r#"INSERT INTO courses (name, code, teachers, main_page_url)
|
||||||
|
|
@ -139,12 +141,9 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
&course.teachers,
|
&course.teachers,
|
||||||
course.main_page_url,
|
course.main_page_url,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
let slots = insert_slots(&mut tx, row.id, course.slots).await?;
|
Ok(row.with_slots(Vec::new()))
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(row.with_slots(slots))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_course(
|
async fn update_course(
|
||||||
|
|
@ -152,8 +151,6 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
) -> Result<Course, RepositoryError> {
|
) -> Result<Course, RepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
|
||||||
|
|
||||||
let row = query_as!(
|
let row = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"UPDATE courses SET name = $2, code = $3, teachers = $4, main_page_url = $5
|
r#"UPDATE courses SET name = $2, code = $3, teachers = $4, main_page_url = $5
|
||||||
|
|
@ -165,21 +162,15 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
&course.teachers,
|
&course.teachers,
|
||||||
course.main_page_url,
|
course.main_page_url,
|
||||||
)
|
)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
||||||
|
let slots = get_slots(&self.pool, id).await?;
|
||||||
query!("DELETE FROM time_slots WHERE course_id = $1", id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let slots = insert_slots(&mut tx, id, course.slots).await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(row.with_slots(slots))
|
Ok(row.with_slots(slots))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
|
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
|
||||||
// The slots are removed by the `ON DELETE CASCADE`
|
// Slots, grades, tasks and notes are removed by the `ON DELETE CASCADE`
|
||||||
let result = query!("DELETE FROM courses WHERE id = $1", id)
|
let result = query!("DELETE FROM courses WHERE id = $1", id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -188,4 +179,46 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_slot(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
slot: TimeSlotData,
|
||||||
|
) -> Result<Course, RepositoryError> {
|
||||||
|
let result = query!(
|
||||||
|
r#"INSERT INTO time_slots (course_id, tag_id, weekday, start_time, end_time)
|
||||||
|
SELECT id, $2, $3, $4, $5 FROM courses WHERE id = $1"#,
|
||||||
|
course_id,
|
||||||
|
slot.tag_id,
|
||||||
|
i16::from(slot.weekday),
|
||||||
|
slot.start_time,
|
||||||
|
slot.end_time,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound(format!("course {course_id}")));
|
||||||
|
}
|
||||||
|
self.get_course(course_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_slot(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
slot_id: TimeSlotId,
|
||||||
|
) -> Result<Course, RepositoryError> {
|
||||||
|
let result = query!(
|
||||||
|
"DELETE FROM time_slots WHERE id = $1 AND course_id = $2",
|
||||||
|
slot_id,
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound(format!(
|
||||||
|
"slot {slot_id} of course {course_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.get_course(course_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
src/services/database/grades.rs
Normal file
110
src/services/database/grades.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
grade::{Grade, GradeData, GradeId, to_swiss},
|
||||||
|
},
|
||||||
|
repositories::{RepositoryError, grades_repository::GradesRepository},
|
||||||
|
},
|
||||||
|
services::database::SqlxDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GradeDB {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub max_score: f64,
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GradeDB> for Grade {
|
||||||
|
fn from(value: GradeDB) -> Self {
|
||||||
|
Grade {
|
||||||
|
id: value.id,
|
||||||
|
swiss_grade: to_swiss(value.score / value.max_score),
|
||||||
|
name: value.name,
|
||||||
|
score: value.score,
|
||||||
|
max_score: value.max_score,
|
||||||
|
weight: value.weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found(id: GradeId) -> RepositoryError {
|
||||||
|
RepositoryError::NotFound(format!("grade {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GradesRepository for SqlxDatabase {
|
||||||
|
async fn get_grades(&self, course_id: CourseId) -> Result<Vec<Grade>, RepositoryError> {
|
||||||
|
Ok(query_as!(
|
||||||
|
GradeDB,
|
||||||
|
r#"SELECT id, name, score, max_score, weight FROM grades
|
||||||
|
WHERE course_id = $1 ORDER BY id"#,
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_grade_course(&self, id: GradeId) -> Result<CourseId, RepositoryError> {
|
||||||
|
query!("SELECT course_id FROM grades WHERE id = $1", id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|row| row.course_id)
|
||||||
|
.ok_or_else(|| not_found(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_grade(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
grade: GradeData,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
query!(
|
||||||
|
r#"INSERT INTO grades (course_id, name, score, max_score, weight)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)"#,
|
||||||
|
course_id,
|
||||||
|
grade.name,
|
||||||
|
grade.score,
|
||||||
|
grade.max_score,
|
||||||
|
grade.weight,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_grade(&self, id: GradeId, grade: GradeData) -> Result<(), RepositoryError> {
|
||||||
|
let result = query!(
|
||||||
|
r#"UPDATE grades SET name = $2, score = $3, max_score = $4, weight = $5
|
||||||
|
WHERE id = $1"#,
|
||||||
|
id,
|
||||||
|
grade.name,
|
||||||
|
grade.score,
|
||||||
|
grade.max_score,
|
||||||
|
grade.weight,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_grade(&self, id: GradeId) -> Result<(), RepositoryError> {
|
||||||
|
let result = query!("DELETE FROM grades WHERE id = $1", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present
|
//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present
|
||||||
//! (`cargo sqlx prepare`).
|
//! (`cargo sqlx prepare`).
|
||||||
|
|
||||||
|
mod course_files;
|
||||||
mod courses;
|
mod courses;
|
||||||
mod slot_tags;
|
mod grades;
|
||||||
|
mod tags;
|
||||||
|
mod tasks;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
|
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::query_as;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
models::slot_tag::SlotTag,
|
|
||||||
repositories::{RepositoryError, slot_tags_repository::SlotTagsRepository},
|
|
||||||
},
|
|
||||||
services::database::SqlxDatabase,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct SlotTagDB {
|
|
||||||
pub id: i32,
|
|
||||||
pub name: Value,
|
|
||||||
pub color: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SlotTagDB> for SlotTag {
|
|
||||||
type Error = RepositoryError;
|
|
||||||
|
|
||||||
fn try_from(value: SlotTagDB) -> Result<Self, Self::Error> {
|
|
||||||
Ok(SlotTag {
|
|
||||||
id: value.id,
|
|
||||||
name: serde_json::from_value(value.name)?,
|
|
||||||
color: value.color,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SlotTagsRepository for SqlxDatabase {
|
|
||||||
async fn get_slot_tags(&self) -> Result<Vec<SlotTag>, RepositoryError> {
|
|
||||||
query_as!(
|
|
||||||
SlotTagDB,
|
|
||||||
r#"SELECT id, name, color FROM slot_tags ORDER BY id"#
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(TryInto::try_into)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
72
src/services/database/tags.rs
Normal file
72
src/services/database/tags.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
models::tag::{Tag, TagId, TagScope},
|
||||||
|
repositories::{RepositoryError, tags_repository::TagsRepository},
|
||||||
|
},
|
||||||
|
services::database::SqlxDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TagDB {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Value,
|
||||||
|
pub color: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scope(scope: &str) -> Result<TagScope, RepositoryError> {
|
||||||
|
match scope {
|
||||||
|
"slot" => Ok(TagScope::Slot),
|
||||||
|
"task" => Ok(TagScope::Task),
|
||||||
|
"file" => Ok(TagScope::File),
|
||||||
|
_ => Err(RepositoryError::TypeConversion(format!(
|
||||||
|
"invalid tag scope {scope}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<TagDB> for Tag {
|
||||||
|
type Error = RepositoryError;
|
||||||
|
|
||||||
|
fn try_from(value: TagDB) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Tag {
|
||||||
|
id: value.id,
|
||||||
|
name: serde_json::from_value(value.name)?,
|
||||||
|
color: value.color,
|
||||||
|
scopes: value
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.map(|scope| parse_scope(scope))
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TagsRepository for SqlxDatabase {
|
||||||
|
async fn get_tags(&self, scope: Option<TagScope>) -> Result<Vec<Tag>, RepositoryError> {
|
||||||
|
query_as!(
|
||||||
|
TagDB,
|
||||||
|
r#"SELECT id, name, color, scopes FROM tags
|
||||||
|
WHERE $1::TEXT IS NULL OR $1 = ANY(scopes)
|
||||||
|
ORDER BY id"#,
|
||||||
|
scope.map(TagScope::as_str),
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(TryInto::try_into)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tag_id(&self, slug: &str) -> Result<TagId, RepositoryError> {
|
||||||
|
query!("SELECT id FROM tags WHERE slug = $1", slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|row| row.id)
|
||||||
|
.ok_or_else(|| RepositoryError::NotFound(format!("tag {slug}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/services/database/tasks.rs
Normal file
202
src/services/database/tasks.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
//! Tasks and their tags. A task is always written together with its tags, inside a
|
||||||
|
//! transaction.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{Postgres, Transaction, query, query_as};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
models::{
|
||||||
|
course::CourseId,
|
||||||
|
tag::TagId,
|
||||||
|
task::{Task, TaskData, TaskId},
|
||||||
|
},
|
||||||
|
repositories::{RepositoryError, tasks_repository::TasksRepository},
|
||||||
|
},
|
||||||
|
services::database::SqlxDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TaskDB {
|
||||||
|
pub id: i32,
|
||||||
|
pub course_id: i32,
|
||||||
|
pub parent_id: Option<i32>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub links: Vec<String>,
|
||||||
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
|
pub done: bool,
|
||||||
|
pub tag_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TaskDB> for Task {
|
||||||
|
fn from(value: TaskDB) -> Self {
|
||||||
|
Task {
|
||||||
|
id: value.id,
|
||||||
|
course_id: value.course_id,
|
||||||
|
parent_id: value.parent_id,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
links: value.links,
|
||||||
|
deadline: value.deadline,
|
||||||
|
done: value.done,
|
||||||
|
tag_ids: value.tag_ids,
|
||||||
|
subtasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found(id: TaskId) -> RepositoryError {
|
||||||
|
RepositoryError::NotFound(format!("task {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_tags(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
task_id: TaskId,
|
||||||
|
tag_ids: &[TagId],
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
query!(
|
||||||
|
r#"INSERT INTO task_tags (task_id, tag_id)
|
||||||
|
SELECT $1, tag_id FROM UNNEST($2::INTEGER[]) AS tag_id
|
||||||
|
ON CONFLICT DO NOTHING"#,
|
||||||
|
task_id,
|
||||||
|
tag_ids,
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TasksRepository for SqlxDatabase {
|
||||||
|
async fn get_tasks(&self, course_id: CourseId) -> Result<Vec<Task>, RepositoryError> {
|
||||||
|
Ok(query_as!(
|
||||||
|
TaskDB,
|
||||||
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
|
t.deadline, t.done,
|
||||||
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
WHERE t.course_id = $1
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_dated_tasks(
|
||||||
|
&self,
|
||||||
|
from: DateTime<Utc>,
|
||||||
|
to: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<Task>, RepositoryError> {
|
||||||
|
Ok(query_as!(
|
||||||
|
TaskDB,
|
||||||
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
|
t.deadline, t.done,
|
||||||
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
WHERE (t.deadline >= $1 AND t.deadline < $2)
|
||||||
|
OR t.id IN (SELECT parent_id FROM tasks
|
||||||
|
WHERE deadline >= $1 AND deadline < $2 AND parent_id IS NOT NULL)
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError> {
|
||||||
|
query_as!(
|
||||||
|
TaskDB,
|
||||||
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
|
t.deadline, t.done,
|
||||||
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
GROUP BY t.id"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(Into::into)
|
||||||
|
.ok_or_else(|| not_found(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_task(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
parent_id: Option<TaskId>,
|
||||||
|
task: TaskData,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let id = query!(
|
||||||
|
r#"INSERT INTO tasks (course_id, parent_id, name, description, links, deadline, done)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id"#,
|
||||||
|
course_id,
|
||||||
|
parent_id,
|
||||||
|
task.name,
|
||||||
|
task.description,
|
||||||
|
&task.links,
|
||||||
|
task.deadline,
|
||||||
|
task.done,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.id;
|
||||||
|
insert_tags(&mut tx, id, &task.tag_ids).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let result = query!(
|
||||||
|
r#"UPDATE tasks SET name = $2, description = $3, links = $4, deadline = $5, done = $6
|
||||||
|
WHERE id = $1"#,
|
||||||
|
id,
|
||||||
|
task.name,
|
||||||
|
task.description,
|
||||||
|
&task.links,
|
||||||
|
task.deadline,
|
||||||
|
task.done,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
query!("DELETE FROM task_tags WHERE task_id = $1", id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
insert_tags(&mut tx, id, &task.tag_ids).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError> {
|
||||||
|
// The subtasks and the tags are removed by the `ON DELETE CASCADE`
|
||||||
|
let result = query!("DELETE FROM tasks WHERE id = $1", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(not_found(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
//! External services used by the core: database, and any third party api you add.
|
//! External services used by the core: database, file storage, pdf rendering, and any
|
||||||
|
//! third party api you add.
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
pub mod pandoc;
|
||||||
|
pub mod storage;
|
||||||
|
|
|
||||||
73
src/services/pandoc.rs
Normal file
73
src/services/pandoc.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//! Markdown to pdf with pandoc and pdflatex, using the course template (`template.tex`,
|
||||||
|
//! `box-filter.lua` and `assets/` of the template directory). Same command as the
|
||||||
|
//! `mdpdf` shell function, plus the lua filter for the `::: {.defbox}` blocks.
|
||||||
|
|
||||||
|
use std::{path::PathBuf, process::Stdio, time::Duration};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::{fs, process::Command, time::timeout};
|
||||||
|
|
||||||
|
use crate::core::repositories::pdf_renderer::{PdfRenderer, RenderError};
|
||||||
|
|
||||||
|
/// A LaTeX document stuck on an error must not hang the request forever
|
||||||
|
const RENDER_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
/// Lines of the log returned when the rendering fails
|
||||||
|
const LOG_TAIL_LINES: usize = 30;
|
||||||
|
|
||||||
|
pub struct PandocRenderer {
|
||||||
|
template_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PandocRenderer {
|
||||||
|
/// `template_dir` must be absolute: pandoc runs in a temporary directory
|
||||||
|
pub fn new(template_dir: PathBuf) -> Self {
|
||||||
|
Self { template_dir }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal(err: impl std::fmt::Display) -> RenderError {
|
||||||
|
RenderError::Internal(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PdfRenderer for PandocRenderer {
|
||||||
|
async fn render(&self, markdown: &str) -> Result<Vec<u8>, RenderError> {
|
||||||
|
let work_dir = tempfile::tempdir().map_err(internal)?;
|
||||||
|
let input = work_dir.path().join("note.md");
|
||||||
|
let output = work_dir.path().join("note.pdf");
|
||||||
|
fs::write(&input, markdown).await.map_err(internal)?;
|
||||||
|
|
||||||
|
let template_dir = self.template_dir.display();
|
||||||
|
let command = Command::new("pandoc")
|
||||||
|
.current_dir(work_dir.path())
|
||||||
|
// `//` makes TeX search the subdirectories too
|
||||||
|
.env("TEXINPUTS", format!(".:{template_dir}//:"))
|
||||||
|
.arg(&input)
|
||||||
|
.arg(format!("--template={template_dir}/template.tex"))
|
||||||
|
.arg(format!("--lua-filter={template_dir}/box-filter.lua"))
|
||||||
|
.arg(format!(
|
||||||
|
"--resource-path={}:{template_dir}",
|
||||||
|
work_dir.path().display()
|
||||||
|
))
|
||||||
|
.arg("--pdf-engine=pdflatex")
|
||||||
|
.arg("--syntax-highlighting=idiomatic")
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&output)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let result = timeout(RENDER_TIMEOUT, command)
|
||||||
|
.await
|
||||||
|
.map_err(|_| RenderError::Failed("The rendering took too long".to_owned()))?
|
||||||
|
.map_err(|err| internal(format!("unable to run pandoc: {err}")))?;
|
||||||
|
|
||||||
|
if !result.status.success() {
|
||||||
|
let log = String::from_utf8_lossy(&result.stderr);
|
||||||
|
let lines: Vec<&str> = log.lines().collect();
|
||||||
|
let tail = lines[lines.len().saturating_sub(LOG_TAIL_LINES)..].join("\n");
|
||||||
|
return Err(RenderError::Failed(tail));
|
||||||
|
}
|
||||||
|
fs::read(&output).await.map_err(internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/services/storage.rs
Normal file
78
src/services/storage.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
//! Storage of the files in a local directory, standing in for the NAS until it is
|
||||||
|
//! plugged in.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
io::ErrorKind,
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::core::repositories::{RepositoryError, storage_repository::StorageRepository};
|
||||||
|
|
||||||
|
pub struct LocalStorage {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalStorage {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path of a file, refusing anything that could escape the root
|
||||||
|
fn resolve(&self, path: &str) -> Result<PathBuf, RepositoryError> {
|
||||||
|
let relative = Path::new(path);
|
||||||
|
if !relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_)))
|
||||||
|
{
|
||||||
|
return Err(RepositoryError::Internal(format!(
|
||||||
|
"invalid storage path {path}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(self.root.join(relative))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error(path: &str, err: std::io::Error) -> RepositoryError {
|
||||||
|
match err.kind() {
|
||||||
|
ErrorKind::NotFound => RepositoryError::NotFound(format!("file {path}")),
|
||||||
|
_ => RepositoryError::Internal(format!("storage error on {path}: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StorageRepository for LocalStorage {
|
||||||
|
async fn read_file(&self, path: &str) -> Result<Vec<u8>, RepositoryError> {
|
||||||
|
fs::read(self.resolve(path)?)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(path, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(&self, path: &str, content: &[u8]) -> Result<(), RepositoryError> {
|
||||||
|
let file = self.resolve(path)?;
|
||||||
|
if let Some(parent) = file.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(path, err))?;
|
||||||
|
}
|
||||||
|
fs::write(file, content)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(path, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, path: &str) -> Result<(), RepositoryError> {
|
||||||
|
match fs::remove_file(self.resolve(path)?).await {
|
||||||
|
Err(err) if err.kind() != ErrorKind::NotFound => Err(io_error(path, err)),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError> {
|
||||||
|
match fs::remove_dir_all(self.resolve(path)?).await {
|
||||||
|
Err(err) if err.kind() != ErrorKind::NotFound => Err(io_error(path, err)),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,12 +18,28 @@ pub struct PostgresConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rendering of the lecture notes to pdf
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct NotesConfig {
|
||||||
|
/// Directory of `template.tex`, `box-filter.lua` and `assets/`
|
||||||
|
pub template_dir: String,
|
||||||
|
/// Logo shown at the top right, relative to `template_dir`
|
||||||
|
pub logo: Option<String>,
|
||||||
|
/// Shown in the header of the pdf
|
||||||
|
pub student_name: Option<String>,
|
||||||
|
pub student_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub postgres: PostgresConfig,
|
pub postgres: PostgresConfig,
|
||||||
/// Directory containing the built frontend
|
/// Directory containing the built frontend
|
||||||
pub frontend_dir: String,
|
pub frontend_dir: String,
|
||||||
|
/// Where the files of the courses are stored (lecture notes, pdf...). A local
|
||||||
|
/// directory standing in for the NAS for now.
|
||||||
|
pub storage_dir: String,
|
||||||
|
pub notes: NotesConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue