feat: implement media management system (phase 1) with backend upload and admin gallery
Some checks are pending
Deploy Código 0 / deploy (push) Waiting to run

This commit is contained in:
Javier 2026-03-25 11:52:50 +01:00
parent d3594bdba0
commit 81e1c3a2a7
7 changed files with 292 additions and 0 deletions

View file

@ -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).

View file

@ -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) => {

View file

@ -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;
}

View file

@ -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<Media, 'id'>, Document {}
export const MediaModel = mongoose.model<MediaDocument>('Media', MediaSchema);

View file

@ -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;

View file

@ -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 = () => {
<Route path="/herramientas/quemados" element={<BurnCalculator />} />
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
<Route path="/protocolo/:id" element={<Protocolo />} />
<Route path="/admin/media" element={<MediaAdmin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>

View file

@ -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<MediaItem[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex justify-between items-end border-b-2 border-orange-500 pb-4">
<div>
<h1 className="text-4xl font-black uppercase tracking-tighter">Administrador de Medios</h1>
<p className="text-zinc-500 font-mono text-sm mt-1">// Gestor de diagramas, fotos y videos técnicos</p>
</div>
<label className="bg-orange-500 text-black px-6 py-3 font-bold uppercase cursor-pointer hover:bg-orange-400 transition-colors flex items-center gap-2">
{uploading ? 'Subiendo...' : <><Plus size={20} /> Añadir Medio</>}
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
</label>
</div>
{error && (
<div className="bg-red-900/20 border-l-4 border-red-500 p-4 text-red-500 font-mono text-sm">
ERROR: {error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{media.map((item) => (
<div key={item._id} className="bg-zinc-900 border border-zinc-800 p-4 group hover:border-orange-500 transition-colors flex flex-col">
<div className="aspect-video bg-black flex items-center justify-center mb-4 relative overflow-hidden border border-zinc-800">
{item.mimetype.startsWith('image/') ? (
<img src={item.url} alt={item.originalname} className="w-full h-full object-cover" />
) : item.mimetype.startsWith('video/') ? (
<Video size={48} className="text-zinc-700" />
) : (
<FileText size={48} className="text-zinc-700" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg truncate uppercase tracking-tight" title={item.originalname}>
{item.originalname}
</h3>
<div className="font-mono text-[10px] text-zinc-500 mt-2 space-y-1">
<p>MIME: {item.mimetype}</p>
<p>SIZE: {formatSize(item.size)}</p>
<p>DATE: {new Date(item.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex gap-2 mt-6 pt-4 border-t border-zinc-800">
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="flex-1 flex items-center justify-center gap-2 py-2 border border-zinc-700 hover:bg-zinc-800 text-xs font-bold uppercase transition-colors"
>
<ExternalLink size={14} /> Ver
</a>
<button
onClick={() => handleDelete(item._id)}
className="w-12 flex items-center justify-center bg-zinc-800 hover:bg-red-900/30 hover:text-red-500 transition-all text-zinc-500 border border-zinc-700 hover:border-red-900"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{media.length === 0 && !uploading && (
<div className="col-span-full py-20 border-2 border-dashed border-zinc-800 flex flex-col items-center justify-center text-zinc-600 font-mono uppercase text-sm tracking-widest">
<ImageIcon size={48} className="mb-4 opacity-20" />
Galería vacía // Sube el primer medio
</div>
)}
</div>
</div>
);
};
export default MediaAdmin;