Mejorar un poquito el diseño y meterle voz usando whisper
This commit is contained in:
parent
0366137e96
commit
b67d3a8454
File diff suppressed because it is too large
Load Diff
|
|
@ -12,6 +12,7 @@
|
|||
"vite": "^7.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,27 @@ export function renderLogin(onLogin) {
|
|||
|
||||
loginContainer.innerHTML = `
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">Iniciar Sesión</h2>
|
||||
<div class="login-brand">
|
||||
<div class="login-logo-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="login-brand-name">Doli</span>
|
||||
</div>
|
||||
<h2 class="login-title">Iniciar sesión</h2>
|
||||
<p class="login-subtitle">Accede a tu cuenta para continuar</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="identifier">Usuario o email</label>
|
||||
<input type="text" id="identifier" name="identifier" required autocomplete="username">
|
||||
<input type="text" id="identifier" name="identifier" required autocomplete="username" placeholder="tu@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Contraseña</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="login-button">Ingresar</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,17 @@ export function createSidebar(pages, currentPage) {
|
|||
const sidebarHeader = document.createElement('div');
|
||||
sidebarHeader.className = 'sidebar-header';
|
||||
sidebarHeader.innerHTML = /*html*/`
|
||||
<h2>Doli</h2>
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-logo-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Doli</h2>
|
||||
</div>
|
||||
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
|
|
@ -38,10 +48,13 @@ export function createSidebar(pages, currentPage) {
|
|||
sidebar.appendChild(sidebarHeader);
|
||||
sidebar.appendChild(nav);
|
||||
|
||||
// Toggle functionality
|
||||
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
document.dispatchEvent(new CustomEvent('sidebar:close-mobile'));
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
return sidebar;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
import { pipeline } from '@huggingface/transformers';
|
||||
|
||||
// ── Modelo (singleton entre sesiones) ─────────────────────────────────────────
|
||||
// Xenova/whisper-tiny + transformers.js v3 = WASM puro, sin WebGPU, todos los navegadores
|
||||
let transcriberPromise = null;
|
||||
|
||||
function loadModel(onProgress) {
|
||||
if (!transcriberPromise) {
|
||||
transcriberPromise = pipeline(
|
||||
'automatic-speech-recognition',
|
||||
'Xenova/whisper-tiny',
|
||||
{ progress_callback: onProgress }
|
||||
).catch(err => { transcriberPromise = null; throw err; });
|
||||
}
|
||||
return transcriberPromise;
|
||||
}
|
||||
|
||||
// ── Comandos de voz ───────────────────────────────────────────────────────────
|
||||
const COMMANDS = [
|
||||
{
|
||||
label: 'Nueva factura',
|
||||
hint: '"nueva factura" · "crear factura"',
|
||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>`,
|
||||
patterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura', 'nueva facturas'],
|
||||
action: () => { window.location.hash = '#create-invoice'; },
|
||||
},
|
||||
{
|
||||
label: 'Ver facturas',
|
||||
hint: '"facturas" · "ver facturas"',
|
||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
||||
patterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'],
|
||||
action: () => { window.location.hash = '#invoices'; },
|
||||
},
|
||||
{
|
||||
label: 'Ver clientes',
|
||||
hint: '"clientes" · "ver clientes"',
|
||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||
patterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'],
|
||||
action: () => { window.location.hash = '#clients'; },
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
hint: '"dashboard" · "inicio" · "resumen"',
|
||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
|
||||
patterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'],
|
||||
action: () => { window.location.hash = '#dashboard'; },
|
||||
},
|
||||
{
|
||||
label: 'Configuración',
|
||||
hint: '"configuración" · "ajustes"',
|
||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
||||
patterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'],
|
||||
action: () => { window.location.hash = '#settings'; },
|
||||
},
|
||||
];
|
||||
|
||||
function matchCommand(text) {
|
||||
const norm = text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
|
||||
for (const cmd of COMMANDS) {
|
||||
for (const pattern of cmd.patterns) {
|
||||
const normPattern = pattern.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
if (norm.includes(normPattern)) return cmd;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Grabación ─────────────────────────────────────────────────────────────────
|
||||
async function startRecording() {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
|
||||
.find(t => MediaRecorder.isTypeSupported(t)) || '';
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
const chunks = [];
|
||||
recorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
|
||||
recorder.start(200);
|
||||
return {
|
||||
stop: () => new Promise(resolve => {
|
||||
recorder.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
resolve(new Blob(chunks, { type: mimeType || 'audio/webm' }));
|
||||
};
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
else resolve(new Blob(chunks));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function blobToFloat32(blob) {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
if (arrayBuffer.byteLength === 0) throw new Error('Blob de audio vacío');
|
||||
|
||||
// 1) Decodificar al sample rate nativo del archivo (p.ej. 44100 Hz)
|
||||
const decodeCtx = new AudioContext();
|
||||
let nativeBuffer;
|
||||
try {
|
||||
nativeBuffer = await decodeCtx.decodeAudioData(arrayBuffer);
|
||||
} finally {
|
||||
await decodeCtx.close();
|
||||
}
|
||||
|
||||
const srcRate = nativeBuffer.sampleRate;
|
||||
const srcData = nativeBuffer.getChannelData(0); // mono
|
||||
|
||||
// 2) Si ya está a 16 kHz no hace falta resampling
|
||||
if (srcRate === 16000) return srcData;
|
||||
|
||||
// 3) Resampling a 16 kHz con OfflineAudioContext
|
||||
const targetLength = Math.ceil(srcData.length * (16000 / srcRate));
|
||||
const offCtx = new OfflineAudioContext(1, targetLength, 16000);
|
||||
|
||||
const srcBuf = offCtx.createBuffer(1, srcData.length, srcRate);
|
||||
srcBuf.getChannelData(0).set(srcData);
|
||||
|
||||
const source = offCtx.createBufferSource();
|
||||
source.buffer = srcBuf;
|
||||
source.connect(offCtx.destination);
|
||||
source.start(0);
|
||||
|
||||
const resampled = await offCtx.startRendering();
|
||||
return resampled.getChannelData(0);
|
||||
}
|
||||
|
||||
// ── Componente ────────────────────────────────────────────────────────────────
|
||||
let assistantEl = null;
|
||||
|
||||
export function mountVoiceAssistant() {
|
||||
if (assistantEl) { assistantEl.style.display = ''; return; }
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return;
|
||||
|
||||
// ── DOM ────────────────────────────────────────────────────────────────────
|
||||
assistantEl = document.createElement('div');
|
||||
assistantEl.className = 'va-root';
|
||||
assistantEl.innerHTML = /*html*/`
|
||||
<div class="va-panel" id="va-panel" hidden>
|
||||
<div class="va-panel-header">
|
||||
<span class="va-panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
Asistente de voz
|
||||
</span>
|
||||
<button class="va-close-btn" id="va-close" aria-label="Cerrar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="va-status-area" id="va-status-area">
|
||||
<div class="va-status-icon" id="va-status-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</div>
|
||||
<p class="va-status-text" id="va-status-text">Di un comando…</p>
|
||||
<p class="va-status-sub" id="va-status-sub"></p>
|
||||
</div>
|
||||
|
||||
<div class="va-result" id="va-result" hidden>
|
||||
<span class="va-result-label">Escuché:</span>
|
||||
<span class="va-result-text" id="va-result-text"></span>
|
||||
<span class="va-result-action" id="va-result-action"></span>
|
||||
</div>
|
||||
|
||||
<div class="va-commands">
|
||||
<p class="va-commands-title">Comandos disponibles</p>
|
||||
<ul class="va-commands-list" id="va-commands-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="va-fab" id="va-fab" aria-label="Asistente de voz">
|
||||
<svg class="va-fab-mic" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
<svg class="va-fab-stop" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:none"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(assistantEl);
|
||||
|
||||
// ── Poblar lista de comandos ────────────────────────────────────────────────
|
||||
const cmdList = assistantEl.querySelector('#va-commands-list');
|
||||
COMMANDS.forEach(cmd => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'va-cmd-item';
|
||||
li.innerHTML = /*html*/`
|
||||
<span class="va-cmd-icon">${cmd.icon}</span>
|
||||
<span class="va-cmd-info">
|
||||
<span class="va-cmd-label">${cmd.label}</span>
|
||||
<span class="va-cmd-hint">${cmd.hint}</span>
|
||||
</span>
|
||||
`;
|
||||
li.addEventListener('click', () => { cmd.action(); closePanel(); });
|
||||
cmdList.appendChild(li);
|
||||
});
|
||||
|
||||
// ── Referencias ────────────────────────────────────────────────────────────
|
||||
const fab = assistantEl.querySelector('#va-fab');
|
||||
const panel = assistantEl.querySelector('#va-panel');
|
||||
const closeBtn = assistantEl.querySelector('#va-close');
|
||||
const statusIcon = assistantEl.querySelector('#va-status-icon');
|
||||
const statusText = assistantEl.querySelector('#va-status-text');
|
||||
const statusSub = assistantEl.querySelector('#va-status-sub');
|
||||
const resultBox = assistantEl.querySelector('#va-result');
|
||||
const resultText = assistantEl.querySelector('#va-result-text');
|
||||
const resultAction = assistantEl.querySelector('#va-result-action');
|
||||
const fabMic = fab.querySelector('.va-fab-mic');
|
||||
const fabStop = fab.querySelector('.va-fab-stop');
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────────────────────
|
||||
let state = 'idle'; // idle | recording | processing
|
||||
let recorder = null;
|
||||
let panelOpen = false;
|
||||
|
||||
function setStatus(s, text, sub = '') {
|
||||
state = s;
|
||||
statusText.textContent = text;
|
||||
statusSub.textContent = sub;
|
||||
statusIcon.className = `va-status-icon va-status-icon--${s}`;
|
||||
fab.className = `va-fab va-fab--${s}`;
|
||||
fabMic.style.display = (s === 'recording') ? 'none' : '';
|
||||
fabStop.style.display = (s === 'recording') ? '' : 'none';
|
||||
}
|
||||
|
||||
function showResult(heard, actionLabel) {
|
||||
resultBox.hidden = false;
|
||||
resultText.textContent = `"${heard}"`;
|
||||
resultAction.textContent = actionLabel ? `→ ${actionLabel}` : '→ Comando no reconocido';
|
||||
resultAction.className = 'va-result-action' + (actionLabel ? '' : ' va-result-action--unknown');
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panel.hidden = false;
|
||||
panelOpen = true;
|
||||
resultBox.hidden = true;
|
||||
fab.setAttribute('aria-label', 'Hablar');
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
panelOpen = false;
|
||||
if (state === 'recording' && recorder) {
|
||||
recorder.stop().catch(() => {});
|
||||
recorder = null;
|
||||
}
|
||||
setStatus('idle', 'Di un comando…');
|
||||
}
|
||||
|
||||
// ── Lógica principal ───────────────────────────────────────────────────────
|
||||
fab.addEventListener('click', async () => {
|
||||
if (!panelOpen) { openPanel(); return; }
|
||||
|
||||
if (state === 'recording') {
|
||||
// Detener grabación
|
||||
setStatus('processing', 'Procesando audio…');
|
||||
const blob = await recorder.stop();
|
||||
recorder = null;
|
||||
await transcribe(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== 'idle') return;
|
||||
|
||||
// Iniciar grabación
|
||||
try {
|
||||
recorder = await startRecording();
|
||||
setStatus('recording', 'Escuchando…', 'Clic para detener');
|
||||
resultBox.hidden = true;
|
||||
// Pre-carga el modelo mientras graba (sin bloquear)
|
||||
if (!transcriberPromise) loadModel();
|
||||
} catch (e) {
|
||||
console.error('[VA] Micrófono:', e);
|
||||
setStatus('idle', 'Sin acceso al micrófono');
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', closePanel);
|
||||
|
||||
async function transcribe(blob) {
|
||||
try {
|
||||
// Carga el modelo si aún no está listo
|
||||
setStatus('processing', 'Cargando modelo…', 'Solo la primera vez (~40 MB)');
|
||||
const transcriber = await loadModel(prog => {
|
||||
if (prog.status === 'progress') {
|
||||
const pct = Math.round(prog.progress ?? 0);
|
||||
statusSub.textContent = `Descargando: ${pct}%`;
|
||||
}
|
||||
});
|
||||
|
||||
setStatus('processing', 'Transcribiendo…', '');
|
||||
|
||||
let audioData;
|
||||
try {
|
||||
audioData = await blobToFloat32(blob);
|
||||
} catch (e) {
|
||||
console.error('[VA] Error decodificando audio:', e);
|
||||
setStatus('idle', 'Error al leer el audio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioData.length < 3200) { // < 0.2s de audio
|
||||
setStatus('idle', 'Muy corto, inténtalo de nuevo');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await transcriber(audioData, { language: 'spanish', task: 'transcribe' });
|
||||
const text = (result?.text ?? '').trim();
|
||||
|
||||
if (!text) {
|
||||
setStatus('idle', 'No se detectó voz');
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = matchCommand(text);
|
||||
showResult(text, cmd?.label ?? '');
|
||||
setStatus('idle', 'Di un comando…');
|
||||
|
||||
if (cmd) {
|
||||
setTimeout(() => { cmd.action(); }, 600);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VA] Error en transcripción:', e?.message ?? e);
|
||||
const msg = e?.message?.includes('vacío') ? 'Audio vacío, graba más tiempo'
|
||||
: e?.message?.includes('session') ? 'Error cargando el modelo'
|
||||
: e?.message?.includes('decodeAudioData') ? 'Formato de audio no soportado'
|
||||
: 'Error al procesar';
|
||||
setStatus('idle', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hideVoiceAssistant() {
|
||||
if (assistantEl) assistantEl.style.display = 'none';
|
||||
}
|
||||
|
||||
export function showVoiceAssistant() {
|
||||
if (assistantEl) assistantEl.style.display = '';
|
||||
else mountVoiceAssistant();
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import './styles/facturas.css'
|
|||
import './styles/clients.css'
|
||||
import './styles/create-invoice.css'
|
||||
import './styles/settings.css'
|
||||
import './styles/voice-assistant.css'
|
||||
import { initRouter } from './router.js'
|
||||
import { initSessionManager } from './services/session.js'
|
||||
import { initTheme } from './services/theme.js'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createSidebar } from './components/Sidebar.js';
|
|||
import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js';
|
||||
import { showConfirmExitModal } from './components/ConfirmExitModal.js';
|
||||
import { applyLoginTheme, applySavedTheme } from './services/theme.js';
|
||||
import { hideVoiceAssistant, showVoiceAssistant } from './components/VoiceAssistant.js';
|
||||
|
||||
// 🔧 Modo DEV: cambiar a false para activar login
|
||||
const DEV_MODE = false;
|
||||
|
|
@ -80,8 +81,10 @@ export function initRouter() {
|
|||
|
||||
if (hash === '#login') {
|
||||
applyLoginTheme();
|
||||
hideVoiceAssistant();
|
||||
} else {
|
||||
applySavedTheme();
|
||||
showVoiceAssistant();
|
||||
}
|
||||
|
||||
app.innerHTML = '';
|
||||
|
|
@ -118,12 +121,47 @@ export function initRouter() {
|
|||
// Render page content using registry
|
||||
const pageContent = page.render();
|
||||
|
||||
if (pageContent && typeof pageContent.cleanup === 'function') {
|
||||
currentPageCleanup = pageContent.cleanup;
|
||||
}
|
||||
// Mobile sidebar: backdrop + topbar
|
||||
const sidebarBackdrop = document.createElement('div');
|
||||
sidebarBackdrop.className = 'sidebar-backdrop';
|
||||
|
||||
const openMobileSidebar = () => {
|
||||
sidebar.classList.add('mobile-open');
|
||||
sidebarBackdrop.classList.add('visible');
|
||||
};
|
||||
const closeMobileSidebar = () => {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
sidebarBackdrop.classList.remove('visible');
|
||||
};
|
||||
const handleMobileClose = () => closeMobileSidebar();
|
||||
document.addEventListener('sidebar:close-mobile', handleMobileClose);
|
||||
sidebarBackdrop.addEventListener('click', closeMobileSidebar);
|
||||
|
||||
const mobileTopbar = document.createElement('header');
|
||||
mobileTopbar.className = 'mobile-topbar';
|
||||
mobileTopbar.innerHTML = /*html*/`
|
||||
<button class="mobile-menu-btn" aria-label="Abrir menú">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="mobile-topbar-title">${page.name}</span>
|
||||
`;
|
||||
mobileTopbar.querySelector('.mobile-menu-btn').addEventListener('click', openMobileSidebar);
|
||||
|
||||
// Chain page cleanup with sidebar event listener cleanup
|
||||
const pageCleanup = pageContent && typeof pageContent.cleanup === 'function' ? pageContent.cleanup : null;
|
||||
currentPageCleanup = () => {
|
||||
document.removeEventListener('sidebar:close-mobile', handleMobileClose);
|
||||
if (pageCleanup) pageCleanup();
|
||||
};
|
||||
|
||||
mainContent.appendChild(mobileTopbar);
|
||||
mainContent.appendChild(pageContent);
|
||||
layout.appendChild(sidebar);
|
||||
layout.appendChild(sidebarBackdrop);
|
||||
layout.appendChild(mainContent);
|
||||
app.appendChild(layout);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import { pipeline } from '@huggingface/transformers';
|
||||
|
||||
// ── Iconos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MIC_ICON = /*html*/`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>`;
|
||||
|
||||
const STOP_ICON = /*html*/`
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>`;
|
||||
|
||||
const SPIN_ICON = /*html*/`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 2a10 10 0 0 1 10 10" class="voice-spin"/>
|
||||
</svg>`;
|
||||
|
||||
// ── Modelo (singleton) ────────────────────────────────────────────────────────
|
||||
|
||||
let transcriberPromise = null;
|
||||
|
||||
function loadModel(onProgress) {
|
||||
if (!transcriberPromise) {
|
||||
transcriberPromise = pipeline(
|
||||
'automatic-speech-recognition',
|
||||
'Xenova/whisper-tiny',
|
||||
{ progress_callback: onProgress }
|
||||
).catch(err => {
|
||||
transcriberPromise = null; // permitir reintento
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return transcriberPromise;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function showTip(btn, text, duration = 3000) {
|
||||
btn.querySelectorAll('.voice-tip').forEach(t => t.remove());
|
||||
const tip = document.createElement('span');
|
||||
tip.className = 'voice-tip';
|
||||
tip.textContent = text;
|
||||
btn.appendChild(tip);
|
||||
if (duration > 0) setTimeout(() => tip.remove(), duration);
|
||||
return tip;
|
||||
}
|
||||
|
||||
async function startMediaRecorder() {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
|
||||
.find(t => MediaRecorder.isTypeSupported(t)) || '';
|
||||
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
const chunks = [];
|
||||
recorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
|
||||
recorder.start(200);
|
||||
|
||||
return {
|
||||
stop: () => new Promise(resolve => {
|
||||
recorder.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
resolve(new Blob(chunks, { type: mimeType || 'audio/webm' }));
|
||||
};
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
else resolve(new Blob(chunks));
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function blobToFloat32(blob) {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const ctx = new AudioContext({ sampleRate: 16000 });
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||
await ctx.close();
|
||||
return audioBuffer.getChannelData(0); // mono Float32Array a 16kHz
|
||||
}
|
||||
|
||||
// ── API pública ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adjunta un botón de voz (Whisper local) a un input.
|
||||
* Devuelve el botón, o null si el navegador no soporta MediaRecorder.
|
||||
*/
|
||||
export function attachVoiceInput(inputEl, { lang = 'es' } = {}) {
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||
console.warn('[VoiceInput] MediaRecorder no disponible en este navegador.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'voice-btn';
|
||||
btn.setAttribute('aria-label', 'Buscar por voz');
|
||||
btn.title = 'Buscar por voz (Whisper local)';
|
||||
btn.innerHTML = MIC_ICON;
|
||||
|
||||
let mediaRecorder = null; // { stop() }
|
||||
let state = 'idle'; // idle | recording | processing
|
||||
|
||||
const setState = (s) => {
|
||||
state = s;
|
||||
btn.className = `voice-btn voice-btn--${s}`;
|
||||
if (s === 'idle') { btn.innerHTML = MIC_ICON; btn.disabled = false; }
|
||||
if (s === 'recording') { btn.innerHTML = STOP_ICON; btn.disabled = false; }
|
||||
if (s === 'processing') { btn.innerHTML = SPIN_ICON; btn.disabled = true; }
|
||||
};
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
// ── Detener grabación ──────────────────────────────────────────────────
|
||||
if (state === 'recording') {
|
||||
showTip(btn, 'Procesando…', 0);
|
||||
setState('processing');
|
||||
const blob = await mediaRecorder.stop();
|
||||
mediaRecorder = null;
|
||||
await transcribe(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== 'idle') return;
|
||||
|
||||
// ── Iniciar grabación ──────────────────────────────────────────────────
|
||||
try {
|
||||
mediaRecorder = await startMediaRecorder();
|
||||
setState('recording');
|
||||
showTip(btn, 'Hablando… clic para detener');
|
||||
|
||||
// Pre-carga el modelo en paralelo mientras el usuario habla
|
||||
if (!transcriberPromise) loadModel();
|
||||
} catch (e) {
|
||||
console.error('[VoiceInput] Error al acceder al micrófono:', e);
|
||||
showTip(btn, 'Sin acceso al micrófono');
|
||||
setState('idle');
|
||||
}
|
||||
});
|
||||
|
||||
async function transcribe(blob) {
|
||||
try {
|
||||
// Carga / espera el modelo (primera vez descarga ~40 MB, se cachea)
|
||||
const tipLoad = showTip(btn, 'Cargando modelo…', 0);
|
||||
const transcriber = await loadModel(progress => {
|
||||
if (progress.status === 'progress') {
|
||||
const pct = Math.round(progress.progress ?? 0);
|
||||
btn.title = `Cargando modelo: ${pct}%`;
|
||||
}
|
||||
});
|
||||
tipLoad.remove();
|
||||
|
||||
showTip(btn, 'Transcribiendo…', 0);
|
||||
const audioData = await blobToFloat32(blob);
|
||||
const result = await transcriber(audioData, { language: lang, task: 'transcribe' });
|
||||
const text = (result.text ?? '').trim();
|
||||
|
||||
if (text) {
|
||||
inputEl.value = text;
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inputEl.focus();
|
||||
showTip(btn, `"${text}"`);
|
||||
} else {
|
||||
showTip(btn, 'No se detectó texto');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VoiceInput] Error en transcripción:', e);
|
||||
showTip(btn, 'Error al transcribir');
|
||||
} finally {
|
||||
btn.title = 'Buscar por voz';
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envuelve el input en .search-wrapper y adjunta el botón de voz.
|
||||
*/
|
||||
export function wrapWithVoice(inputEl, options = {}) {
|
||||
const voiceBtn = attachVoiceInput(inputEl, options);
|
||||
if (!voiceBtn) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'search-wrapper';
|
||||
inputEl.parentNode.insertBefore(wrapper, inputEl);
|
||||
wrapper.appendChild(inputEl);
|
||||
wrapper.appendChild(voiceBtn);
|
||||
}
|
||||
|
|
@ -333,6 +333,97 @@ button:disabled {
|
|||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
Voice Input
|
||||
============================================ */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.search-wrapper .search-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.clientes-filters .search-wrapper {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--gray-400);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-btn:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
background: var(--blue-50);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.voice-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Estado: grabando */
|
||||
.voice-btn--recording {
|
||||
color: var(--red-500);
|
||||
animation: voice-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Estado: procesando */
|
||||
.voice-btn--processing .voice-spin {
|
||||
transform-origin: center;
|
||||
animation: voice-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.25); }
|
||||
}
|
||||
|
||||
@keyframes voice-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tooltip flotante del botón de voz */
|
||||
.voice-tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--gray-900);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
|
|
|||
|
|
@ -236,6 +236,12 @@
|
|||
background-color: rgba(30, 58, 110, 0.24);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.facturas-page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.facturas-filters {
|
||||
flex-direction: column;
|
||||
|
|
@ -263,8 +269,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 640px) {
|
||||
.facturas-page {
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
/* Scroll horizontal suave en la tabla */
|
||||
.invoices-table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.invoices-table {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
/* Ocultar columnas menos importantes en pantallas pequeñas */
|
||||
.invoices-table th.invoice-date,
|
||||
.invoices-table td.invoice-date,
|
||||
.invoices-table th.invoice-remain,
|
||||
.invoices-table td.invoice-remain {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,27 +8,60 @@
|
|||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--bg-page);
|
||||
background: linear-gradient(145deg, var(--bg-page) 0%, var(--blue-50) 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--card-bg);
|
||||
padding: var(--space-8);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-width: 390px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.login-logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-brand-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-6) 0;
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
|
@ -169,6 +202,11 @@
|
|||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-card {
|
||||
margin: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.session-warning-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -177,3 +215,7 @@
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .login-container {
|
||||
background: linear-gradient(145deg, var(--bg-page) 0%, rgba(30, 58, 110, 0.2) 100%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,26 @@
|
|||
height: 52px;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
|
|
@ -137,7 +157,7 @@
|
|||
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
|
||||
.sidebar.collapsed .sidebar-header h2,
|
||||
.sidebar.collapsed .sidebar-brand h2,
|
||||
.sidebar.collapsed .sidebar-text {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
|
|
@ -164,22 +184,110 @@
|
|||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* ── Mobile topbar (hidden on desktop) ── */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Sidebar backdrop ── */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 999;
|
||||
animation: backdropFadeIn 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-backdrop.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes backdropFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Sidebar: oculta por defecto, se muestra con .mobile-open */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
transform: translateX(0);
|
||||
transform: translateX(-100%);
|
||||
width: 220px !important;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Anular collapsed en móvil para que el contenido se vea bien cuando se abre */
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
width: 220px;
|
||||
width: 220px !important;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-brand h2,
|
||||
.sidebar.collapsed .sidebar-text {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-link {
|
||||
justify-content: flex-start;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Topbar móvil */
|
||||
.mobile-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
height: 52px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-topbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
/* ============================================================
|
||||
Voice Assistant — Floating button + panel
|
||||
============================================================ */
|
||||
|
||||
.va-root {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── FAB ─────────────────────────────────────────────────── */
|
||||
.va-fab {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.va-fab:hover {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.55);
|
||||
transform: scale(1.06);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.va-fab--recording {
|
||||
background: var(--red-500) !important;
|
||||
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45) !important;
|
||||
animation: va-pulse-fab 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.va-fab--processing {
|
||||
background: var(--amber-600) !important;
|
||||
box-shadow: 0 4px 14px rgba(217, 119, 6, 0.4) !important;
|
||||
}
|
||||
|
||||
@keyframes va-pulse-fab {
|
||||
0%, 100% { box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45); }
|
||||
50% { box-shadow: 0 4px 22px rgba(239, 68, 68, 0.75); transform: scale(1.06); }
|
||||
}
|
||||
|
||||
/* ── Panel ───────────────────────────────────────────────── */
|
||||
.va-panel {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
width: 290px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
animation: va-slide-up 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes va-slide-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.va-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.va-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.va-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.va-close-btn:hover {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Área de estado ──────────────────────────────────────── */
|
||||
.va-status-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.va-status-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.va-status-icon--recording {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--red-500);
|
||||
animation: va-pulse-icon 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.va-status-icon--processing {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
color: var(--amber-600);
|
||||
}
|
||||
|
||||
@keyframes va-pulse-icon {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.12); }
|
||||
}
|
||||
|
||||
.va-status-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.va-status-sub {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* ── Resultado ───────────────────────────────────────────── */
|
||||
.va-result {
|
||||
margin: 0 12px 12px;
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.va-result-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.va-result-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.va-result-action {
|
||||
font-size: 12px;
|
||||
color: var(--green-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.va-result-action--unknown {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Lista de comandos ───────────────────────────────────── */
|
||||
.va-commands {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 10px 0 4px;
|
||||
}
|
||||
|
||||
.va-commands-title {
|
||||
margin: 0 0 6px;
|
||||
padding: 0 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.va-commands-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.va-cmd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.va-cmd-item:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.va-cmd-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--blue-50);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.va-cmd-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.va-cmd-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.va-cmd-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Dark mode ───────────────────────────────────────────── */
|
||||
:root[data-theme='dark'] .va-panel {
|
||||
background: #081226;
|
||||
border-color: #1e2f4d;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .va-close-btn:hover,
|
||||
:root[data-theme='dark'] .va-cmd-item:hover {
|
||||
background: rgba(30, 58, 110, 0.3);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .va-result {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border-color: #1e2f4d;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .va-status-icon {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .va-cmd-icon {
|
||||
background: rgba(30, 58, 110, 0.4);
|
||||
}
|
||||
|
||||
/* ── Móvil ───────────────────────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
.va-root {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.va-panel {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 290px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
exclude: ['@huggingface/transformers'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue