diff --git a/PROGRESS.md b/PROGRESS.md index 08d238bd..f8caa8f7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -33,7 +33,12 @@ Registro histórico de hitos y sesiones del proyecto. - 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. +- **Rediseño de Navegación**: Simplificación de `BottomNav` (5 items) y rediseño brutalista oscuro de `MenuSheet`. +- **Panel de Administración (Opción B)**: + - Implementación de **Gestión de Medios** (Subida/Eliminación con persistencia en VPS). + - Migración de Protocolos de datos estáticos a **MongoDB**. + - Creación de la vista de **Listado de Protocolos** para administración (`/admin/protocols`). + - Población inicial de la DB con **55 protocolos** mediante el script de migración en el servidor. ### Próximos Pasos - Ejecutar el despliegue final de la aplicación en el VPS (puerto 9112). diff --git a/promo-site/Dockerfile b/promo-site/Dockerfile index 0f820694..54eea41e 100644 --- a/promo-site/Dockerfile +++ b/promo-site/Dockerfile @@ -1,4 +1,23 @@ +FROM node:20-alpine as build-stage +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + FROM nginx:alpine -COPY . /usr/share/nginx/html/ +COPY --from=build-stage /app/dist /usr/share/nginx/html +# Configuración para React Router +RUN echo 'server { \ + listen 80; \ + location / { \ + root /usr/share/nginx/html; \ + index index.html; \ + try_files $uri $uri/ /index.html; \ + } \ + location /api { \ + proxy_pass http://codigo0-backend:3000; \ + } \ +}' > /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/promo-site/index.html b/promo-site/index.html index de17d664..18fa79b8 100644 --- a/promo-site/index.html +++ b/promo-site/index.html @@ -1,825 +1,15 @@ - - - -Código 0 — Protocolos TES - - - - - - - - - - -
-
v0.1 · En desarrollo activo
- -

- Código
- Cero. -

- -

Protocolos TES. Sin papel. Sin esperas. Sin excusas.

- -

- Guía digital de protocolos para Técnicos de Emergencias Sanitarias. - Gratuita, offline-first, construida por profesionales del SAMU. - Consulta lo que necesitas en los segundos que importan. -

- -
- Abrir app → - Ver en GitHub -
- -
- Quick start - - $ git clone github.com/planetazuzu/codigo0 && npm i && npm run dev - -
-
- - -
-
- SVB Adulto - SCACEST - TCE Grave - Shock Anafiláctico - Politraumatismo - ACVA - Crisis Convulsiva - Intoxicación - Disnea Aguda - Parto Inminente - SVB Pediátrico - Quemaduras - - SVB Adulto - SCACEST - TCE Grave - Shock Anafiláctico - Politraumatismo - ACVA - Crisis Convulsiva - Intoxicación - Disnea Aguda - Parto Inminente - SVB Pediátrico - Quemaduras -
-
- - -
-
-
-
+40
-
Protocolos activos
-
-
-
0€
-
Coste para el TES
-
-
-
100%
-
Offline capable
-
-
-
25+
-
Años en campo
-
-
-
- - -
-
Características
-

Por qué Código 0

- -
-
- -
Consulta en segundos
-

Protocolos indexados y buscables. No PDFs de 200 páginas. No PDFs que tardan en abrir.

-
-
- 🔌 -
Offline-first
-

Funciona sin conexión una vez cargado. En campo, en el monte, en la UVI móvil. Sin excusas.

-
-
- 📋 -
YAML-driven
-

Cada protocolo es un archivo editable. Cualquier TES puede proponer cambios con un Pull Request.

-
-
- 🆓 -
Siempre gratis
-

Sin registro, sin freemium, sin anuncios, sin tracking. Construido por la comunidad TES, para la comunidad TES.

-
-
- 🔄 -
Auto-actualizable
-

Sincroniza protocolos en segundo plano cuando hay red. Siempre tienes la versión más reciente.

-
-
- 🎨 -
Alta densidad, cero ruido
-

Diseño brutalist: máximo contraste, información densa, sin decoración que distrae cuando más importa.

-
-
-
- - -
-
-
- -

Los que usas cada día

