diff --git a/backend/server.ts b/backend/server.ts index f6b425b..d691fef 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -62,7 +62,7 @@ async function startServer() { app.use('/api/discord', authenticateUser, discordRoutes); app.use('/api/tent', authenticateUser, tentRoutes); app.use('/api/bus', authenticateUser, busRoutes); - app.use("/api/uploads/imgnews", express.static(path.join(__dirname, "/uploads/imgnews"))); + app.use("/api/uploads/news", express.static(path.join(__dirname, "/uploads/news"))); app.use("/api/uploads/foodmenu", express.static(path.join(__dirname, "/uploads/foodmenu"))); app.use("/api/uploads/plannings", express.static(path.join(__dirname, "/uploads/plannings"))); app.use("/api/exports/bus", express.static(path.join(__dirname, "/exports/bus"))); diff --git a/backend/src/controllers/im_export.controller.ts b/backend/src/controllers/im_export.controller.ts index 3e5978f..60ae264 100644 --- a/backend/src/controllers/im_export.controller.ts +++ b/backend/src/controllers/im_export.controller.ts @@ -8,6 +8,7 @@ import * as team_service from "../services/team.service"; import * as user_service from '../services/user.service'; import { Error, Ok } from "../utils/responses"; import { spreadsheet_id } from "../utils/secret"; +import { getLatestUploadedDocument, isSafeUploadSegment, removeUploadedDocuments, toUploadedDocumentStatus } from "../utils/uploadDocuments"; export const exportAllDataToSheets = async (req: Request, res: Response) => { try { @@ -108,6 +109,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => { const file = req.file; try { + // Supprimer l'ancien Menu si un nouveau est uploadé if (file) { const targetDir = path.join(__dirname, "../../foodmenu"); @@ -116,6 +118,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => { fs.rmSync(targetDir, { recursive: true, force: true }); fs.mkdirSync(targetDir); } + } Ok(res, { msg: "Menu mis à jour avec succès" }); @@ -127,19 +130,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => { }; export const updatePlannings = async (req: Request, res: Response) => { - const file = req.file; - try { - // Supprimer l'ancien Planning si un nouveau est uploadé - if (file) { - const targetDir = path.join(__dirname, "../../plannings"); - - if (fs.existsSync(targetDir)) { - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.mkdirSync(targetDir); - } - } - Ok(res, { msg: "Planning mis à jour avec succès" }); return; } catch (err) { @@ -157,3 +148,56 @@ export const exportUsersCSV = async (req: Request, res: Response) => { Error(res, { msg: "Erreur lors de l'export CSV" }); } }; + +export const getUploadedDocumentStatus = async (req: Request, res: Response) => { + const { category, item } = req.params; + + if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) { + Error(res, { msg: "Paramètres invalides" }); + return; + } + + try { + const latestDocument = await getLatestUploadedDocument(category, item); + + Ok(res, { + data: toUploadedDocumentStatus(category, latestDocument), + }); + } catch (err: any) { + if (err?.code === "ENOENT") { + Ok(res, { + data: toUploadedDocumentStatus(category, null), + }); + return; + } + + console.error(err); + Error(res, { msg: "Erreur lors de la vérification du document" }); + } +}; + +export const deleteDocument = async (req: Request, res: Response) => { + const { category, item } = req.params; + + if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) { + Error(res, { msg: "Paramètres invalides" }); + return; + } + + try { + const deletedCount = await removeUploadedDocuments(category, item); + + if (deletedCount === 0) { + return Ok(res, { msg: "Aucun document à supprimer" }); + } + + Ok(res, {}); + } catch (err: any) { + if (err?.code === "ENOENT") { + Ok(res, {}); + return; + } + + Error(res, { msg: "Erreur lors de la vérification du document" }); + } +}; diff --git a/backend/src/controllers/news.controller.ts b/backend/src/controllers/news.controller.ts index a910932..0b97ac2 100644 --- a/backend/src/controllers/news.controller.ts +++ b/backend/src/controllers/news.controller.ts @@ -7,19 +7,72 @@ import * as user_service from '../services/user.service'; import * as template from "../utils/emailtemplates"; import { Error, Ok } from "../utils/responses"; +const toStoredUploadPath = (imageUrl: string) => { + if (!imageUrl) { + return null; + } + + let normalized = imageUrl.trim(); + if (!normalized) { + return null; + } + + // Accept absolute URLs and keep only the pathname part. + if (normalized.startsWith("http://") || normalized.startsWith("https://")) { + try { + normalized = new URL(normalized).pathname; + } catch { + return null; + } + } + + if (normalized.startsWith("/api/")) { + normalized = normalized.slice(4); + } + + if (!normalized.startsWith("/uploads/")) { + return null; + } + + return normalized; +}; + +const resolveStoredImagePath = (imageUrl: string) => { + const storedPath = toStoredUploadPath(imageUrl); + if (!storedPath) { + return null; + } + + return path.resolve(process.cwd(), storedPath.replace(/^\//, "")); +}; + +const deleteImageIfExists = (imageUrl: string) => { + const imagePath = resolveStoredImagePath(imageUrl); + if (!imagePath) { + return; + } + + if (fs.existsSync(imagePath)) { + fs.unlinkSync(imagePath); + } +}; + export const createNews = async (req: Request, res: Response) => { - const { title, description, type, published, target } = req.body; + const { title, description, type, published, target, image_url } = req.body; const file = req.file; try { - const image_url = file ? `/api/uploads/imgnews/${file.filename}` : undefined; + const resolvedImageUrl = file + ? `/uploads/news/${file.filename}` + : image_url; + const news = await news_service.createNews( title, description, type, - published, + published === true || published === "true", target, - image_url); + resolvedImageUrl); Ok(res, { msg: "Actu créée avec succès", data: news }); } catch (err) { console.error(err); @@ -103,41 +156,53 @@ export const deleteNews = async (req: Request, res: Response) => { try { const existing = await news_service.getNewsById(Number(newsId)); if (existing?.image_url) { - const imagePath = path.join(__dirname, "../../", existing.image_url); - if (fs.existsSync(imagePath)) { - fs.unlinkSync(imagePath); - } + deleteImageIfExists(existing.image_url); } + await news_service.deleteNews(Number(newsId)); Ok(res, { msg: "Actus supprimée avec succès !" }); - ; - } catch { + } catch (err) { + console.error(err); Error(res, { msg: "Erreur lors de la suppression de l'actus" }); } }; export const updateNews = async (req: Request, res: Response) => { - const { id, title, description, type, target } = req.body; + const { id, title, description, type, target, image_url } = req.body; const file = req.file; - const image_url = file ? `/api/uploads/imgnews/${file.filename}` : undefined; + const hasImageUrlField = Object.prototype.hasOwnProperty.call(req.body, "image_url"); + const resolvedImageUrl = file + ? `/uploads/news/${file.filename}` + : hasImageUrlField + ? (image_url ?? null) + : undefined; try { const existing = await news_service.getNewsById(Number(id)); if (!existing) { Error(res, { msg: "Actu introuvable" }); + return; } - // Supprimer l'ancienne image si une nouvelle est uploadée - if (file && existing.image_url) { - const oldPath = path.join(__dirname, "../../", existing.image_url); - if (fs.existsSync(oldPath)) { - fs.unlinkSync(oldPath); - } + const shouldReplaceImage = typeof resolvedImageUrl === "string"; + const shouldRemoveImage = resolvedImageUrl === null; + + // Supprimer l'ancienne image si elle est remplacée ou explicitement supprimée. + if (existing.image_url && ((shouldReplaceImage && existing.image_url !== resolvedImageUrl) || shouldRemoveImage)) { + deleteImageIfExists(existing.image_url); } - const updates: any = { title, description, type, target }; - if (image_url) updates.image_url = image_url; + const updates: { + title: string; + description: string; + type: string; + target: string; + image_url?: string | null; + } = { title, description, type, target }; + if (resolvedImageUrl !== undefined) { + updates.image_url = resolvedImageUrl; + } const updated = await news_service.updateNews(Number(id), updates); diff --git a/backend/src/middlewares/multer.middleware.ts b/backend/src/middlewares/multer.middleware.ts index 1ce4a37..6f4baac 100644 --- a/backend/src/middlewares/multer.middleware.ts +++ b/backend/src/middlewares/multer.middleware.ts @@ -3,13 +3,29 @@ import fs from "fs/promises"; import multer from "multer"; import path from "path"; import { Error } from "../utils/responses"; +import { isSafeUploadSegment, removeUploadedDocuments } from "../utils/uploadDocuments"; -export const createUploadMiddleware = ( - relativeUploadDir: string, - modifiedName: boolean = true -) => { - const uploadPath = path.resolve(process.cwd(), relativeUploadDir); +export enum MIMEType { + PDF = "application/pdf", + PNG = "image/png", + JPEG = "image/jpeg", +} +const acceptedMIMETypesByItem: Record> = { + foodmenu: { + menu: [MIMEType.PDF], + }, + news: {}, + plannings: { + tc: [MIMEType.PDF], + bacheloria: [MIMEType.PDF], + fise: [MIMEType.PDF], + fisea: [MIMEType.PDF], + master: [MIMEType.PDF], + }, +}; + +export const createUploadMiddleware = () => { // On stocke d'abord en mémoire pour vérifier le type réel const storage = multer.memoryStorage(); @@ -24,46 +40,73 @@ export const createUploadMiddleware = ( const verifyAndSave = async ( req: Request, res: Response, - next: NextFunction + next: NextFunction, ) => { try { if (!req.file) return Error(res, { msg: "Aucun fichier reçu" }); + + const category = req.params.category; + const item = req.params.item; + + if (!category || !item) { + return Error(res, { msg: "Catégorie ou item manquant" }); + } + + if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) { + return Error(res, { msg: "Paramètres invalides" }); + } + const { originalname, buffer } = req.file; // Vérif du vrai type const { fileTypeFromBuffer } = await import("file-type"); const detected = await fileTypeFromBuffer(buffer); - console.log( - `[UPLOAD] Type détecté: ${detected?.mime || "inconnu"}` - ); - const isImage = detected?.mime?.startsWith("image/"); - const isPDF = detected?.mime === "application/pdf"; + const detectedMime = detected?.mime as MIMEType | undefined; + + const acceptedTypes = category === "news" ? [MIMEType.PNG, MIMEType.JPEG] : acceptedMIMETypesByItem[category]?.[item]; + - if (!isImage && !isPDF) { - Error(res, { msg: "Seules les images et les PDF sont autorisés" }); + + if (!acceptedTypes) { + return Error(res, { msg: "Catégorie ou item inconnu" }); + } + + const isAccepted = !!detectedMime && acceptedTypes.includes(detectedMime); + + if (!isAccepted) { + Error(res, { msg: "Type de fichier non autorisé" }); return; } + const uploadPath = path.resolve(process.cwd(), "uploads", category); + // Création dossier si nécessaire await fs.mkdir(uploadPath, { recursive: true }); + if (category === "news") { + if (!/^\d+$/.test(item)) { + return Error(res, { msg: "Le nom du fichier doit être composé uniquement de chiffres." }); + } + + const currentEntries = await fs.readdir(uploadPath); + if (currentEntries.length >= 100) { + return Error(res, { msg: "Le nombre maximal d'images d'actualités a été atteind." }); + } + } + + // Supprime tout document existant avec le même basename, quelle que soit l'extension. + await removeUploadedDocuments(category, item); + const ext = path.extname(originalname); - const baseName = path.basename(originalname, ext); - const timestamp = Date.now(); - const finalName = modifiedName - ? `${baseName}-${timestamp}${ext}` - : originalname; + const finalName = `${item}${ext}`; const finalPath = path.join(uploadPath, finalName); // Sauvegarde du fichier sur disque await fs.writeFile(finalPath, buffer); - // On rajoute le chemin pour les middlewares suivants - (req as any).savedFilePath = finalPath; - next(); } catch (err) { next(err); diff --git a/backend/src/routes/im_export.routes.ts b/backend/src/routes/im_export.routes.ts index 8d73cbe..38077cd 100644 --- a/backend/src/routes/im_export.routes.ts +++ b/backend/src/routes/im_export.routes.ts @@ -3,13 +3,18 @@ import * as imexportController from '../controllers/im_export.controller'; import { createUploadMiddleware } from "../middlewares/multer.middleware"; import { checkRole } from '../middlewares/user.middleware'; -const uploadFoodMenu = createUploadMiddleware("uploads/foodmenu/", false); -const uploadPlannings = createUploadMiddleware("uploads/plannings/", false); +const uploadMiddleware = createUploadMiddleware(); const imexportRouter = express.Router(); -imexportRouter.post('/admin/foodimport', checkRole("Admin", []), uploadFoodMenu.multerUpload.single("foodFile"), uploadFoodMenu.verifyAndSave, imexportController.updateFoodMenu); -imexportRouter.post('/admin/plannings', checkRole("Admin", []), uploadPlannings.multerUpload.single("planningFile"), uploadPlannings.verifyAndSave, imexportController.updatePlannings); +imexportRouter.post( + '/admin/import/:category/:item', checkRole("Admin", []), + uploadMiddleware.multerUpload.single("file"), + uploadMiddleware.verifyAndSave, + imexportController.updateFoodMenu); + imexportRouter.post('/admin/exportgsheet', checkRole("Admin", []), imexportController.exportAllDataToSheets); imexportRouter.get('/admin/exportbus', checkRole("Admin", []), imexportController.exportUsersCSV); +imexportRouter.get('/admin/document/:category/:item', checkRole("Admin", []), imexportController.getUploadedDocumentStatus); +imexportRouter.delete('/admin/document/:category/:item', checkRole("Admin", []), imexportController.deleteDocument); export default imexportRouter; diff --git a/backend/src/routes/news.routes.ts b/backend/src/routes/news.routes.ts index cc21fe2..7b74e4b 100644 --- a/backend/src/routes/news.routes.ts +++ b/backend/src/routes/news.routes.ts @@ -1,14 +1,12 @@ import express from "express"; import * as newsController from "../controllers/news.controller"; -import { createUploadMiddleware } from "../middlewares/multer.middleware"; import { checkRole } from "../middlewares/user.middleware"; -const uploadImgNews = createUploadMiddleware("uploads/imgnews/", true); const newsRouter = express.Router(); //Admin routes -newsRouter.post("/admin/createnews", checkRole("Admin", ["Communication"]), uploadImgNews.multerUpload.single("file"), uploadImgNews.verifyAndSave, newsController.createNews); -newsRouter.post("/admin/updatenews", checkRole("Admin", ["Communication"]), uploadImgNews.multerUpload.single("file"), uploadImgNews.verifyAndSave, newsController.updateNews); +newsRouter.post("/admin/createnews", checkRole("Admin", ["Communication"]), newsController.createNews); +newsRouter.post("/admin/updatenews", checkRole("Admin", ["Communication"]), newsController.updateNews); newsRouter.get("/admin/all", checkRole("Admin", ["Communication"]), newsController.listAllNews); newsRouter.post("/admin/publish", checkRole("Admin", ["Communication"]), newsController.publishNews); newsRouter.delete("/admin/deletenews", checkRole("Admin", ["Communication"]), newsController.deleteNews); diff --git a/backend/src/utils/responses.ts b/backend/src/utils/responses.ts index 1e6931b..acc1ff8 100644 --- a/backend/src/utils/responses.ts +++ b/backend/src/utils/responses.ts @@ -28,6 +28,12 @@ export const Unauthorized = (res: Response, details: { data?: any, msg?: string res.status(Code.UNAUTHORIZED).json(new HttpResponse(Code.UNAUTHORIZED, msg, details.data)); }; +export const Forbidden = (res: Response, details: { data?: any, msg?: string }) => { + const msg = details.msg || 'Forbidden'; + res.status(Code.FORBIDDEN).json(new HttpResponse(Code.FORBIDDEN, msg, details.data)); +}; + + export const Conflict = (res: Response, details: { data?: any, msg?: string }) => { const msg = details.msg || "Conflict"; res.status(Code.CONFLICT).json(new HttpResponse(Code.CONFLICT, msg, details.data)); @@ -40,11 +46,12 @@ export const Teapot = (res: Response, details: { data?: any, msg?: string }) => export enum Code { OK = 200, + CREATED = 201, ACCEPTED = 202, - NOT_FOUND = 404, BAD_REQUEST = 400, UNAUTHORIZED = 401, - CREATED = 201, + FORBIDDEN = 403, + NOT_FOUND = 404, CONFLICT = 409, IM_A_TEAPOT = 418, ISE = 500 @@ -76,6 +83,8 @@ export class HttpResponse { return 'INTERNAL_SERVER_ERROR'; case Code.UNAUTHORIZED: return 'UNAUTHORIZED'; + case Code.FORBIDDEN: + return 'FORBIDDEN'; case Code.CONFLICT: return 'CONFLICT'; case Code.IM_A_TEAPOT: diff --git a/backend/src/utils/uploadDocuments.ts b/backend/src/utils/uploadDocuments.ts new file mode 100644 index 0000000..91d61dd --- /dev/null +++ b/backend/src/utils/uploadDocuments.ts @@ -0,0 +1,84 @@ +import fs from "fs/promises"; +import path from "path"; + +export type UploadedDocument = { + name: string; + fullPath: string; + extension: string | null; + mtimeMs: number; +}; + +export type UploadedDocumentStatus = { + exists: boolean; + extension: string | null; + fileName: string | null; + relativePath: string | null; +}; + +export const isSafeUploadSegment = (value: string) => /^[a-zA-Z0-9_-]+$/.test(value); + +export const getUploadDirectory = (category: string) => path.resolve(process.cwd(), "uploads", category); + +export const findUploadedDocuments = async (category: string, item: string): Promise => { + const uploadsDir = getUploadDirectory(category); + + const entries = await fs.readdir(uploadsDir); + const candidates = entries.filter((name) => path.parse(name).name === item); + + return Promise.all( + candidates.map(async (name) => { + const fullPath = path.join(uploadsDir, name); + const stats = await fs.stat(fullPath); + + return { + name, + fullPath, + extension: path.extname(name).replace(/^\./, "").toLowerCase() || null, + mtimeMs: stats.mtimeMs, + }; + }) + ); +}; + +export const getLatestUploadedDocument = async ( + category: string, + item: string, +): Promise => { + const documents = await findUploadedDocuments(category, item); + + if (documents.length === 0) { + return null; + } + + documents.sort((a, b) => b.mtimeMs - a.mtimeMs); + return documents[0]; +}; + +export const removeUploadedDocuments = async (category: string, item: string): Promise => { + const documents = await findUploadedDocuments(category, item); + + await Promise.all(documents.map((document) => fs.unlink(document.fullPath))); + + return documents.length; +}; + +export const toUploadedDocumentStatus = ( + category: string, + document: UploadedDocument | null, +): UploadedDocumentStatus => { + if (!document) { + return { + exists: false, + extension: null, + fileName: null, + relativePath: null, + }; + } + + return { + exists: true, + extension: document.extension, + fileName: document.name, + relativePath: `/uploads/${category}/${document.name}`, + }; +}; diff --git a/frontend/src/components/Admin/adminExportImport.tsx b/frontend/src/components/Admin/adminExportImport.tsx index 883b8ab..009086b 100644 --- a/frontend/src/components/Admin/adminExportImport.tsx +++ b/frontend/src/components/Admin/adminExportImport.tsx @@ -1,9 +1,10 @@ -import { FileText } from "lucide-react"; -import { type ChangeEvent, useState } from "react"; +import { useState } from "react"; -import { exportBus, exportDb, importFoodMenu, importPlannings } from "../../services/requests/im_export.service"; +import { exportBus, exportDb } from "../../services/requests/im_export.service"; import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { AdminFileImport } from "./adminFileImport"; + export const AdminExportConnect = () => { const [loading, setLoading] = useState<{ db: boolean; bus: boolean }>({ @@ -101,106 +102,16 @@ export const AdminExportConnect = () => { export const AdminImportFoodMenu = () => { - const [menu, setMenu] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [message, setMessage] = useState(""); - - const handleFileChange = (e: ChangeEvent) => { - setError(null); - setMessage(""); - if (e.target.files && e.target.files.length > 0) { - const selected = e.target.files[0]; - if (selected.type !== "application/pdf") { - setError("Seuls les fichiers PDF sont autorisés"); - setMenu(null); - } else { - setMenu(selected); - } - } - }; - - const handleImport = async () => { - if (!menu) { - setError("Veuillez sélectionner un fichier PDF avant d'importer."); - return; - } - - setLoading(true); - setError(null); - setMessage(""); - - try { - const formData = new FormData(); - formData.append("foodFile", menu); - - const response = await importFoodMenu(formData); - setMessage(response.message || "Importation réussie !"); - } catch (err) { - console.error("Erreur lors de l'importation du menu", err); - setError("Une erreur est survenue pendant l'importation."); - } finally { - setLoading(false); - } - }; return ( - Importer le menu au format PDF + Importer le menu - - {/* Rappel des règles de nommage */} -

- ⚠️ Le fichier doit être nommé FoodMenu.pdf
-

- -
- {/* Input fichier masqué */} - - - - - {/* Affiche le nom du fichier sélectionné */} - {menu && ( -
- - {menu.name} -
- )} - - {/* Bouton importer */} - -
- - {error && ( -

{error}

- )} - {message && ( -

- {message} -

- )} +
); @@ -208,154 +119,19 @@ export const AdminImportFoodMenu = () => { export const AdminImportPlannings = () => { - const [planning, setPlanning] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [message, setMessage] = useState(""); - const [selectedPlanning, setSelectedPlanning] = useState(""); - - const handleFileChange = (e: ChangeEvent) => { - setError(null); - setMessage(""); - if (e.target.files && e.target.files.length > 0) { - const selected = e.target.files[0]; - if (selected.type !== "application/pdf") { - setError("Seuls les fichiers PDF sont autorisés"); - setPlanning(null); - } else { - setPlanning(selected); - } - } - }; - - const handleImport = async (planningName: string) => { - if (!planning) { - setError("Veuillez sélectionner un fichier PDF avant d'importer."); - return; - } - - setLoading(true); - setError(null); - setMessage(""); - setSelectedPlanning(planningName); - - try { - const formData = new FormData(); - formData.append("planningFile", planning); - - const response = await importPlannings(formData); - setMessage(response.message || `Importation réussie pour ${planningName} !`); - } catch (err) { - console.error("Erreur lors de l'importation du planning", err); - setError("Une erreur est survenue pendant l'importation."); - } finally { - setLoading(false); - } - }; - return ( - Importer les plannings au format PDF + Importer les plannings - - - {/* Rappel des règles de nommage */} -

- ⚠️ Le fichier doit être nommé en minuscules, sans accents, au format
- filiere.pdf (ex: tc.pdf, bachelor.pdf) -

- -
- {/* Input fichier masqué */} - - - - - {/* Affiche le nom du fichier sélectionné */} - {planning && ( -
- - {planning.name} -
- )} - - {/* Différents boutons d'import */} -
- - - - - - - - - -
-
- - {/* Messages */} - {error && ( -

{error}

- )} - {message && ( -

- {message} -

- )} + + + + + +
); diff --git a/frontend/src/components/Admin/adminFileImport.tsx b/frontend/src/components/Admin/adminFileImport.tsx new file mode 100644 index 0000000..8274db5 --- /dev/null +++ b/frontend/src/components/Admin/adminFileImport.tsx @@ -0,0 +1,331 @@ +import { ExternalLink, FilePenLine, FilePlus, FileText, Trash } from "lucide-react"; +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; +import Swal from "sweetalert2"; + +import { MIMEType } from "../../interfaces/import.interface"; +import { checkIfExistingDocument, deleteFile, importFile } from "../../services/requests/im_export.service"; +import { Card, CardContent } from "../ui/card"; + +type AdminFileImportProps = { + category: string; + item?: string; + acceptedTypes?: MIMEType[]; + title: string; + draft?: boolean; + draftInitialUrl?: string | null; + onDraftFileChange?: (file: File | null) => void; + onDraftDelete?: () => void; + onDraftSubmitReady?: (submit: (itemOverride?: string) => Promise) => void; +}; + +type ExistingFile = { + exists: boolean; + extension: string | null; + fileName: string | null; + relativePath: string | null; +}; + +export const AdminFileImport = ( + { + category, + item, + acceptedTypes = [MIMEType.PDF], + title, + draft = false, + draftInitialUrl, + onDraftFileChange, + onDraftDelete, + onDraftSubmitReady, + }: AdminFileImportProps, +) => { + const [existingFile, setExistingFile] = useState(null); + const [fileURL, setFileURL] = useState(null); + const [draftFile, setDraftFile] = useState(null); + const [draftPreviewURL, setDraftPreviewURL] = useState(null); + + const resolvePublicFileUrl = useCallback((url: string) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + + const baseUrl = import.meta.env.VITE_API_URL as string; + if (url.startsWith("/api/") && baseUrl.endsWith("/api")) { + return `${baseUrl}${url.slice(4)}`; + } + + return `${baseUrl}${url}`; + }, []); + + const getExtensionFromPath = useCallback((url: string) => { + const cleanUrl = url.split("?")[0].split("#")[0]; + const fileName = cleanUrl.split("/").pop() ?? ""; + if (!fileName.includes(".")) { + return null; + } + + return fileName.split(".").pop()?.toLowerCase() ?? null; + }, []); + + const checkFileStatus = useCallback(async () => { + if (draft || !item) { + return; + } + + try { + const status = await checkIfExistingDocument(category, item); + setExistingFile(status); + + if (status.exists && status.relativePath) { + setFileURL(resolvePublicFileUrl(status.relativePath)); + } else { + setFileURL(null); + } + } catch { + setExistingFile(null); + setFileURL(null); + } + }, [category, draft, item, resolvePublicFileUrl]); + + useEffect(() => { + if (!draft || draftFile || draftPreviewURL) { + return; + } + + if (!draftInitialUrl) { + setExistingFile(null); + setFileURL(null); + return; + } + + const resolvedUrl = resolvePublicFileUrl(draftInitialUrl); + const extension = getExtensionFromPath(draftInitialUrl); + const fileName = draftInitialUrl.split("?")[0].split("#")[0].split("/").pop() ?? null; + + setExistingFile({ + exists: true, + extension, + fileName, + relativePath: null, + }); + setFileURL(resolvedUrl); + }, [draft, draftFile, draftInitialUrl, draftPreviewURL, getExtensionFromPath, resolvePublicFileUrl]); + + useEffect(() => { + if (!draft) { + void checkFileStatus(); + } + }, [checkFileStatus, draft]); + + const submitDraftUpload = useCallback(async (itemOverride?: string): Promise => { + if (!draft || !draftFile) { + return null; + } + + const targetItem = itemOverride ?? item; + + if (!targetItem) { + throw new Error("L'item est requis pour la soumission du brouillon."); + } + + const formData = new FormData(); + formData.append("file", draftFile); + + await importFile(formData, category, targetItem); + + const status = await checkIfExistingDocument(category, targetItem); + setExistingFile(status); + if (status.exists && status.relativePath) { + setFileURL(resolvePublicFileUrl(status.relativePath)); + } + + setDraftFile(null); + if (draftPreviewURL) { + URL.revokeObjectURL(draftPreviewURL); + setDraftPreviewURL(null); + } + onDraftFileChange?.(null); + + return status.relativePath ?? null; + }, [category, draft, draftFile, draftPreviewURL, item, onDraftFileChange, resolvePublicFileUrl]); + + useEffect(() => { + if (draft && onDraftSubmitReady) { + onDraftSubmitReady(submitDraftUpload); + } + }, [draft, onDraftSubmitReady, submitDraftUpload]); + + const inputId = `${category}-${item ?? "draft"}-fileInput`; + + const handleFileChange = async (e: ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) { + Swal.fire({ + icon: "error", + title: "Aucun fichier sélectionné" + }); + return; + } + + const selected = e.target.files[0]; + if (!selected || !acceptedTypes.includes(selected.type as MIMEType)) { + Swal.fire({ + icon: "error", + title: "Format incompatible", + text: "Le fichier sélectionné n'est pas autorisé pour ce champ." + }); + return; + } + + if (draft) { + const extension = selected.name.includes(".") + ? selected.name.split(".").pop()?.toLowerCase() ?? null + : null; + + if (draftPreviewURL) { + URL.revokeObjectURL(draftPreviewURL); + } + const localPreviewURL = URL.createObjectURL(selected); + + setDraftFile(selected); + setExistingFile({ + exists: true, + extension, + fileName: selected.name, + relativePath: null, + }); + setDraftPreviewURL(localPreviewURL); + setFileURL(localPreviewURL); + onDraftFileChange?.(selected); + return; + } + + if (!item) { + Swal.fire({ + icon: "error", + title: "Configuration invalide", + text: "L'item est requis pour l'upload direct.", + }); + return; + } + + try { + const formData = new FormData(); + formData.append("file", selected); + + const response = await importFile(formData, category, item); + await checkFileStatus(); + await Swal.fire("✅ Import réussi", response.message, "success"); + } catch (err) { + console.error("Erreur lors de l'importation du menu", err); + Swal.fire({ + icon: "error", + title: "Erreur", + text: "Erreur lors de l'importation du menu.", + }); + } + }; + + const handleDelete = async () => { + if (draft) { + if (draftPreviewURL) { + URL.revokeObjectURL(draftPreviewURL); + } + setDraftFile(null); + setDraftPreviewURL(null); + setExistingFile(null); + setFileURL(null); + onDraftFileChange?.(null); + onDraftDelete?.(); + return; + } + + if (!item) { + return; + } + + const confirm = await Swal.fire({ + title: `Supprimer le document ${title} ?`, + text: "Cette action est irreversible.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#2563eb", + cancelButtonColor: "#d33", + confirmButtonText: "🚍 Oui", + cancelButtonText: "Annuler", + }); + + if (confirm.isConfirmed) { + try { + const response = await deleteFile(category, item); + await checkFileStatus(); + await Swal.fire("✅ Suppression réussie", response.message, "success"); + } catch (err) { + console.error("Erreur lors de la suppression du document", err); + Swal.fire({ + icon: "error", + title: "Erreur", + text: "Erreur lors de la suppression du document.", + }); + } + } + }; + + return ( + + +
+
+

{title}

+ +
+ {existingFile?.exists ? ( + <> + +

.{existingFile?.extension}

+ + ) : ( + + )} +
+
+
+ `${ext}`).join(",")} + onChange={handleFileChange} + className="hidden" + /> + + + + {(existingFile?.exists || !!draftPreviewURL) && ( + <> + {(draftPreviewURL || fileURL) && ( + + + + )} + + + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/Admin/adminNews.tsx b/frontend/src/components/Admin/adminNews.tsx index 4ce24f1..eab5435 100644 --- a/frontend/src/components/Admin/adminNews.tsx +++ b/frontend/src/components/Admin/adminNews.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import Swal from "sweetalert2"; +import { MIMEType } from "../../interfaces/import.interface"; import { type News } from "../../interfaces/news.interface"; import { createNews, @@ -13,13 +14,14 @@ import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; +import { AdminFileImport } from "./adminFileImport"; export const AdminNews = () => { const [newsList, setNewsList] = useState([]); const [loading, setLoading] = useState(true); const [editingId, setEditingId] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(null); + const [editingImageUrl, setEditingImageUrl] = useState(null); + const [submitDraftImage, setSubmitDraftImage] = useState<((itemOverride?: string) => Promise) | null>(null); const [formData, setFormData] = useState({ title: "", @@ -52,41 +54,78 @@ export const AdminNews = () => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; - const handleImageChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setSelectedFile(file); - setPreviewUrl(URL.createObjectURL(file)); + const getErrorMessage = (err: unknown, fallback: string) => { + if (typeof err === "object" && err !== null && "response" in err) { + const response = (err as { response?: { data?: { msg?: string } } }).response; + if (response?.data?.msg) { + return response.data.msg; + } } + + return fallback; }; - const handleCreateOrUpdate = async () => { - try { - const formDataToSend = new FormData(); + const resolveNewsImageUrl = (imageUrl: string) => { + if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + return imageUrl; + } - if (selectedFile) { - formDataToSend.append("file", selectedFile); - } + const baseUrl = import.meta.env.VITE_API_URL as string; + if (imageUrl.startsWith("/api/") && baseUrl.endsWith("/api")) { + return `${baseUrl}${imageUrl.slice(4)}`; + } + + return `${baseUrl}${imageUrl}`; + }; - formDataToSend.append("title", formData.title); - formDataToSend.append("description", formData.description); - formDataToSend.append("type", formData.type); - formDataToSend.append("published", String(formData.published)); - formDataToSend.append("target", formData.target); + const handleCreateOrUpdate = async () => { + try { + const payload = { + title: formData.title, + description: formData.description, + type: formData.type, + published: formData.published, + target: formData.target, + }; let response; if (editingId) { - formDataToSend.append("id", String(editingId)); - response = await updateNews(formDataToSend); + const uploadedImageUrl = submitDraftImage + ? await submitDraftImage(String(editingId)) + : null; + + const image_url = uploadedImageUrl + ? uploadedImageUrl + : editingImageUrl === null + ? null + : undefined; + + response = await updateNews({ + ...payload, + id: String(editingId), + image_url, + }); } else { - response = await createNews(formDataToSend); + response = await createNews(payload); + + const createdId = response?.data?.id; + if (createdId && submitDraftImage) { + const image_url = await submitDraftImage(String(createdId)); + if (image_url) { + await updateNews({ + ...payload, + id: String(createdId), + image_url, + }); + } + } } await Swal.fire("✅ Succès", response.message, "success"); resetForm(); fetchNews(); - } catch (err: any) { - Swal.fire("❌ Erreur", err.response?.data?.msg || "Erreur lors de la sauvegarde", "error"); + } catch (err: unknown) { + Swal.fire("❌ Erreur", getErrorMessage(err, "Erreur lors de la sauvegarde"), "error"); } }; @@ -137,8 +176,8 @@ export const AdminNews = () => { const response = await publishNews(news, sendEmail.isConfirmed); await Swal.fire("✅ Publiée", response.message, "success"); fetchNews(); - } catch (err: any) { - Swal.fire("❌ Erreur", err.response?.data?.msg || "Erreur lors de la publication", "error"); + } catch (err: unknown) { + Swal.fire("❌ Erreur", getErrorMessage(err, "Erreur lors de la publication"), "error"); } }; @@ -151,6 +190,7 @@ export const AdminNews = () => { target: news.target, }); setEditingId(news.id); + setEditingImageUrl(news.image_url ?? null); window.scrollTo({ top: 0, behavior: "smooth" }); }; @@ -162,14 +202,10 @@ export const AdminNews = () => { published: false, target: "Tous", }); - setSelectedFile(null); - setPreviewUrl(null); setEditingId(null); + setEditingImageUrl(null); }; - const handleRemoveImage = () => { - setPreviewUrl(null); - }; return ( @@ -194,48 +230,19 @@ export const AdminNews = () => { placeholder="Contenu de l'actu" /> - {/* Image upload amélioré */} -
- - - - - {selectedFile && ( -

{selectedFile.name}

- )} - - {previewUrl && ( -
-
- Aperçu -
- -
- )} -
+ { + setEditingImageUrl(null); + setSubmitDraftImage(null); + }} + onDraftSubmitReady={(submit) => setSubmitDraftImage(() => submit)} + />