feat: integrate Vite for frontend development and build process

- Added Vite scripts for development, build, and preview in package.json
- Created README.md with instructions for frontend setup
- Added client/index.html as the main entry point for the frontend
- Implemented client/src/main.js for handling file uploads and UI interactions
- Added client/src/styles.css for styling the frontend components
- Configured Vite in vite.config.js for building the frontend assets
This commit is contained in:
Cesar Mendivil 2026-03-21 20:27:18 -07:00
parent 5e55dbadd7
commit b0e088b9de
9 changed files with 1379 additions and 76 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules/
dist/

8
README.md Normal file
View File

@ -0,0 +1,8 @@
Frontend build (Vite)
- Development: run `npm run frontend:dev` in the project root, which starts Vite. Open the dev server URL printed by Vite (usually http://localhost:5173) and the backend at `http://localhost:8282`.
- Build: run `npm run frontend:build` which outputs files to `dist/`. The Express server will serve `dist/index.html` at `/admin` once `dist/` exists.
Notes:
- Install dev dependencies with `npm install` before running the scripts.
- The Vite build includes `@vitejs/plugin-legacy` for broader browser support.

20
client/index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin | Enlace Directo News</title>
<link rel="icon" href="/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/lucide.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script>window.addEventListener('load', ()=>{ if(window.lucide) lucide.replace() })</script>
</body>
</html>

111
client/src/main.js Normal file
View File

@ -0,0 +1,111 @@
import './styles.css'
function createNodeFromHTML(html) {
const template = document.createElement('template')
template.innerHTML = html.trim()
return template.content.firstChild
}
const markup = `
<div class="min-h-screen bg-[#09090b] text-zinc-100 flex items-center justify-center p-6 font-sans">
<div class="w-full max-w-md bg-zinc-900/50 border border-zinc-800 backdrop-blur-xl rounded-2xl p-8 shadow-2xl">
<header class="mb-8 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-500 mb-4 border border-emerald-500/20">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/><path d="m9 12 2 2 4-4"/></svg>
</div>
<h1 class="text-xl font-semibold tracking-tight">Enlace Directo News</h1>
<p class="text-zinc-500 text-sm mt-1">Panel de administración de sesiones</p>
</header>
<form id="uploadForm" class="space-y-6">
<div id="dropZone" class="group relative border-2 border-dashed border-zinc-800 hover:border-emerald-500/50 hover:bg-emerald-500/[0.02] rounded-xl p-8 transition-all duration-200 cursor-pointer text-center">
<input id="fileInput" type="file" name="cookies" accept=".txt" hidden />
<div class="flex flex-col items-center">
<div class="p-3 rounded-full bg-zinc-800 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 transition-colors mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
</div>
<p class="text-sm font-medium text-zinc-300">Arrastra tu <span class="text-emerald-500 text-xs font-mono">cookies.txt</span></p>
<p class="text-xs text-zinc-500 mt-1">o haz clic para explorar</p>
</div>
</div>
<div id="fileInfo" class="hidden flex items-center justify-between bg-zinc-800/50 border border-zinc-700 p-3 rounded-lg animate-in fade-in slide-in-from-bottom-2">
<div class="flex items-center gap-3">
<svg class="text-emerald-500" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
<span id="fileName" class="text-xs font-medium truncate max-w-[180px]"></span>
</div>
<button id="clearBtn" type="button" class="text-xs text-zinc-500 hover:text-red-400 transition-colors font-semibold px-2">Remover</button>
</div>
<button id="submitBtn" type="submit" class="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-semibold py-3 rounded-xl transition-all shadow-lg shadow-emerald-900/20 active:scale-[0.98]">
Actualizar Cookies
</button>
</form>
<div id="status" class="hidden mt-6 p-3 rounded-lg text-sm text-center border animate-in zoom-in-95"></div>
</div>
</div>
`
const root = document.getElementById('app')
root.appendChild(createNodeFromHTML(markup))
const dropZone = document.getElementById('dropZone')
const fileInput = document.getElementById('fileInput')
const uploadForm = document.getElementById('uploadForm')
const fileInfo = document.getElementById('fileInfo')
const fileName = document.getElementById('fileName')
const clearBtn = document.getElementById('clearBtn')
const status = document.getElementById('status')
function showStatus(text, ok = true) {
status.textContent = text
status.classList.remove('hidden')
// Limpiamos clases previas
status.className = 'mt-6 p-3 rounded-lg text-sm text-center border animate-in zoom-in-95'
if (ok) {
status.classList.add('bg-emerald-500/10', 'text-emerald-400', 'border-emerald-500/20')
} else {
status.classList.add('bg-red-500/10', 'text-red-400', 'border-red-500/20')
}
}
function updateFileInfo() {
if (fileInput.files && fileInput.files[0]) {
fileInfo.classList.remove('hidden')
fileName.textContent = fileInput.files[0].name
} else {
fileInfo.classList.add('hidden')
fileName.textContent = ''
}
}
dropZone.addEventListener('click', () => fileInput.click())
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('active') })
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('active'))
dropZone.addEventListener('drop', (e) => {
e.preventDefault()
dropZone.classList.remove('active')
if (e.dataTransfer.files && e.dataTransfer.files.length) fileInput.files = e.dataTransfer.files
updateFileInfo()
})
fileInput.addEventListener('change', updateFileInfo)
clearBtn.addEventListener('click', () => { fileInput.value = ''; updateFileInfo() })
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault()
if (!fileInput.files || !fileInput.files[0]) return showStatus('Selecciona un archivo .txt primero', false)
const fd = new FormData()
fd.append('cookies', fileInput.files[0])
try {
const res = await fetch('/upload-cookies', { method: 'POST', body: fd })
const json = await res.json()
if (res.ok) showStatus(json.message || 'Subida correcta')
else showStatus(json.message || json.error || 'Error al subir', false)
} catch (err) {
showStatus(err.message || 'Error de red', false)
}
})

