From 3ee195c45f48ec6e55a59f73a852ab04f1712de7 Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:09:24 +0200 Subject: [PATCH 01/10] feat: add Folder model and link to Subject with folderId foreign key --- .../20260704110914_add_folders/migration.sql | 14 ++++++++++++++ backend/prisma/schema.prisma | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 backend/prisma/migrations/20260704110914_add_folders/migration.sql diff --git a/backend/prisma/migrations/20260704110914_add_folders/migration.sql b/backend/prisma/migrations/20260704110914_add_folders/migration.sql new file mode 100644 index 0000000..b1448e0 --- /dev/null +++ b/backend/prisma/migrations/20260704110914_add_folders/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Subject" ADD COLUMN "folderId" INTEGER; + +-- CreateTable +CREATE TABLE "Folder" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Folder_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Subject" ADD CONSTRAINT "Subject_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9eda04a..2d39903 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -39,6 +39,13 @@ model Manta { event Event @relation(fields: [eventId], references: [id]) } +model Folder { + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + subjects Subject[] +} + model Subject { id Int @id @default(autoincrement()) name String @@ -53,6 +60,8 @@ model Subject { proposed Boolean @default(false) rejected Boolean @default(false) rejectionReason String? + folderId Int? + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } From c87bef62bfaa79d7c43fbd12244925d7ca2d4929 Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:12:31 +0200 Subject: [PATCH 02/10] feat: implement folder handling in subject model and service, add folderId support --- backend/src/models/folder.ts | 18 ++++++++++++++++++ backend/src/models/subject.ts | 2 +- backend/src/services/subject.ts | 2 +- backend/src/types/subject.ts | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 backend/src/models/folder.ts diff --git a/backend/src/models/folder.ts b/backend/src/models/folder.ts new file mode 100644 index 0000000..315fbcc --- /dev/null +++ b/backend/src/models/folder.ts @@ -0,0 +1,18 @@ +import { prisma } from "../utils/prisma"; + +export const folderModel = { + findAll: () => + prisma.folder.findMany({ + orderBy: { name: "asc" }, + include: { _count: { select: { subjects: true } } }, + }), + + create: (name: string) => + prisma.folder.create({ data: { name } }), + + rename: (id: number, name: string) => + prisma.folder.update({ where: { id }, data: { name } }), + + delete: (id: number) => + prisma.folder.delete({ where: { id } }), +}; diff --git a/backend/src/models/subject.ts b/backend/src/models/subject.ts index 98eb7d4..454866e 100644 --- a/backend/src/models/subject.ts +++ b/backend/src/models/subject.ts @@ -35,7 +35,7 @@ export const subjectModel = { reject: (id: number, reason: string) => prisma.subject.update({ where: { id }, data: { proposed: false, rejected: true, rejectionReason: reason } }), - update: (id: number, data: { name: string; description: string; difficulty: import("@prisma/client").Difficulty; tags: string[]; files: string[]; urls: string[] }) => + update: (id: number, data: { name: string; description: string; difficulty: import("@prisma/client").Difficulty; tags: string[]; files: string[]; urls: string[]; folderId?: number | null }) => prisma.subject.update({ where: { id }, data }), deleteById: (id: number) => diff --git a/backend/src/services/subject.ts b/backend/src/services/subject.ts index b025cea..e64fac9 100644 --- a/backend/src/services/subject.ts +++ b/backend/src/services/subject.ts @@ -43,7 +43,7 @@ export const subjectService = { const urls = (data.urls ?? "").split("\n").map(u => u.trim()).filter(Boolean); const files = await saveFiles(data.newFiles ?? []); - return subjectModel.create({ name: data.name, description: data.description, difficulty, tags, files, urls }); + return subjectModel.create({ name: data.name, description: data.description, difficulty, tags, files, urls, folderId: data.folderId ?? null }); }, getVisible: async () => { diff --git a/backend/src/types/subject.ts b/backend/src/types/subject.ts index 79acc49..898e2b8 100644 --- a/backend/src/types/subject.ts +++ b/backend/src/types/subject.ts @@ -8,6 +8,7 @@ export interface SubjectCreateData { files: string[]; urls: string[]; proposed?: boolean; + folderId?: number | null; } export interface SubjectServiceInput { @@ -17,6 +18,7 @@ export interface SubjectServiceInput { tags: string; urls?: string; newFiles?: File[]; + folderId?: number | null; } export interface SubjectProposeInput { From 58482e77b1f1b048e9c2e4ced2ed96f4827c63d4 Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:15:42 +0200 Subject: [PATCH 03/10] feat: add folder management endpoints and integrate folderId in subject updates --- backend/src/routes/admin.ts | 38 +++++++++++++++++++++++++++++++++ backend/src/routes/manta.ts | 5 +++++ backend/src/services/subject.ts | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9ea8f03..2b71eb5 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -2,8 +2,18 @@ import Elysia, { t } from "elysia"; import { subjectService } from "../services/subject"; import { eventService } from "../services/event"; import { whitelistModel } from "../models/whitelist"; +import { folderModel } from "../models/folder"; import { verifyAdminToken } from "../lib/adminJwt"; +// formData envoie folderId en str : "" = racine (null), absent = ne pas modif +function parseFolderId(raw: unknown): number | null | undefined { + if (raw === undefined) return undefined; + const s = String(raw); + if (s === "") return null; + const n = Number(s); + return isNaN(n) ? undefined : n; +} + export const adminRoutes = new Elysia({ prefix: "/admin" }) .onBeforeHandle(async ({ headers }) => { const token = headers["authorization"]?.replace("Bearer ", ""); @@ -26,6 +36,7 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) tags: String(data["tags"] ?? ""), urls: data["urls"] ? String(data["urls"]) : undefined, newFiles, + folderId: parseFolderId(data["folderId"]), }); return { success: true, subject }; }, @@ -40,10 +51,35 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) ]), tags: t.Optional(t.String()), urls: t.Optional(t.String()), + folderId: t.Optional(t.String()), file: t.Optional(t.Any()), }), } ) + .get("/folders", async () => { + const folders = await folderModel.findAll(); + return { folders }; + }) + .post("/folders", async ({ body }) => { + const folder = await folderModel.create(body.name.trim()); + return { success: true, folder }; + }, { + body: t.Object({ name: t.String({ minLength: 1 }) }), + }) + .patch("/folders/:id", async ({ params, body, set }) => { + const id = Number(params.id); + if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; } + const folder = await folderModel.rename(id, body.name.trim()); + return { success: true, folder }; + }, { + body: t.Object({ name: t.String({ minLength: 1 }) }), + }) + .delete("/folders/:id", async ({ params, set }) => { + const id = Number(params.id); + if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; } + await folderModel.delete(id); + return { success: true }; + }) .get("/subjects", async () => { const subjects = await subjectService.getAll(); return { subjects }; @@ -67,6 +103,7 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) urls: data["urls"] ? String(data["urls"]) : undefined, newFiles, existingFiles, + folderId: parseFolderId(data["folderId"]), }); return { success: true, subject }; }, { @@ -77,6 +114,7 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) tags: t.Optional(t.String()), urls: t.Optional(t.String()), existingFiles: t.Optional(t.String()), + folderId: t.Optional(t.String()), file: t.Optional(t.Any()), }), }) diff --git a/backend/src/routes/manta.ts b/backend/src/routes/manta.ts index 7a74db4..ac5ab84 100644 --- a/backend/src/routes/manta.ts +++ b/backend/src/routes/manta.ts @@ -2,6 +2,7 @@ import Elysia, { t } from "elysia"; import { mantaAuth } from "../middlewares/mantaAuth"; import { subjectService } from "../services/subject"; import { eventService } from "../services/event"; +import { folderModel } from "../models/folder"; const DIFF_LITERALS = t.Union([ t.Literal("Débutant"), @@ -15,6 +16,10 @@ export const mantaRoutes = new Elysia({ prefix: "/manta" }) const subjects = await subjectService.getAll(); return { subjects }; }) + .get("/folders", async () => { + const folders = await folderModel.findAll(); + return { folders }; + }) .get("/subjects/proposed", async () => { const subjects = await subjectService.getProposed(); return { subjects }; diff --git a/backend/src/services/subject.ts b/backend/src/services/subject.ts index e64fac9..661a96c 100644 --- a/backend/src/services/subject.ts +++ b/backend/src/services/subject.ts @@ -100,7 +100,7 @@ export const subjectService = { const newFileNames = await saveFiles(data.newFiles ?? []); const files = [...(data.existingFiles ?? []), ...newFileNames]; - return subjectModel.update(id, { name: data.name, description: data.description, difficulty, tags, files, urls }); + return subjectModel.update(id, { name: data.name, description: data.description, difficulty, tags, files, urls, folderId: data.folderId }); }, setVisible: (id: number, visible: boolean) => From 0780a21a49869038c35196c2132da49c8c35931e Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:16:17 +0200 Subject: [PATCH 04/10] feat: add folderId to AdminSubject interface and define Folder interface --- frontend/src/components/admin/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/components/admin/types.ts b/frontend/src/components/admin/types.ts index b731dda..d6cfe51 100644 --- a/frontend/src/components/admin/types.ts +++ b/frontend/src/components/admin/types.ts @@ -13,6 +13,13 @@ export interface AdminSubject extends Subject { id: number; visible: boolean; pinned: boolean; + folderId: number | null; +} + +export interface Folder { + id: number; + name: string; + _count: { subjects: number }; } export interface ProposedSubject extends Subject { From 23158dc6c09bae7b0560c8a1adca93ab7bad9635 Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:17:51 +0200 Subject: [PATCH 05/10] feat: enhance folder management in AdminSubjectsTab with create, rename, and delete functionalities --- .../src/components/admin/AdminSubjectsTab.tsx | 337 ++++++++++++++++-- 1 file changed, 308 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/admin/AdminSubjectsTab.tsx b/frontend/src/components/admin/AdminSubjectsTab.tsx index e34b8d2..bced831 100644 --- a/frontend/src/components/admin/AdminSubjectsTab.tsx +++ b/frontend/src/components/admin/AdminSubjectsTab.tsx @@ -1,27 +1,45 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Stack, Group, Text } from "@mantine/core"; import { IconBook, IconFileText, IconLink, IconEye, IconEyeOff, IconPencil, IconPin, IconPinnedOff, + IconFolder, IconFolderPlus, IconPlus, IconTrash, IconX, + IconArrowLeft, IconChevronRight, } from "@tabler/icons-react"; import { Badge } from "../ui/Badge"; import type { Subject } from "../../config"; -import { type AdminSubject } from "./types"; +import { type AdminSubject, type Folder } from "./types"; import { EditModal } from "./EditModal"; import { API } from "../../lib/api"; -export function AdminSubjectsTab({ token }: { token: string }) { +type FolderModal = | { mode: "create" } | { mode: "rename"; folder: Folder } | null; + +export function AdminSubjectsTab({ token, onAddSubject }: { + token: string; + onAddSubject: (folderId: number | null) => void; +}) { const [subjects, setSubjects] = useState([]); + const [folders, setFolders] = useState([]); + const [currentFolderId, setCurrentFolderId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [editTarget, setEditTarget] = useState(null); + const [fabOpen, setFabOpen] = useState(false); + const [folderModal, setFolderModal] = useState(null); + const [folderName, setFolderName] = useState(""); + const [folderLoading, setFolderLoading] = useState(false); + + const authHeaders = { Authorization: `Bearer ${token}` }; useEffect(() => { - fetch(`${API}/admin/subjects`, { headers: { Authorization: `Bearer ${token}` } }) - .then(r => r.json() as Promise<{ subjects?: AdminSubject[] }>) - .then(d => setSubjects(d.subjects ?? [])) - .catch(() => setError("Impossible de charger les sujets.")) - .finally(() => setLoading(false)); + Promise.allSettled([ + fetch(`${API}/admin/subjects`, { headers: authHeaders }).then(r => r.ok ? r.json() as Promise<{ subjects?: AdminSubject[] }> : Promise.reject()), + fetch(`${API}/admin/folders`, { headers: authHeaders }).then(r => r.ok ? r.json() as Promise<{ folders?: Folder[] }> : Promise.reject()), + ]).then(([subs, fols]) => { + if (subs.status === "fulfilled") setSubjects(subs.value.subjects ?? []); + else setError("Impossible de charger les sujets."); + if (fols.status === "fulfilled") setFolders(fols.value.folders ?? []); + }).finally(() => setLoading(false)); }, [token]); const toggle = async (s: AdminSubject) => { @@ -29,7 +47,7 @@ export function AdminSubjectsTab({ token }: { token: string }) { setSubjects(prev => prev.map(x => x.id === s.id ? { ...x, visible: next } : x)); await fetch(`${API}/admin/subjects/${s.id}/visible`, { method: "PATCH", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + headers: { ...authHeaders, "Content-Type": "application/json" }, body: JSON.stringify({ visible: next }), }); }; @@ -39,12 +57,67 @@ export function AdminSubjectsTab({ token }: { token: string }) { setSubjects(prev => prev.map(x => x.id === s.id ? { ...x, pinned: next } : x)); const res = await fetch(`${API}/admin/subjects/${s.id}/pinned`, { method: "PATCH", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + headers: { ...authHeaders, "Content-Type": "application/json" }, body: JSON.stringify({ pinned: next }), }); if (!res.ok) setSubjects(prev => prev.map(x => x.id === s.id ? { ...x, pinned: s.pinned } : x)); }; + const submitFolderModal = async () => { + const name = folderName.trim(); + if (!name || !folderModal) return; + setFolderLoading(true); + try { + if (folderModal.mode === "create") { + const res = await fetch(`${API}/admin/folders`, { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (res.ok) { + const { folder } = await res.json() as { folder: Folder }; + setFolders(prev => [...prev, { ...folder, _count: { subjects: 0 } }].sort((a, b) => a.name.localeCompare(b.name))); + } + } else { + const id = folderModal.folder.id; + const res = await fetch(`${API}/admin/folders/${id}`, { + method: "PATCH", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (res.ok) + setFolders(prev => prev.map(f => f.id === id ? { ...f, name } : f).sort((a, b) => a.name.localeCompare(b.name))); + } + } finally { + setFolderLoading(false); + setFolderModal(null); + setFolderName(""); + } + }; + + const deleteFolder = async (folder: Folder) => { + if (!confirm(`Supprimer le dossier « ${folder.name} » ? Les sujets qu'il contient retourneront à la racine.`)) return; + setFolders(prev => prev.filter(f => f.id !== folder.id)); + setSubjects(prev => prev.map(s => s.folderId === folder.id ? { ...s, folderId: null } : s)); + if (currentFolderId === folder.id) setCurrentFolderId(null); + await fetch(`${API}/admin/folders/${folder.id}`, { + method: "DELETE", + headers: authHeaders, + }); + }; + + const currentFolder = folders.find(f => f.id === currentFolderId) ?? null; + const shownSubjects = useMemo( + () => subjects.filter(s => s.folderId === currentFolderId), + [subjects, currentFolderId] + ); + const folderCounts = useMemo(() => { + const counts = new Map(); + for (const s of subjects) + if (s.folderId !== null) counts.set(s.folderId, (counts.get(s.folderId) ?? 0) + 1); + return counts; + }, [subjects]); + const visible = subjects.filter(s => s.visible).length; const pinned = subjects.filter(s => s.pinned).length; @@ -54,28 +127,110 @@ export function AdminSubjectsTab({ token }: { token: string }) { return ( <> - - {[ - { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, - { label: "Épinglés", value: pinned, color: "var(--epi-intermediate)" }, - { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, - { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, - ].map(({ label, value, color }) => ( -
- {value} - {label} -
- ))} -
+ {currentFolder === null ? ( + + {[ + { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, + { label: "Épinglés", value: pinned, color: "var(--epi-intermediate)" }, + { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, + { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, + ].map(({ label, value, color }) => ( +
+ {value} + {label} +
+ ))} +
+ ) : ( + + + + + + {currentFolder.name} + + + )} + + {currentFolder === null && folders.length > 0 && ( +
+ {folders.map(f => { + const count = folderCounts.get(f.id) ?? 0; + return ( +
setCurrentFolderId(f.id)} + style={{ + display: "flex", alignItems: "center", gap: 10, + background: "var(--epi-surface)", + border: "1px solid var(--epi-border)", + borderRadius: 10, padding: "12px 14px", + cursor: "pointer", transition: "border-color 0.15s", + }} + onMouseEnter={e => { + e.currentTarget.style.borderColor = "var(--epi-accent)"; + const actions = e.currentTarget.querySelector("[data-folder-actions]"); + if (actions) actions.style.opacity = "1"; + }} + onMouseLeave={e => { + e.currentTarget.style.borderColor = "var(--epi-border)"; + const actions = e.currentTarget.querySelector("[data-folder-actions]"); + if (actions) actions.style.opacity = "0"; + }} + > + +
+ {f.name} + {count} sujet{count > 1 ? "s" : ""} +
+
+ + +
+
+ ); + })} +
+ )} - {subjects.length === 0 ? ( - Aucun sujet créé pour l'instant. + {shownSubjects.length === 0 ? ( + + {currentFolder === null + ? (subjects.length === 0 ? "Aucun sujet créé pour l'instant." : "Aucun sujet à la racine.") + : "Ce dossier est vide."} + ) : ( - {subjects.map(s => ( + {shownSubjects.map(s => (
+ {/* FAB + dropdown */} +
+ {fabOpen && ( + <> +
setFabOpen(false)} style={{ position: "fixed", inset: 0 }} /> +
+ {[ + { label: "Créer un dossier", Icon: IconFolderPlus, action: () => { setFolderName(""); setFolderModal({ mode: "create" }); } }, + { label: "Ajouter un sujet", Icon: IconBook, action: () => onAddSubject(currentFolderId) }, + ].map(({ label, Icon, action }) => ( + + ))} +
+ + )} + +
+ + {/* Modal création / renommage dossier */} + {folderModal && ( +
+
+
{ e.preventDefault(); submitFolderModal(); }}> + + + + {folderModal.mode === "create" ? "Créer un dossier" : "Renommer le dossier"} + + + + +
+ + setFolderName(e.target.value)} + placeholder="ex : Winter Camp 2026" + autoFocus + style={{ + flex: 1, background: "none", border: "none", outline: "none", + color: "#fff", fontSize: 14, fontFamily: "inherit", + }} + /> +
+ + + + + +
+
+
+
+ )} + {editTarget && ( setEditTarget(null)} onSaved={updated => { setSubjects(prev => prev.map(s => s.id === updated.id ? { ...s, ...updated } : s)); From f0ce261575e99843a249d97b5d5a97472c2cd0bf Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:18:24 +0200 Subject: [PATCH 06/10] feat: add folder selection to EditModal and include folderId in subject updates --- frontend/src/components/admin/EditModal.tsx | 33 +++++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/admin/EditModal.tsx b/frontend/src/components/admin/EditModal.tsx index 2099e6c..9d5a842 100644 --- a/frontend/src/components/admin/EditModal.tsx +++ b/frontend/src/components/admin/EditModal.tsx @@ -2,15 +2,16 @@ import { useState, useRef } from "react"; import { Stack, Group, Text } from "@mantine/core"; import { IconUpload, IconX, IconCheck, IconPlus, IconBook, - IconLink, IconFileText, + IconLink, IconFileText, IconFolder, } from "@tabler/icons-react"; import { TagInput } from "../ui/TagInput"; -import { type AdminSubject, type Difficulty, DIFF_COLORS } from "./types"; +import { type AdminSubject, type Difficulty, type Folder, DIFF_COLORS } from "./types"; import { API } from "../../lib/api"; -export function EditModal({ subject, token, onClose, onSaved }: { +export function EditModal({ subject, token, folders, onClose, onSaved }: { subject: AdminSubject; token: string; + folders: Folder[]; onClose: () => void; onSaved: (updated: AdminSubject) => void; }) { @@ -18,6 +19,7 @@ export function EditModal({ subject, token, onClose, onSaved }: { const [description, setDesc] = useState(subject.description); const [urls, setUrls] = useState(subject.urls ?? []); const [difficulty, setDifficulty] = useState(subject.difficulty as Difficulty); + const [folderId, setFolderId] = useState(subject.folderId !== null ? String(subject.folderId) : ""); const [tags, setTags] = useState(subject.tags); const [newPdfFiles, setNewPdfFiles] = useState([]); const [keepFiles, setKeepFiles] = useState(subject.files); @@ -49,6 +51,7 @@ export function EditModal({ subject, token, onClose, onSaved }: { const validUrls = urls.map(u => u.trim()).filter(Boolean); if (validUrls.length > 0) fd.append("urls", validUrls.join("\n")); if (keepFiles.length > 0) fd.append("existingFiles", keepFiles.join(",")); + fd.append("folderId", folderId); for (const f of newPdfFiles) fd.append("file", f); const res = await fetch(`${API}/admin/subjects/${subject.id}`, { @@ -153,6 +156,30 @@ export function EditModal({ subject, token, onClose, onSaved }: {
+
+ Dossier +
+ + +
+
+
Fichiers (PDF, image, Markdown (optionnel)) From 81d0263331dc690d24477532e90d29cffde3331f Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 13:32:39 +0200 Subject: [PATCH 07/10] feat: implement folder selection in SubjectForm and SubjectsTab, enhance folder management in AdminPage --- frontend/src/components/admin/SubjectForm.tsx | 47 ++++++- frontend/src/components/manta/SubjectsTab.tsx | 133 ++++++++++++++---- frontend/src/components/manta/types.ts | 7 + frontend/src/pages/AdminPage.tsx | 10 +- 4 files changed, 162 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/admin/SubjectForm.tsx b/frontend/src/components/admin/SubjectForm.tsx index 376dac5..2afa276 100644 --- a/frontend/src/components/admin/SubjectForm.tsx +++ b/frontend/src/components/admin/SubjectForm.tsx @@ -1,23 +1,35 @@ -import { useState, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import { Stack, Group, Text } from "@mantine/core"; -import { IconUpload, IconX, IconCheck, IconPlus, IconBook, IconLink } from "@tabler/icons-react"; +import { IconUpload, IconX, IconCheck, IconPlus, IconBook, IconLink, IconFolder } from "@tabler/icons-react"; import { TagInput } from "../ui/TagInput"; -import { type Difficulty, DIFF_COLORS } from "./types"; +import { type Difficulty, type Folder, DIFF_COLORS } from "./types"; import { API } from "../../lib/api"; -export function SubjectForm({ token }: { token: string }) { +export function SubjectForm({ token, defaultFolderId = null }: { + token: string; + defaultFolderId?: number | null; +}) { const [name, setName] = useState(""); const [description, setDesc] = useState(""); const [urls, setUrls] = useState([]); const [difficulty, setDifficulty] = useState("Débutant"); const [tags, setTags] = useState([]); const [pdfFiles, setPdfFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [folderId, setFolderId] = useState(defaultFolderId !== null ? String(defaultFolderId) : ""); const [dragOver, setDragOver] = useState(false); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(""); const fileInputRef = useRef(null); + useEffect(() => { + fetch(`${API}/admin/folders`, { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.ok ? r.json() as Promise<{ folders?: Folder[] }> : Promise.reject()) + .then(d => setFolders(d.folders ?? [])) + .catch(() => {}); + }, [token]); + const ALLOWED_EXTS = new Set([".pdf", ".png", ".jpg", ".jpeg", ".md"]); const addFiles = (list: FileList | null) => { if (!list) return; @@ -46,6 +58,7 @@ export function SubjectForm({ token }: { token: string }) { fd.append("tags", tags.join(",")); const validUrls = urls.map(u => u.trim()).filter(Boolean); if (validUrls.length > 0) fd.append("urls", validUrls.join("\n")); + fd.append("folderId", folderId); for (const f of pdfFiles) fd.append("file", f); const res = await fetch(`${API}/admin/subjects`, { @@ -219,6 +232,32 @@ export function SubjectForm({ token }: { token: string }) { Appuie sur Entrée ou virgule pour ajouter un tag.
+ {folders.length > 0 && ( +
+ Dossier +
+ + +
+
+ )} +
Fichiers (PDF, image, Markdown (optionnel)) diff --git a/frontend/src/components/manta/SubjectsTab.tsx b/frontend/src/components/manta/SubjectsTab.tsx index fc1d353..9a79bcb 100644 --- a/frontend/src/components/manta/SubjectsTab.tsx +++ b/frontend/src/components/manta/SubjectsTab.tsx @@ -1,24 +1,30 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Stack, Group, Text } from "@mantine/core"; import { IconEye, IconEyeOff, IconBook, IconFileText, IconLink, + IconFolder, IconArrowLeft, IconChevronRight, } from "@tabler/icons-react"; import { Badge } from "../ui/Badge"; import type { Subject } from "../../config"; -import { type SubjectWithVisible } from "./types"; +import { type SubjectWithVisible, type Folder } from "./types"; import { API } from "../../lib/api"; export function SubjectsTab({ token }: { token: string }) { const [subjects, setSubjects] = useState([]); + const [folders, setFolders] = useState([]); + const [currentFolderId, setCurrentFolderId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); useEffect(() => { - fetch(`${API}/manta/subjects`, { credentials: "include" }) - .then(r => r.json() as Promise<{ subjects?: SubjectWithVisible[] }>) - .then(d => setSubjects(d.subjects ?? [])) - .catch(() => setError("Impossible de charger les sujets.")) - .finally(() => setLoading(false)); + Promise.allSettled([ + fetch(`${API}/manta/subjects`, { credentials: "include" }).then(r => r.ok ? r.json() as Promise<{ subjects?: SubjectWithVisible[] }> : Promise.reject()), + fetch(`${API}/manta/folders`, { credentials: "include" }).then(r => r.ok ? r.json() as Promise<{ folders?: Folder[] }> : Promise.reject()), + ]).then(([subs, fols]) => { + if (subs.status === "fulfilled") setSubjects(subs.value.subjects ?? []); + else setError("Impossible de charger les sujets."); + if (fols.status === "fulfilled") setFolders(fols.value.folders ?? []); + }).finally(() => setLoading(false)); }, [token]); const toggle = async (subject: SubjectWithVisible) => { @@ -32,35 +38,104 @@ export function SubjectsTab({ token }: { token: string }) { }); }; + const currentFolder = folders.find(f => f.id === currentFolderId) ?? null; + const shownSubjects = useMemo( + () => subjects.filter(s => s.folderId === currentFolderId), + [subjects, currentFolderId] + ); + const folderCounts = useMemo(() => { + const counts = new Map(); + for (const s of subjects) + if (s.folderId !== null) counts.set(s.folderId, (counts.get(s.folderId) ?? 0) + 1); + return counts; + }, [subjects]); + const visible = subjects.filter(s => s.visible).length; + if (loading) return Chargement…; + if (error) return {error}; + return ( - - {[ - { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, - { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, - { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, - ].map(({ label, value, color }) => ( -
- {value} - {label} -
- ))} -
+ {currentFolder === null ? ( + + {[ + { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, + { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, + { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, + ].map(({ label, value, color }) => ( +
+ {value} + {label} +
+ ))} +
+ ) : ( + + + + + + {currentFolder.name} + + + )} + + {currentFolder === null && folders.length > 0 && ( +
+ {folders.map(f => { + const count = folderCounts.get(f.id) ?? 0; + return ( +
setCurrentFolderId(f.id)} + style={{ + display: "flex", alignItems: "center", gap: 10, + background: "var(--epi-surface)", + border: "1px solid var(--epi-border)", + borderRadius: 10, padding: "12px 14px", + cursor: "pointer", transition: "border-color 0.15s", + }} + onMouseEnter={e => { e.currentTarget.style.borderColor = "var(--epi-accent)"; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = "var(--epi-border)"; }} + > + +
+ {f.name} + {count} sujet{count > 1 ? "s" : ""} +
+
+ ); + })} +
+ )} - {loading ? ( - Chargement… - ) : error ? ( - {error} - ) : subjects.length === 0 ? ( - Aucun sujet créé pour l'instant. + {shownSubjects.length === 0 ? ( + + {currentFolder === null + ? (subjects.length === 0 ? "Aucun sujet créé pour l'instant." : "Aucun sujet à la racine.") + : "Ce dossier est vide."} + ) : ( - {subjects.map(s => ( + {shownSubjects.map(s => (
= { export interface SubjectWithVisible extends Subject { id: number; visible: boolean; + folderId: number | null; +} + +export interface Folder { + id: number; + name: string; + _count: { subjects: number }; } export interface EventData { diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 979a572..91dfd40 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -11,6 +11,12 @@ import type { AdminTab } from "../components/admin/types"; function AdminApp({ token, onLogout }: { token: string; onLogout: () => void }) { const [tab, setTab] = useState("subjects"); + const [defaultFolderId, setDefaultFolderId] = useState(null); + + const addSubjectInFolder = (folderId: number | null) => { + setDefaultFolderId(folderId); + setTab("add-subject"); + }; return (
@@ -31,9 +37,9 @@ function AdminApp({ token, onLogout }: { token: string; onLogout: () => void })
- {tab === "subjects" && } + {tab === "subjects" && } {tab === "suggestions" && } - {tab === "add-subject" && } + {tab === "add-subject" && } {tab === "whitelist" && }
From e9a2f80a33a7198ec7e0bef002a49d39dbbceff6 Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 14:20:08 +0200 Subject: [PATCH 08/10] feat: add setAllVisible method and endpoint to manage visibility of all subjects, enhance AdminSubjectsTab with visibility toggle --- backend/src/models/subject.ts | 3 + backend/src/routes/admin.ts | 6 ++ backend/src/services/subject.ts | 3 + .../src/components/admin/AdminSubjectsTab.tsx | 98 +++++++++++++++---- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/backend/src/models/subject.ts b/backend/src/models/subject.ts index 454866e..eb23027 100644 --- a/backend/src/models/subject.ts +++ b/backend/src/models/subject.ts @@ -23,6 +23,9 @@ export const subjectModel = { setVisible: (id: number, visible: boolean) => prisma.subject.update({ where: { id }, data: { visible } }), + setAllVisible: (visible: boolean) => + prisma.subject.updateMany({ where: { proposed: false, rejected: false }, data: { visible } }), + setPinned: (id: number, pinned: boolean) => prisma.subject.update({ where: { id }, data: { pinned } }), diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 2b71eb5..9a261b1 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -118,6 +118,12 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) file: t.Optional(t.Any()), }), }) + .patch("/subjects/visible-all", async ({ body }) => { + await subjectService.setAllVisible(body.visible); + return { success: true }; + }, { + body: t.Object({ visible: t.Boolean() }), + }) .patch("/subjects/:id/visible", async ({ params, body, set }) => { const id = Number(params.id); if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; } diff --git a/backend/src/services/subject.ts b/backend/src/services/subject.ts index 661a96c..41b193b 100644 --- a/backend/src/services/subject.ts +++ b/backend/src/services/subject.ts @@ -106,6 +106,9 @@ export const subjectService = { setVisible: (id: number, visible: boolean) => subjectModel.setVisible(id, visible), + setAllVisible: (visible: boolean) => + subjectModel.setAllVisible(visible), + setPinned: (id: number, pinned: boolean) => subjectModel.setPinned(id, pinned), diff --git a/frontend/src/components/admin/AdminSubjectsTab.tsx b/frontend/src/components/admin/AdminSubjectsTab.tsx index bced831..58ad2f1 100644 --- a/frontend/src/components/admin/AdminSubjectsTab.tsx +++ b/frontend/src/components/admin/AdminSubjectsTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { Stack, Group, Text } from "@mantine/core"; import { IconBook, IconFileText, IconLink, @@ -28,6 +28,14 @@ export function AdminSubjectsTab({ token, onAddSubject }: { const [folderModal, setFolderModal] = useState(null); const [folderName, setFolderName] = useState(""); const [folderLoading, setFolderLoading] = useState(false); + const [notice, setNotice] = useState(""); + const noticeTimer = useRef | null>(null); + + const showNotice = (message: string) => { + if (noticeTimer.current) clearTimeout(noticeTimer.current); + setNotice(message); + noticeTimer.current = setTimeout(() => setNotice(""), 4000); + }; const authHeaders = { Authorization: `Bearer ${token}` }; @@ -106,6 +114,17 @@ export function AdminSubjectsTab({ token, onAddSubject }: { }); }; + const makeAllVisible = async () => { + const before = subjects; + setSubjects(prev => prev.map(s => ({ ...s, visible: true }))); + const res = await fetch(`${API}/admin/subjects/visible-all`, { + method: "PATCH", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ visible: true }), + }); + if (!res.ok) setSubjects(before); + }; + const currentFolder = folders.find(f => f.id === currentFolderId) ?? null; const shownSubjects = useMemo( () => subjects.filter(s => s.folderId === currentFolderId), @@ -128,22 +147,40 @@ export function AdminSubjectsTab({ token, onAddSubject }: { <> {currentFolder === null ? ( - - {[ - { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, - { label: "Épinglés", value: pinned, color: "var(--epi-intermediate)" }, - { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, - { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, - ].map(({ label, value, color }) => ( -
- {value} - {label} -
- ))} -
+ + + {[ + { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, + { label: "Épinglés", value: pinned, color: "var(--epi-intermediate)" }, + { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, + { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, + ].map(({ label, value, color }) => ( +
+ {value} + {label} +
+ ))} +
+ {visible < subjects.length && ( + + + + )} +
) : ( - - )} -
+ + {[ + { label: "Visibles", value: visible, color: "var(--epi-beginner)" }, + { label: "Épinglés", value: pinned, color: "var(--epi-intermediate)" }, + { label: "Masqués", value: subjects.length - visible, color: "var(--epi-border)" }, + { label: "Total", value: subjects.length, color: "var(--epi-accent)" }, + ].map(({ label, value, color }) => ( +
+ {value} + {label} +
+ ))} +
) : ( + + ); + })()} + {shownSubjects.length === 0 ? ( {currentFolder === null From abc837e384d39affaa80e8b77cc4c5c5ce3e30fe Mon Sep 17 00:00:00 2001 From: moustillon Date: Sat, 4 Jul 2026 14:43:18 +0200 Subject: [PATCH 10/10] feat: implement subject deletion functionality in AdminSubjectsTab, including file cleanup --- backend/src/models/subject.ts | 3 ++ backend/src/routes/admin.ts | 6 ++++ backend/src/services/subject.ts | 14 ++++++++- .../src/components/admin/AdminSubjectsTab.tsx | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/src/models/subject.ts b/backend/src/models/subject.ts index b959560..b52c59d 100644 --- a/backend/src/models/subject.ts +++ b/backend/src/models/subject.ts @@ -43,4 +43,7 @@ export const subjectModel = { deleteById: (id: number) => prisma.subject.delete({ where: { id } }), + + countByFile: (filename: string) => + prisma.subject.count({ where: { files: { has: filename } } }), }; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 2963709..8811ae5 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -143,6 +143,12 @@ export const adminRoutes = new Elysia({ prefix: "/admin" }) }, { body: t.Object({ pinned: t.Boolean() }), }) + .delete("/subjects/:id", async ({ params, set }) => { + const id = Number(params.id); + if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; } + await subjectService.delete(id); + return { success: true }; + }) .post("/subjects/:id/approve", async ({ params, set }) => { const id = Number(params.id); if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; } diff --git a/backend/src/services/subject.ts b/backend/src/services/subject.ts index cd9a764..f06e812 100644 --- a/backend/src/services/subject.ts +++ b/backend/src/services/subject.ts @@ -1,7 +1,7 @@ import type { Difficulty } from "@prisma/client"; import { subjectModel } from "../models/subject"; import type { SubjectServiceInput, SubjectProposeInput } from "../types/subject"; -import { writeFile } from "fs/promises"; +import { writeFile, unlink } from "fs/promises"; import { join } from "path"; const DIFFICULTY_MAP: Record = { @@ -116,4 +116,16 @@ export const subjectService = { const rows = await subjectModel.findPinned(); return rows.map(s => ({ ...s, difficulty: DIFFICULTY_LABEL[s.difficulty] })); }, + + delete: async (id: number) => { + const subject = await subjectModel.findById(id); + if (!subject) throw new Error("Subject not found"); + await subjectModel.deleteById(id); + // supprime les fichiers uploadés qu'aucun autre sujet ne référence + for (const file of subject.files) { + const stillUsed = await subjectModel.countByFile(file); + if (stillUsed === 0) + await unlink(join(UPLOADS_DIR, file)).catch(() => {}); + } + }, }; diff --git a/frontend/src/components/admin/AdminSubjectsTab.tsx b/frontend/src/components/admin/AdminSubjectsTab.tsx index d26adb4..21c1c30 100644 --- a/frontend/src/components/admin/AdminSubjectsTab.tsx +++ b/frontend/src/components/admin/AdminSubjectsTab.tsx @@ -116,6 +116,17 @@ export function AdminSubjectsTab({ token, onAddSubject }: { const currentFolder = folders.find(f => f.id === currentFolderId) ?? null; + const deleteSubject = async (s: AdminSubject) => { + if (!confirm(`Supprimer définitivement « ${s.name} » ? Cette action est irréversible.`)) return; + const before = subjects; + setSubjects(prev => prev.filter(x => x.id !== s.id)); + const res = await fetch(`${API}/admin/subjects/${s.id}`, { + method: "DELETE", + headers: authHeaders, + }); + if (!res.ok) setSubjects(before); + }; + const toggleAllVisible = async () => { const target = !subjects.filter(s => s.folderId === currentFolderId).every(s => s.visible); const before = subjects; @@ -357,6 +368,26 @@ export function AdminSubjectsTab({ token, onAddSubject }: { > {s.visible ? <> Visible : <> Masqué} +
))}