feat: migración a PostgreSQL, tiempo real SSE, Dockerfile y compose para Coolify

This commit is contained in:
urieljareth
2026-08-13 15:01:58 -06:00
commit f7fded5a44
28 changed files with 7314 additions and 0 deletions
+436
View File
@@ -0,0 +1,436 @@
import React, { useState } from 'react';
import { KeyRound, Trash2, Copy, Check, Download, AlertTriangle, ShieldCheck, X } from 'lucide-react';
import { ApiService } from '../services/api';
interface AdminPanelProps {
rawVotes: Record<string, string>;
onVoteResetSuccess: () => Promise<void>;
isOpen?: boolean;
onClose?: () => void;
}
export const AdminPanel: React.FC<AdminPanelProps> = ({
rawVotes,
onVoteResetSuccess,
isOpen: controlledOpen,
onClose,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [selectedVoter, setSelectedVoter] = useState('');
const [password, setPassword] = useState('');
const [adminMsg, setAdminMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [copiedCode, setCopiedCode] = useState(false);
const [copiedSingleHtml, setCopiedSingleHtml] = useState(false);
const isPanelOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const togglePanel = () => {
if (onClose && controlledOpen !== undefined) {
onClose();
} else {
setInternalOpen(!internalOpen);
}
};
const registeredVoters = Object.keys(rawVotes).sort();
const handleResetVote = async () => {
if (!selectedVoter) {
setAdminMsg({ text: 'Selecciona un votante de la lista.', type: 'error' });
return;
}
if (password.trim() !== 'RESETEOE3') {
setAdminMsg({ text: 'Contraseña de administración incorrecta.', type: 'error' });
return;
}
setIsDeleting(true);
setAdminMsg(null);
try {
await ApiService.deleteVote(selectedVoter, password.trim());
await onVoteResetSuccess();
setAdminMsg({
text: `Voto de "${selectedVoter}" restablecido y eliminado de PostgreSQL.`,
type: 'success',
});
setSelectedVoter('');
setPassword('');
} catch (err) {
setAdminMsg({
text: `Error al restablecer voto: ${err instanceof Error ? err.message : 'Error desconocido'}`,
type: 'error',
});
} finally {
setIsDeleting(false);
}
};
const codeSnippet = `// Votos embebidos en código fuente — Consultoría E3
const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(codeSnippet);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2500);
} catch {
// fallback
}
};
const generateStandaloneHtml = () => {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>¿Qué proyecto debe ser el ganador? — Consultoría E3</title>
<meta name="description" content="Encuesta interna de Consultoría E3 para elegir al proyecto ganador en tiempo real.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #000000;
--surface: #0a0a0a;
--accent: #00B7D4;
--text-primary: #ffffff;
--text-secondary: #cfd6d9;
--glass: rgba(255, 255, 255, 0.04);
--glass-border: rgba(255, 255, 255, 0.08);
--green-live: #00e676;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg);
color: var(--text-primary);
font-family: 'Poppins', sans-serif;
min-height: 100vh;
padding: 24px 16px 60px;
}
.wrap { max-width: 1100px; margin: 0 auto; }
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 24px; }
.glass-panel {
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 20px;
backdrop-filter: blur(16px);
padding: 24px;
display: flex;
flex-direction: column;
}
.title-font { font-family: 'Bebas Neue', sans-serif; text-transform: uppercase; letter-spacing: 1.5px; }
.accent-text { color: var(--accent); }
.btn-pill {
background: transparent; border: 1px solid var(--glass-border); color: var(--text-secondary); padding: 12px 16px; border-radius: 100px; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; transition: all 0.2s; cursor: pointer; text-align: center;
}
.btn-pill:hover:not(:disabled) { background: rgba(0, 183, 212, 0.1); border-color: var(--accent); color: #fff; }
.btn-pill.active { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 700; box-shadow: 0 0 15px rgba(0, 183, 212, 0.4); }
.live-indicator { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; text-transform: uppercase; color: var(--green-live); letter-spacing: 1px; }
.dot { width: 8px; height: 8px; background: var(--green-live); border-radius: 50%; box-shadow: 0 0 8px var(--green-live); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:0.3; } }
input[type="text"], input[type="password"], select {
width: 100%; padding: 14px; background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 12px; color: #fff; font-size: 14px; font-family: inherit; text-transform: uppercase; outline: none; transition: 0.2s;
}
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 15px rgba(0,183,212,0.25); }
.pills-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.bar-row { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; gap: 12px; margin-bottom: 12px; }
.bar-track { height: 8px; background: rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; }
.bar-fill { height: 100%; background: var(--text-secondary); border-radius: 10px; transition: width 0.5s ease; }
.bar-fill.leader { background: var(--accent); box-shadow: 0 0 10px rgba(0, 183, 212, 0.4); }
.badge { font-size: 9px; background: var(--accent); color: var(--bg); padding: 2px 6px; border-radius: 4px; font-weight: 800; }
.btn-action { width: 100%; padding: 14px; border-radius: 12px; background: var(--accent); color: #000; font-weight: 800; text-transform: uppercase; letter-spacing: 1.5px; border: none; cursor: pointer; transition: 0.2s; margin-top: 16px; box-shadow: 0 0 15px rgba(0,183,212,0.3); }
.btn-action:hover { background: #38bdf8; }
</style>
</head>
<body>
<div class="wrap">
<div class="dashboard">
<section class="glass-panel">
<div style="margin-bottom: 24px;">
<div class="live-indicator" style="margin-bottom: 8px;"><span class="dot"></span> SINCRONIZADO EN VIVO</div>
<h1 class="title-font" style="font-size: 38px;">CONSULTORÍA <span class="accent-text">E3</span></h1>
<p style="font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px;">Sistema de Votación Global 2026</p>
</div>
<div style="margin-bottom: 20px;">
<label style="display:block; font-size:11px; text-transform:uppercase; color:#888; font-weight:600; margin-bottom:6px;">Paso 1: Identificación</label>
<input type="text" id="voterInput" placeholder="ESCRIBE TU NOMBRE COMPLETO">
</div>
<div style="flex:1;">
<label style="display:block; font-size:11px; text-transform:uppercase; color:#888; font-weight:600; margin-bottom:8px;">Paso 2: Elige el Proyecto Ganador</label>
<div class="pills-grid" id="pillsWrap"></div>
</div>
<div id="confirmBox" style="margin-top:14px;"></div>
<button type="button" class="btn-action" id="btnCastVote">REGISTRAR VOTO ATÓMICO</button>
</section>
<section class="glass-panel">
<div style="display:flex; justify-content:space-between; align-items:flex-end; margin-bottom: 20px;">
<div>
<h2 class="title-font" style="font-size: 26px;">RESULTADOS <span class="accent-text">REAL-TIME</span></h2>
<p style="font-size: 11px; color: #888; font-family: monospace;" id="totalVotosTxt">TOTAL DE VOTOS: 0</p>
</div>
<div style="text-align:right;">
<span class="title-font accent-text" style="font-size: 26px;" id="pctTxt">0%</span>
<p style="font-size: 9px; color: #888; text-transform: uppercase;">Actividad</p>
</div>
</div>
<div id="barsWrap" style="flex:1;"></div>
<div style="margin-top:20px; border-top:1px solid var(--glass-border); pt:14px; display:flex; justify-content:space-between; align-items:center; font-size:10px; font-family:monospace; color:#666;">
<div>ENDPOINT: WORTHY-DUCK-110283</div>
<div style="cursor:pointer; color:#aaa;" onclick="syncVotes()">ACTUALIZAR ⟳</div>
</div>
</section>
</div>
</div>
<script>
const CONFIG = {
endpoint: "https://worthy-duck-110283.upstash.io",
token: "gQAAAAAAAa7LAQIgcDE5Yjc5NzdlMmI1ODk0NjA0ODhhZDViNTNmOTViYTdkYg",
key: "e3_consultoria_encuesta_2026"
};
const CANDIDATOS = ["SERVANDO", "URIEL", "ANNA", "YAMILET", "VERONICA", "ERICK", "MELINA", "LEYMI", "GRECIA"];
let state = { votes: ${JSON.stringify(rawVotes)}, currentVoter: localStorage.getItem('e3_voter_app') || '', selected: null };
async function callRedis(cmd) {
const res = await fetch(CONFIG.endpoint, { method: "POST", headers: { "Authorization": "Bearer " + CONFIG.token, "Content-Type": "application/json" }, body: JSON.stringify(cmd) });
const data = await res.json();
return data.result;
}
async function syncVotes() {
try {
const res = await callRedis(["HGETALL", CONFIG.key]);
const votes = {};
if (Array.isArray(res)) { for (let i = 0; i < res.length; i += 2) { if (res[i] && res[i+1]) votes[res[i].toUpperCase().trim()] = res[i+1].toUpperCase().trim(); } }
else if (res && typeof res === 'object') { for (const [k, v] of Object.entries(res)) { if (k && v) votes[k.toUpperCase().trim()] = String(v).toUpperCase().trim(); } }
state.votes = votes;
render();
} catch (e) { console.error(e); }
}
async function submitVote() {
const voter = state.currentVoter.trim().toUpperCase();
const cand = state.selected || state.votes[voter];
if (!voter || voter.length < 2) return alert("Escribe tu nombre antes de votar.");
if (!cand) return alert("Selecciona un candidato.");
if (voter === cand || voter.split(/\\s+/).includes(cand)) return alert("No puedes votar por ti mismo.");
try {
await callRedis(["HSET", CONFIG.key, voter, cand]);
state.votes[voter] = cand;
localStorage.setItem('e3_voter_app', voter);
render();
alert("¡Voto registrado en la nube con éxito!");
} catch (e) { alert("Error: " + e.message); }
}
function render() {
const voter = state.currentVoter.trim().toUpperCase();
const pillsWrap = document.getElementById('pillsWrap');
pillsWrap.innerHTML = '';
const existingVote = state.votes[voter];
const activeCand = state.selected || existingVote;
CANDIDATOS.forEach((c, idx) => {
const isSelf = voter && (voter === c || voter.split(/\\s+/).includes(c));
const isSelected = activeCand === c;
const btn = document.createElement('button');
btn.className = 'btn-pill' + (isSelected ? ' active' : '');
btn.disabled = isSelf;
if (idx === CANDIDATOS.length - 1) btn.style.gridColumn = 'span 2';
btn.innerHTML = c + (isSelf ? ' <small style="font-size:9px;">(Tú)</small>' : '');
btn.onclick = () => { state.selected = c; render(); };
pillsWrap.appendChild(btn);
});
const counts = {}; CANDIDATOS.forEach(c => counts[c] = 0);
let total = 0;
Object.values(state.votes).forEach(c => { if (counts[c] !== undefined) { counts[c]++; total++; } });
document.getElementById('totalVotosTxt').innerText = 'TOTAL DE VOTOS: ' + total;
document.getElementById('pctTxt').innerText = (total > 0 ? Math.min(100, Math.round((total / Math.max(total, 12)) * 100)) : 0) + '%';
const sorted = CANDIDATOS.map(name => ({ name, votes: counts[name], pct: total > 0 ? (counts[name] / total) * 100 : 0 })).sort((a,b) => b.votes - a.votes);
const maxVotes = sorted[0]?.votes || 0;
const barsWrap = document.getElementById('barsWrap');
barsWrap.innerHTML = '';
sorted.forEach(item => {
const isLeader = maxVotes > 0 && item.votes === maxVotes;
const row = document.createElement('div');
row.className = 'bar-row';
row.innerHTML = \`<span style="font-size:12px;font-weight:600;color:\${isLeader?'#fff':'#aaa'};">\${item.name}</span><div class="bar-track"><div class="bar-fill \${isLeader?'leader':''}" style="width:\${Math.max(item.pct, item.votes>0?4:0)}%;"></div></div><span style="font-size:12px;font-family:monospace;color:\${isLeader?'var(--accent)':'#aaa'};">\${item.votes} \${isLeader?'<span class="badge">LÍDER</span>':''}</span>\`;
barsWrap.appendChild(row);
});
}
document.getElementById('voterInput').addEventListener('input', e => { state.currentVoter = e.target.value.toUpperCase(); localStorage.setItem('e3_voter_app', state.currentVoter); render(); });
document.getElementById('btnCastVote').addEventListener('click', submitVote);
setInterval(syncVotes, 2500);
syncVotes();
</script>
</body>
</html>`;
};
const handleCopySingleHtml = async () => {
try {
const html = generateStandaloneHtml();
await navigator.clipboard.writeText(html);
setCopiedSingleHtml(true);
setTimeout(() => setCopiedSingleHtml(false), 2500);
} catch {
// fallback
}
};
const handleDownloadSingleHtml = () => {
const html = generateStandaloneHtml();
const blob = new Blob([html], { type: 'text/html;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'encuesta-e3-immersive.html';
link.click();
URL.revokeObjectURL(url);
};
return (
<div className="w-full mt-6">
{/* Accordion / Toggle trigger if not controlled externally */}
{controlledOpen === undefined && (
<div className="flex justify-end mb-3">
<button
type="button"
onClick={togglePanel}
className="text-xs font-mono uppercase tracking-widest text-gray-500 hover:text-[#00B7D4] transition-colors flex items-center gap-1.5 cursor-pointer"
>
<KeyRound className="w-3.5 h-3.5" />
<span>{isPanelOpen ? 'Cerrar Panel Admin' : 'Panel de Administración'}</span>
</button>
</div>
)}
{isPanelOpen && (
<div className="glass-panel p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
{onClose && (
<button
type="button"
onClick={onClose}
className="absolute top-5 right-5 text-gray-400 hover:text-white p-1 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
)}
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-[#00B7D4]/10 border border-[#00B7D4]/30 flex items-center justify-center text-[#00B7D4]">
<KeyRound className="w-4 h-4" />
</div>
<div>
<h3 className="title-font text-lg text-white">Panel de Administración</h3>
<p className="text-[11px] text-gray-400 font-mono">
Operaciones Atómicas PostgreSQL & Exportación
</p>
</div>
</div>
{/* Reset section */}
<div className="space-y-3 pb-5 border-b border-white/5">
<label className="block text-xs uppercase tracking-wider text-gray-400 font-semibold">
Restablecer Voto por Error Humano (DELETE)
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<select
value={selectedVoter}
onChange={(e) => setSelectedVoter(e.target.value)}
className="w-full px-3 py-2.5 bg-white/[0.04] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none uppercase font-mono"
>
<option value="">Selecciona votante...</option>
{registeredVoters.map((v) => (
<option key={v} value={v}>
{v} → {rawVotes[v]}
</option>
))}
</select>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Contraseña: RESETEOE3"
className="w-full px-3 py-2.5 bg-white/[0.04] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none"
/>
<button
type="button"
onClick={handleResetVote}
disabled={isDeleting || !selectedVoter}
className="px-4 py-2.5 bg-red-950/30 hover:bg-red-900/50 border border-red-500/40 text-red-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-2"
>
<Trash2 className="w-3.5 h-3.5" />
<span>{isDeleting ? 'Borrando...' : 'Restablecer'}</span>
</button>
</div>
{adminMsg && (
<div
className={`p-3 rounded-xl text-xs flex items-center gap-2 ${
adminMsg.type === 'success'
? 'bg-[#00e676]/10 text-[#00e676] border border-[#00e676]/20'
: 'bg-red-950/40 text-red-300 border border-red-500/30'
}`}
>
{adminMsg.type === 'success' ? (
<ShieldCheck className="w-4 h-4 shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 shrink-0" />
)}
<span>{adminMsg.text}</span>
</div>
)}
</div>
{/* Export section */}
<div className="pt-4 space-y-3">
<div className="text-xs uppercase tracking-wider text-gray-300 font-semibold">
Exportar Versión Autónoma Single-File (HTML5 + CSS + JS)
</div>
<div className="flex flex-wrap gap-2.5">
<button
type="button"
onClick={handleCopySingleHtml}
className="px-3.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-xs font-mono text-[#00B7D4] transition-all flex items-center gap-1.5 cursor-pointer"
>
{copiedSingleHtml ? <Check className="w-3.5 h-3.5 text-[#00e676]" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedSingleHtml ? 'HTML Copiado' : 'Copiar Archivo HTML Autónomo'}</span>
</button>
<button
type="button"
onClick={handleDownloadSingleHtml}
className="px-3.5 py-2 rounded-lg bg-[#00B7D4]/10 hover:bg-[#00B7D4]/20 border border-[#00B7D4] text-xs font-mono text-[#00B7D4] transition-all flex items-center gap-1.5 cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Descargar .html</span>
</button>
<button
type="button"
onClick={handleCopyCode}
className="px-3.5 py-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.06] border border-white/10 text-xs font-mono text-gray-300 transition-all flex items-center gap-1.5 cursor-pointer"
>
{copiedCode ? <Check className="w-3.5 h-3.5 text-[#00e676]" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedCode ? 'Código Copiado' : 'Copiar const VOTOS_EMBEBIDOS'}</span>
</button>
</div>
</div>
</div>
)}
</div>
);
};