feat: migración a PostgreSQL, tiempo real SSE, Dockerfile y compose para Coolify
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user