From 81e1c3a2a7413130195b5fa12c6aee05e82797de Mon Sep 17 00:00:00 2001 From: Javier Date: Wed, 25 Mar 2026 11:52:50 +0100 Subject: [PATCH] feat: implement media management system (phase 1) with backend upload and admin gallery --- PROGRESS.md | 2 + backend/src/app.ts | 6 + backend/src/domain/entities/Media.ts | 12 ++ .../database/models/MediaModel.ts | 19 +++ .../http/routes/media.routes.ts | 96 +++++++++++ frontend/src/App.tsx | 2 + frontend/src/pages/admin/MediaAdmin.tsx | 155 ++++++++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 backend/src/domain/entities/Media.ts create mode 100644 backend/src/infrastructure/database/models/MediaModel.ts create mode 100644 backend/src/infrastructure/http/routes/media.routes.ts create mode 100644 frontend/src/pages/admin/MediaAdmin.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 7fe62350..08d238bd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -32,6 +32,8 @@ Registro histórico de hitos y sesiones del proyecto. - Se corrigió el repositorio remoto en el VPS para que apunte a Forgejo. - Se lanzó la reconstrucción de contenedores y despliegue final en el puerto `9112`. - Despliegue de la **Web de Promoción** en el puerto `9113` tras corregir enlaces internos. +- Configuración de **Auto-Despliegue** mediante `.woodpecker.yml`. El sistema ya está sincronizado. +- **Rediseño de Navegación**: Simplificación de `BottomNav` (5 items) y rediseño brutalista oscuro de `MenuSheet` para mejor usabilidad en emergencias. ### Próximos Pasos - Ejecutar el despliegue final de la aplicación en el VPS (puerto 9112). diff --git a/backend/src/app.ts b/backend/src/app.ts index 250c6eda..c9d26e0f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,8 @@ import helmet from 'helmet'; import dotenv from 'dotenv'; import authRoutes from './routes/auth'; import protocolRoutes from './infrastructure/http/routes/protocol.routes'; +import mediaRoutes from './infrastructure/http/routes/media.routes'; +import path from 'path'; // Load environment variables dotenv.config(); @@ -38,6 +40,10 @@ export function createApp() { // API routes app.use('/api/auth', authRoutes); app.use('/api/content', protocolRoutes); + app.use('/api/media', mediaRoutes); + + // Static files for media + app.use('/api/media/static', express.static(path.join(__dirname, '../uploads'))); // 404 handler app.use((_req, res) => { diff --git a/backend/src/domain/entities/Media.ts b/backend/src/domain/entities/Media.ts new file mode 100644 index 00000000..1223083d --- /dev/null +++ b/backend/src/domain/entities/Media.ts @@ -0,0 +1,12 @@ +export interface Media { + id: string; + filename: string; + originalname: string; + mimetype: string; + size: number; + path: string; + url: string; + category?: string; + createdAt?: Date; + updatedAt?: Date; +} diff --git a/backend/src/infrastructure/database/models/MediaModel.ts b/backend/src/infrastructure/database/models/MediaModel.ts new file mode 100644 index 00000000..11d9e80c --- /dev/null +++ b/backend/src/infrastructure/database/models/MediaModel.ts @@ -0,0 +1,19 @@ +import mongoose, { Schema, Document } from 'mongoose'; +import { Media } from '../../../domain/entities/Media'; + +const MediaSchema = new Schema({ + filename: { type: String, required: true }, + originalname: { type: String, required: true }, + mimetype: { type: String, required: true }, + size: { type: Number, required: true }, + path: { type: String, required: true }, + url: { type: String, required: true }, + category: String +}, { + timestamps: true, + collection: 'media' +}); + +export interface MediaDocument extends Omit, Document {} + +export const MediaModel = mongoose.model('Media', MediaSchema); diff --git a/backend/src/infrastructure/http/routes/media.routes.ts b/backend/src/infrastructure/http/routes/media.routes.ts new file mode 100644 index 00000000..81333d2b --- /dev/null +++ b/backend/src/infrastructure/http/routes/media.routes.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { MediaModel } from '../database/models/MediaModel'; + +const router = Router(); + +// Configuración de Multer +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadPath = path.join(__dirname, '../../../../uploads'); + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|gif|mp4|webm|pdf/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + if (extname && mimetype) { + return cb(null, true); + } + cb(new Error('Formato de archivo no permitido')); + } +}); + +// Endpoint de subida +router.post('/upload', upload.single('file'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No se ha subido ningún archivo' }); + } + + const { filename, originalname, mimetype, size, path: filePath } = req.file; + const url = `/api/media/static/${filename}`; + + const media = await MediaModel.create({ + filename, + originalname, + mimetype, + size, + path: filePath, + url, + category: req.body.category + }); + + res.status(201).json(media); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +// Listar medios +router.get('/', async (req, res) => { + try { + const media = await MediaModel.find().sort({ createdAt: -1 }); + res.json(media); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +// Eliminar medio +router.delete('/:id', async (req, res) => { + try { + const media = await MediaModel.findById(req.params.id); + if (!media) { + return res.status(404).json({ error: 'Archivo no encontrado' }); + } + + // Eliminar archivo físico + if (fs.existsSync(media.path)) { + fs.unlinkSync(media.path); + } + + // Eliminar registro + await MediaModel.findByIdAndDelete(req.params.id); + + res.json({ message: 'Archivo eliminado correctamente' }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d43542f..f8185731 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ const GlasgowCalculator = lazy(() => import("./pages/GlasgowCalculator")); const TriageStart = lazy(() => import("./pages/TriageStart")); const BurnCalculator = lazy(() => import("./pages/BurnCalculator")); const DoseCalculator = lazy(() => import("./pages/DoseCalculator")); +const MediaAdmin = lazy(() => import("./pages/admin/MediaAdmin")); // Wrapper para pasar props a Home const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => { @@ -99,6 +100,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/admin/MediaAdmin.tsx b/frontend/src/pages/admin/MediaAdmin.tsx new file mode 100644 index 00000000..c600de0b --- /dev/null +++ b/frontend/src/pages/admin/MediaAdmin.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react'; +import { Upload, Trash2, Image as ImageIcon, FileText, Video, ExternalLink, Plus } from 'lucide-react'; + +interface MediaItem { + _id: string; + filename: string; + originalname: string; + mimetype: string; + size: number; + url: string; + createdAt: string; +} + +const MediaAdmin: React.FC = () => { + const [media, setMedia] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + const fetchMedia = async () => { + try { + const response = await fetch('/api/media'); + const data = await response.json(); + setMedia(data); + } catch (err) { + console.error('Error fetching media:', err); + } + }; + + useEffect(() => { + fetchMedia(); + }, []); + + const handleUpload = async (e: React.ChangeEvent) => { + if (!e.target.files?.[0]) return; + + setUploading(true); + setError(null); + const formData = new FormData(); + formData.append('file', e.target.files[0]); + + try { + const response = await fetch('/api/media/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) throw new Error('Error al subir el archivo'); + + await fetchMedia(); + } catch (err: any) { + setError(err.message); + } finally { + setUploading(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('¿Estás seguro de eliminar este archivo?')) return; + + try { + const response = await fetch(`/api/media/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Error al eliminar'); + + setMedia(media.filter(item => item._id !== id)); + } catch (err: any) { + alert(err.message); + } + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( +
+
+
+

Administrador de Medios

+

// Gestor de diagramas, fotos y videos técnicos

+
+ + +
+ + {error && ( +
+ ERROR: {error} +
+ )} + +
+ {media.map((item) => ( +
+
+ {item.mimetype.startsWith('image/') ? ( + {item.originalname} + ) : item.mimetype.startsWith('video/') ? ( +
+ +
+

+ {item.originalname} +

+
+

MIME: {item.mimetype}

+

SIZE: {formatSize(item.size)}

+

DATE: {new Date(item.createdAt).toLocaleDateString()}

+
+
+ +
+ + Ver + + +
+
+ ))} + + {media.length === 0 && !uploading && ( +
+ + Galería vacía // Sube el primer medio +
+ )} +
+
+ ); +}; + +export default MediaAdmin;