-
    -
  • -
    -
    SVB Adulto
    -
    Soporte Vital Básico
    -
    - ERC 2021 -
  • -
  • -
    -
    SCACEST
    -
    Cardiología
    -
    - SAMU -
  • -
  • -
    -
    TCE Grave
    -
    Trauma
    -
    - PHTLS -
  • -
  • -
    -
    Shock Anafiláctico
    -
    Urgencias
    -
    - SAMU -
  • -
  • -
    -
    ACVA / Ictus
    -
    Neurología
    -
    - Código Ictus -
  • -
  • -
    -
    SVB Pediátrico
    -
    Pediatría
    -
    - ERC 2021 -
  • -
  • -
    -
    Politraumatismo
    -
    Trauma
    -
    - PHTLS -
  • -
  • -
    + 33 protocolos más →
    -
  • -
-
- -
- -
-
-
-
-
-
-
- svb-adulto.yaml - -
-
-# docs/protocolos/svb-adulto.yaml
-titulo: SVB Adulto
-version: "2.0"
-categoria: soporte_vital
-referencia: ERC 2021
-
-pasos:
-  - id: 1
-    accion: Seguridad del entorno
-    duracion: null
-
-  - id: 2
-    accion: Valorar consciencia
-    duracion: 10s
-
-  - id: 3
-    accion: Pedir ayuda — 112
-    alerta: true
-
-  - id: 4
-    accion: Apertura vía aérea
-    tecnica: frente-mentón
-
-# ... 8 pasos más -
-
-
-
-
- - -
-

- Código libre.
- Protocolo claro.
- Actúa ahora.
- Salva vidas. -

