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
Some checks are pending
Deploy Código 0 / deploy (push) Waiting to run
This commit is contained in:
parent
d3594bdba0
commit
81e1c3a2a7
|
|
@ -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 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`.
|
- 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.
|
- 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
|
### Próximos Pasos
|
||||||
- Ejecutar el despliegue final de la aplicación en el VPS (puerto 9112).
|
- Ejecutar el despliegue final de la aplicación en el VPS (puerto 9112).
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import helmet from 'helmet';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
import protocolRoutes from './infrastructure/http/routes/protocol.routes';
|
import protocolRoutes from './infrastructure/http/routes/protocol.routes';
|
||||||
|
import mediaRoutes from './infrastructure/http/routes/media.routes';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
@ -38,6 +40,10 @@ export function createApp() {
|
||||||
// API routes
|
// API routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/content', protocolRoutes);
|
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
|
// 404 handler
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
|
|
|
||||||
12
backend/src/domain/entities/Media.ts
Normal file
12
backend/src/domain/entities/Media.ts
Normal 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;
|
||||||
|
}
|
||||||
19
backend/src/infrastructure/database/models/MediaModel.ts
Normal file
19
backend/src/infrastructure/database/models/MediaModel.ts
Normal 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);
|
||||||
96
backend/src/infrastructure/http/routes/media.routes.ts
Normal file
96
backend/src/infrastructure/http/routes/media.routes.ts
Normal 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;
|
||||||
|
|
@ -41,6 +41,7 @@ const GlasgowCalculator = lazy(() => import("./pages/GlasgowCalculator"));
|
||||||
const TriageStart = lazy(() => import("./pages/TriageStart"));
|
const TriageStart = lazy(() => import("./pages/TriageStart"));
|
||||||
const BurnCalculator = lazy(() => import("./pages/BurnCalculator"));
|
const BurnCalculator = lazy(() => import("./pages/BurnCalculator"));
|
||||||
const DoseCalculator = lazy(() => import("./pages/DoseCalculator"));
|
const DoseCalculator = lazy(() => import("./pages/DoseCalculator"));
|
||||||
|
const MediaAdmin = lazy(() => import("./pages/admin/MediaAdmin"));
|
||||||
|
|
||||||
// Wrapper para pasar props a Home
|
// Wrapper para pasar props a Home
|
||||||
const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => {
|
const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => {
|
||||||
|
|
@ -99,6 +100,7 @@ const App = () => {
|
||||||
<Route path="/herramientas/quemados" element={<BurnCalculator />} />
|
<Route path="/herramientas/quemados" element={<BurnCalculator />} />
|
||||||
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
|
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
|
||||||
<Route path="/protocolo/:id" element={<Protocolo />} />
|
<Route path="/protocolo/:id" element={<Protocolo />} />
|
||||||
|
<Route path="/admin/media" element={<MediaAdmin />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
155
frontend/src/pages/admin/MediaAdmin.tsx
Normal file
155
frontend/src/pages/admin/MediaAdmin.tsx
Normal 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;
|
||||||
Loading…
Reference in a new issue