13
client/src/styles.css Normal file
View File

@ -0,0 +1,13 @@
:root{--bg:#0f172a;--card:#0b1220;--muted:#94a3b8;--accent:#06b6d4}
*{box-sizing:border-box}
body{margin:0;font-family:Inter,system-ui,sans-serif;background:var(--bg);color:#f8fafc}
.container{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{width:100%;max-width:720px;background:#0f172a;border:1px solid rgba(255,255,255,0.04);padding:28px;border-radius:12px}
header h1{margin:0;font-size:20px}
.muted{color:var(--muted);font-size:12px}
.drop-zone{border:2px dashed rgba(148,163,184,0.08);padding:24px;border-radius:8px;text-align:center;cursor:pointer}
.drop-zone.active{border-color:var(--accent);background:#071127}
.file-info{display:flex;justify-content:space-between;align-items:center;padding:8px;margin-top:12px;border-radius:6px;background:#071127}
.hidden{display:none}
button{background:var(--accent);color:#042f2e;padding:10px 14px;border-radius:8px;border:0;cursor:pointer}
.status{margin-top:12px;padding:10px;border-radius:8px}

126
index.js
View File

@ -14,8 +14,12 @@ const { warmAndExportCookies } = require('./cookiewarmer');
const COOKIES_PATH = path.resolve(__dirname, 'cookies.txt');
// --- CONFIGURACIÓN DE MULTER (SUBIDA DE ARCHIVOS) ---
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, './'),
filename: (req, file, cb) => cb(null, 'cookies.txt')
destination: (req, file, cb) => {
const uploadDir = path.resolve(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
});
const upload = multer({
@ -125,74 +129,33 @@ app.get('/transcript/:id', (req, res) => {
});
// --- INTERFAZ UX (MODO OSCURO / TAILWIND) ---
app.get('/admin', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin | Enlace Directo News</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.drop-zone { border: 2px dashed #334155; transition: all 0.3s; }
.drop-zone.active { border-color: #38bdf8; background: #1e293b; }
.advice-card { background: linear-gradient(145deg, rgba(245, 158, 11, 0.1), rgba(245, 158, 11, 0.05)); }
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-md bg-slate-800 p-8 rounded-2xl shadow-2xl border border-slate-700">
<header class="text-center mb-8">
<div class="inline-block p-3 bg-sky-500/10 rounded-full mb-4">
<i class="fa-solid fa-bolt-lightning text-sky-400 text-2xl"></i>
</div>
<h1 class="text-2xl font-bold text-white">Enlace Directo News</h1>
<p class="text-slate-400 text-sm">Gestor de Sesión de YouTube</p>
</header>
<form id="uploadForm" class="space-y-6">
<div id="dropZone" class="drop-zone p-10 rounded-xl text-center cursor-pointer group">
<i class="fa-solid fa-file-code text-4xl mb-4 text-slate-500 group-hover:text-sky-400 transition-colors"></i>
<p class="text-sm text-slate-300">Arrastra tu <b>cookies.txt</b> aquí<br><span class="text-slate-500 text-xs">o haz clic para buscar</span></p>
<input type="file" id="fileInput" name="cookies" accept=".txt" class="hidden">
</div>
<div class="advice-card border border-amber-500/20 rounded-xl p-4 flex items-start gap-3">
<div class="text-amber-500 mt-0.5">
<i class="fa-solid fa-circle-info"></i>
</div>
<div class="flex-1">
<p class="text-slate-300 text-[13px] leading-snug">
<strong class="text-amber-400">Tip Pro:</strong> Mira un video por 15 segundos antes de exportar. Esto "calienta" las cookies y reduce bloqueos de bot.
</p>
</div>
</div>
<div id="fileInfo" class="hidden bg-slate-900/50 p-3 rounded-lg text-xs flex items-center justify-between border border-slate-700">
<span id="fileName" class="truncate pr-4 text-sky-200 font-mono"></span>
<button type="button" id="clearBtn" class="text-rose-400 hover:text-rose-300"><i class="fa-solid fa-trash"></i></button>
</div>
<button type="submit" id="submitBtn" class="w-full bg-sky-600 hover:bg-sky-500 text-white font-semibold py-3 rounded-xl transition-all shadow-lg shadow-sky-900/20 active:scale-95">
<i class="fa-solid fa-sync mr-2"></i>Actualizar Cookies
</button>
</form>
<div id="status" class="mt-6 text-center text-sm font-medium hidden p-3 rounded-lg"></div>
<footer class="mt-8 pt-6 border-t border-slate-700 text-center">
<p class="text-slate-500 text-[10px] uppercase tracking-widest font-bold">Bot Profile v2.0 | Ubuntu Environment</p>
</footer>
</div>
<script> ... </script>
</body>
</html>
`);
});
// --- INTERFAZ UX (MODO OSCURO / TAILWIND) ---
// Serve a built frontend from `dist` when available. During development run the Vite dev server.
const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.get('/admin', (req, res) => res.sendFile(path.join(distPath, 'index.html')));
} else {
app.get('/admin', (req, res) => {
res.send(`
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin - Build missing</title>
</head>
<body style="background:#0f172a;color:#f8fafc;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;">
<div style="text-align:center;max-width:640px;padding:24px;">
<h1>Frontend not built</h1>
<p>Run <code>npm run build</code> to build the admin frontend, or run <code>npm run dev</code> during development and open the Vite dev server.</p>
</div>
</body>
</html>
`);
});
}
// --- ENDPOINT: SUBIDA Y VALIDACIÓN ---
app.post('/upload-cookies', upload.single('cookies'), (req, res) => {
@ -201,17 +164,31 @@ app.post('/upload-cookies', upload.single('cookies'), (req, res) => {
return res.status(400).json({ status: "error", message: "No se seleccionó archivo." });
}
const content = fs.readFileSync(COOKIES_PATH, 'utf8');
const tempPath = req.file.path;
const content = fs.readFileSync(tempPath, 'utf8');
// Validación estricta del formato Netscape
if (!content.includes('# Netscape HTTP Cookie File')) {
fs.unlinkSync(COOKIES_PATH); // Borramos el inválido
fs.unlinkSync(tempPath); // Borramos el inválido
return res.status(400).json({
status: "error",
message: "Formato inválido. Debe ser un archivo de cookies Netscape."
});
}
// Move the validated upload into place atomically
try {
fs.copyFileSync(tempPath, COOKIES_PATH);
fs.unlinkSync(tempPath);
} catch (mvErr) {
// If copy or unlink fails, attempt a rename as fallback
try {
fs.renameSync(tempPath, COOKIES_PATH);
} catch (renameErr) {
return res.status(500).json({ status: "error", message: 'Error al guardar cookies: ' + (mvErr.message || renameErr.message) });
}
}
res.json({ status: "success", message: "cookies.txt actualizado." });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
@ -234,6 +211,13 @@ app.get('/refresh-cookies', async (req, res) => {
});
// --- INICIO DEL SERVIDOR ---
// Servir un favicon simple (1x1 PNG) para evitar 404 en el navegador
app.get('/favicon.ico', (req, res) => {
const img = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', 'base64');
res.set('Content-Type', 'image/png');
res.send(img);
});
app.listen(PORT, () => {
console.log(`
🚀 API Enlace Directo News activa

1151
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,16 @@
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"frontend:dev": "vite",
"frontend:build": "vite build",
"frontend:preview": "vite preview",
"clean-cookies": "sed -i 's/^[^#]*#/#/' cookies.txt",
"warm": "node -e 'require(\"./cookiewarmer.js\").warmAndExportCookies(\"PzWTM7YvGag\")'",
"install-browsers": "npx playwright install chromium",
"check-runtime": "/home/xesar/.nvm/versions/node/v20.19.4/bin/node --version",
"refresh": "node cookiewarmer.js",
"postinstall": "npx playwright install chromium",
"open-bot":"/usr/bin/google-chrome --user-data-dir=/home/xesar/bot-profile --no-first-run"
"open-bot": "/usr/bin/google-chrome --user-data-dir=/home/xesar/bot-profile --no-first-run"
},
"keywords": [
"youtube-dl",
@ -24,12 +27,16 @@
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"playwright": "^1.58.2",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"multer": "^1.4.5-lts.1"
"puppeteer-extra-plugin-stealth": "^2.11.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
"autoprefixer": "^10.4.27",
"nodemon": "^3.0.1",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"vite": "^5.2.0"
}
}

10
vite.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
root: path.join(__dirname, 'client'),
build: {
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true
}
})