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