-
- - - - - - \ No newline at end of file + + + + + Código 0 — Portal de Gestión + + + + +
+ + + diff --git a/promo-site/package-lock.json b/promo-site/package-lock.json new file mode 100644 index 00000000..be0469d1 --- /dev/null +++ b/promo-site/package-lock.json @@ -0,0 +1,112 @@ +{ + "name": "promo-site", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "promo-site", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "lucide-react": "^1.6.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/lucide-react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.6.0.tgz", + "integrity": "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-router": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + } + } +} diff --git a/promo-site/package.json b/promo-site/package.json new file mode 100644 index 00000000..5c4d53ec --- /dev/null +++ b/promo-site/package.json @@ -0,0 +1,23 @@ +{ + "name": "promo-site", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", + "lucide-react": "^0.284.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react-swc": "^3.4.0", + "typescript": "^5.2.2", + "vite": "^4.4.9" + } +} diff --git a/promo-site/src/App.tsx b/promo-site/src/App.tsx new file mode 100644 index 00000000..911075da --- /dev/null +++ b/promo-site/src/App.tsx @@ -0,0 +1,23 @@ +import React, { Suspense, lazy } from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import Home from './pages/Home'; + +const MediaAdmin = lazy(() => import('./pages/admin/MediaAdmin')); +const ProtocolListAdmin = lazy(() => import('./pages/admin/ProtocolListAdmin')); + +const App: React.FC = () => { + return ( + + CARGANDO SISTEMA...}> + + } /> + } /> + } /> + } /> + + + + ); +}; + +export default App; diff --git a/promo-site/src/index.css b/promo-site/src/index.css new file mode 100644 index 00000000..c0af01ef --- /dev/null +++ b/promo-site/src/index.css @@ -0,0 +1,93 @@ +/* RESET */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --orange: #FF4500; + --orange-dim: #CC3700; + --orange-pale: #FF6633; + --black: #0A0A0A; + --surface: #111111; + --surface2: #1A1A1A; + --border: #2A2A2A; + --border-hot: #FF4500; + --text: #F0EDE8; + --text-dim: #888880; + --mono: 'Space Mono', monospace; + --display: 'Barlow Condensed', sans-serif; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--black); + color: var(--text); + font-family: var(--mono); + font-size: 14px; + line-height: 1.6; + overflow-x: hidden; +} + +/* SCAN LINES OVERLAY */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0,0,0,0.08) 2px, + rgba(0,0,0,0.08) 4px + ); + pointer-events: none; + z-index: 999; +} + +/* BRUTALIST COMPONENTS - GLOBAL */ +.section-label { + font-family: var(--mono); + font-size: 11px; + color: var(--orange); + text-transform: uppercase; + letter-spacing: 0.2em; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 8px; +} +.section-label::before { + content: ''; + display: block; + width: 24px; + height: 1px; + background: var(--orange); +} + +.btn-primary { + background: var(--orange); + color: var(--black); + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 14px 28px; + text-decoration: none; + border: 2px solid var(--orange); + transition: all 0.15s; + display: inline-block; +} +.glass-card { + background: rgba(26, 26, 26, 0.6); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +} + +.glass-nav { + background: rgba(10, 10, 10, 0.8); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} diff --git a/promo-site/src/main.tsx b/promo-site/src/main.tsx new file mode 100644 index 00000000..2339d59c --- /dev/null +++ b/promo-site/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/promo-site/src/pages/Home.tsx b/promo-site/src/pages/Home.tsx new file mode 100644 index 00000000..66955c84 --- /dev/null +++ b/promo-site/src/pages/Home.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const Home: React.FC = () => { + return ( +
+ {/* NAV */} + + + + + {/* HERO (Simplified for now - porting main parts) */} +
+
v0.1 · En desarrollo activo
+

+ Código
+ Cero. +

+

+ Protocolos TES. Sin papel. Sin esperas. Sin excusas. +

+ +
+ Abrir app → + Panel de Gestión +
+
+ + {/* Rest of the sections can be added slowly, but let's keep it functional first */} +
+ ); +}; + +export default Home; diff --git a/promo-site/src/pages/admin/MediaAdmin.tsx b/promo-site/src/pages/admin/MediaAdmin.tsx new file mode 100644 index 00000000..c600de0b --- /dev/null +++ b/promo-site/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; diff --git a/promo-site/src/pages/admin/ProtocolListAdmin.tsx b/promo-site/src/pages/admin/ProtocolListAdmin.tsx new file mode 100644 index 00000000..1cf0ae6c --- /dev/null +++ b/promo-site/src/pages/admin/ProtocolListAdmin.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, Edit3, Trash2, Search, FileText } from 'lucide-react'; + +interface ProtocolSummary { + id: string; + titulo: string; + categoria: string; + version: string; + urgencia: string; +} + +const ProtocolListAdmin: React.FC = () => { + const [protocols, setProtocols] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const navigate = useNavigate(); + + const fetchProtocols = async () => { + try { + const response = await fetch('/api/content'); + const data = await response.json(); + setProtocols(data); + } catch (err) { + console.error('Error fetching protocols:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchProtocols(); + }, []); + + const handleDelete = async (id: string) => { + if (!confirm(`¿Estás seguro de eliminar el protocolo ${id}?`)) return; + try { + const response = await fetch(`/api/content/${id}`, { method: 'DELETE' }); + if (response.ok) fetchProtocols(); + } catch (err) { + alert('Error al eliminar'); + } + }; + + const filteredProtocols = protocols.filter(p => + p.titulo.toLowerCase().includes(searchTerm.toLowerCase()) || + p.id.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+
+

Editor de Protocolos

+

// Gestión clínica en tiempo real (MongoDB)

+
+ + +
+ +
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + + + + + + + + + + + {filteredProtocols.map((p) => ( + + + + + + + + ))} + +
ID / TituloCategoríaVersiónUrgenciaAcciones
+
{p.titulo}
+
{p.id}
+
+ + {p.categoria} + + v{p.version} + + {p.urgencia} + + +
+ + +
+
+ + {loading && ( +
+ Cargando protocolos... +
+ )} + + {!loading && filteredProtocols.length === 0 && ( +
+ + No se encontraron protocolos +
+ )} +
+
+ ); +}; + +export default ProtocolListAdmin; diff --git a/promo-site/vite.config.ts b/promo-site/vite.config.ts new file mode 100644 index 00000000..75b46781 --- /dev/null +++ b/promo-site/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 9113, + host: true, + proxy: { + '/api': { + target: 'http://207.180.226.141:3002', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + emptyOutDir: true + } +});