lazier

personal summarizer
Log | Files | Refs | README

commit 4a65626314b4ebc0e8bc9761a5362f938a7563b8
parent eaaffd9109107ba84b5b1a85c3d75e720a26a218
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Tue,  5 May 2026 17:18:18 -0300

klance

Diffstat:
M.env.example | 9+--------
Dlazier/api/auth_routes.py | 180-------------------------------------------------------------------------------
Mlazier/api/main.py | 59+++++++++--------------------------------------------------
Dlazier/api/middleware.py | 74--------------------------------------------------------------------------
Mlazier/api/websocket.py | 62+++++++++++++-------------------------------------------------
Dlazier/core/auth.py | 247-------------------------------------------------------------------------------
Mlazier/web/templates/index.html | 471+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Dlazier/web/templates/login.html | 162-------------------------------------------------------------------------------
Mrequirements.txt | 2--
Mtests/test_api.py | 5-----
10 files changed, 328 insertions(+), 943 deletions(-)

diff --git a/.env.example b/.env.example @@ -2,14 +2,7 @@ # Obtenha sua chave em: https://platform.openai.com/api-keys OPENAI_API_KEY= -# Autenticação -# Gere uma chave secreta com: openssl rand -hex 32 -SESSION_SECRET_KEY= - -# Usuário Admin (opcional, mas recomendado) -# Se o banco estiver vazio e essas variáveis existirem, um usuário admin será criado automaticamente -ADMIN_USER= -ADMIN_PASSWORD= +# A WebGUI local não usa login nem sessão (uso pessoal). # Opcional: YouTube PO Token para acesso a formatos Android # Obtenha em: https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide diff --git a/lazier/api/auth_routes.py b/lazier/api/auth_routes.py @@ -1,180 +0,0 @@ -""" -Rotas de autenticação (login/logout) -""" - -from fastapi import APIRouter, Request, Form, HTTPException -from fastapi.responses import RedirectResponse, HTMLResponse, FileResponse -from pathlib import Path -from typing import Optional - -from ..core.auth import verify_password, init_db - -router = APIRouter() - -# Diretório de templates -templates_dir = Path(__file__).parent.parent / "web" / "templates" - -# Marcador único em login.html para injeção de erro (evita replace frágil em </form>) -_LOGIN_ERROR_PLACEHOLDER = '<div id="login-error" class="error" role="alert" hidden></div>' -_LOGIN_ERROR_VISIBLE = ( - '<div id="login-error" class="error" role="alert">Usuário ou senha incorretos.</div>' -) - - -@router.get("/login") -async def login_page(): - """Retorna página de login""" - login_file = templates_dir / "login.html" - if login_file.exists(): - return FileResponse(str(login_file)) - - # Fallback: HTML simples inline - html_content = """ - <!DOCTYPE html> - <html lang="pt-BR"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Login - Lazier</title> - <style> - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #1a237e 0%, #283593 100%); - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - margin: 0; - } - .login-container { - background: white; - padding: 40px; - border-radius: 12px; - box-shadow: 0 8px 24px rgba(0,0,0,0.2); - width: 100%; - max-width: 400px; - } - h1 { - color: #283593; - margin-bottom: 30px; - text-align: center; - } - .form-group { - margin-bottom: 20px; - } - label { - display: block; - margin-bottom: 8px; - color: #333; - font-weight: 500; - } - input { - width: 100%; - padding: 12px; - border: 2px solid #ddd; - border-radius: 8px; - font-size: 16px; - box-sizing: border-box; - } - input:focus { - outline: none; - border-color: #283593; - } - button { - width: 100%; - padding: 12px; - background: linear-gradient(135deg, #283593 0%, #3949ab 100%); - color: white; - border: none; - border-radius: 8px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - margin-top: 10px; - } - button:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(40, 53, 147, 0.3); - } - .error { - color: #d32f2f; - margin-top: 12px; - padding: 10px; - text-align: center; - font-size: 14px; - background: rgba(211, 47, 47, 0.08); - border-radius: 8px; - word-break: break-word; - } - </style> - </head> - <body> - <div class="login-container"> - <h1>Lazier · acesso privado</h1> - <form method="POST" action="/login"> - <div class="form-group"> - <label for="username">Usuário</label> - <input type="text" id="username" name="username" required autofocus autocomplete="username"> - </div> - <div class="form-group"> - <label for="password">Senha</label> - <input type="password" id="password" name="password" required autocomplete="current-password"> - </div> - <button type="submit">Entrar</button> - <div id="login-error" class="error" role="alert" hidden></div> - </form> - </div> - <script> - (function () { - var p = new URLSearchParams(window.location.search); - if (p.get('error') === 'invalid') { - var b = document.getElementById('login-error'); - if (b) { b.textContent = 'Usuário ou senha incorretos.'; b.removeAttribute('hidden'); } - } - })(); - </script> - </body> - </html> - """ - return HTMLResponse(content=html_content) - - -@router.post("/login") -async def login( - request: Request, - username: str = Form(...), - password: str = Form(...) -): - """Processa login e cria sessão""" - # Verifica credenciais - if not verify_password(username, password): - login_file = templates_dir / "login.html" - if login_file.exists(): - with open(login_file, 'r', encoding='utf-8') as f: - html = f.read() - if _LOGIN_ERROR_PLACEHOLDER in html: - html = html.replace( - _LOGIN_ERROR_PLACEHOLDER, - _LOGIN_ERROR_VISIBLE, - 1, - ) - else: - # Template antigo sem placeholder: redireciona com query - return RedirectResponse(url="/login?error=invalid", status_code=303) - return HTMLResponse(content=html, status_code=200) - return RedirectResponse(url="/login?error=invalid", status_code=303) - - # Cria sessão - request.session["username"] = username - request.session["authenticated"] = True - - # Redireciona para home - return RedirectResponse(url="/", status_code=303) - - -@router.get("/logout") -@router.post("/logout") -async def logout(request: Request): - """Limpa sessão e redireciona para login""" - request.session.clear() - return RedirectResponse(url="/login", status_code=303) diff --git a/lazier/api/main.py b/lazier/api/main.py @@ -2,12 +2,10 @@ App FastAPI principal """ -import os from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.sessions import SessionMiddleware try: from dotenv import load_dotenv except ImportError: # pragma: no cover - ambiente sem python-dotenv @@ -16,33 +14,19 @@ except ImportError: # pragma: no cover - ambiente sem python-dotenv from .routes import router from .websocket import websocket_router -from .auth_routes import router as auth_router -from .middleware import AuthMiddleware load_dotenv() def create_app() -> FastAPI: """Cria e configura aplicação FastAPI""" - - # Bootstrap admin na inicialização - try: - from ..core.auth import bootstrap_admin - bootstrap_admin() - except Exception as e: - print(f"ERRO: Falha ao inicializar autenticação: {e}") - raise - + app = FastAPI( title="Lazier", description="Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs", version="0.01", ) - - # Ordem dos middlewares: no FastAPI, o último adicionado executa primeiro - # Por isso, adicionamos na ordem inversa da execução desejada - - # 1. CORS (executa por último - adicionado primeiro) + app.add_middleware( CORSMiddleware, allow_origins=["*"], # Em produção, especificar origens @@ -50,28 +34,7 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) - - # 2. AuthMiddleware (executa no meio - adicionado segundo) - app.add_middleware(AuthMiddleware) - - # 3. SessionMiddleware (executa primeiro - adicionado por último) - # Deve vir por último para executar primeiro e criar a sessão antes do AuthMiddleware - session_secret = os.getenv('SESSION_SECRET_KEY') - if not session_secret: - raise Exception( - "SESSION_SECRET_KEY não configurada. " - "Configure esta variável no arquivo .env. " - "Gere uma chave com: openssl rand -hex 32" - ) - - app.add_middleware( - SessionMiddleware, - secret_key=session_secret, - max_age=14400, # 4 horas - same_site="lax", - https_only=False, # True em produção com HTTPS - ) - + # Inicializa cache try: from ..core.cache import get_cache_manager @@ -93,21 +56,17 @@ def create_app() -> FastAPI: except Exception as e: print(f"ERRO: Falha ao inicializar persistência de jobs: {e}") raise - - # Rotas de autenticação (públicas) - app.include_router(auth_router) - - # Rotas da API (protegidas) + app.include_router(router, prefix="/api") app.include_router(websocket_router, prefix="/ws") - + # Arquivos estáticos static_dir = Path(__file__).parent.parent / "web" / "static" templates_dir = Path(__file__).parent.parent / "web" / "templates" - + if static_dir.exists(): app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") - + # Rota raiz - serve página principal @app.get("/") async def root(): @@ -116,7 +75,7 @@ def create_app() -> FastAPI: if index_file.exists(): return FileResponse(str(index_file)) return {"message": "Lazier API", "version": "0.01"} - + # Healthcheck @app.get("/health") async def health(): @@ -125,7 +84,7 @@ def create_app() -> FastAPI: "status": "ok", "cache": cache_status, } - + return app diff --git a/lazier/api/middleware.py b/lazier/api/middleware.py @@ -1,74 +0,0 @@ -""" -Middleware de autenticação -""" - -from fastapi import Request, HTTPException, status -from fastapi.responses import RedirectResponse, JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.types import ASGIApp - - -class AuthMiddleware(BaseHTTPMiddleware): - """Middleware que protege rotas exigindo autenticação""" - - # Rotas públicas que não precisam de autenticação - PUBLIC_ROUTES = { - '/login', - '/health', - } - - # Prefixos de rotas públicas - PUBLIC_PREFIXES = [ - '/static', - ] - - def __init__(self, app: ASGIApp): - super().__init__(app) - - async def dispatch(self, request: Request, call_next): - """Verifica autenticação antes de processar request""" - - path = request.url.path - - # Verifica se é rota pública - if self._is_public_route(path): - return await call_next(request) - - # Verifica se está autenticado - authenticated = request.session.get("authenticated", False) - - if not authenticated: - # Diferencia comportamento para HTML vs API - if path.startswith("/api/") or path.startswith("/ws/"): - # API/WebSocket: retorna 401 JSON - if path.startswith("/ws/"): - # Para WebSocket, retorna erro antes de aceitar conexão - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"error": "Unauthorized", "message": "Autenticação necessária"} - ) - else: - # API: retorna 401 JSON - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"error": "Unauthorized", "message": "Autenticação necessária"} - ) - else: - # HTML: redirect para login - return RedirectResponse(url="/login", status_code=303) - - # Request autenticado, continua - return await call_next(request) - - def _is_public_route(self, path: str) -> bool: - """Verifica se a rota é pública""" - # Rotas exatas - if path in self.PUBLIC_ROUTES: - return True - - # Prefixos públicos - for prefix in self.PUBLIC_PREFIXES: - if path.startswith(prefix): - return True - - return False diff --git a/lazier/api/websocket.py b/lazier/api/websocket.py @@ -2,64 +2,31 @@ WebSocket para progress em tempo real """ -from fastapi import APIRouter, WebSocket, WebSocketDisconnect -import json import asyncio -from typing import Dict, List +import json +from typing import Dict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect websocket_router = APIRouter() # Armazena conexões WebSocket por job_id -connections: dict = {} - - -def _verify_websocket_session(cookies: dict) -> bool: - """ - Verifica se o cookie de sessão existe - - Args: - cookies: Dicionário de cookies do WebSocket - - Returns: - True se cookie de sessão existe, False caso contrário - - Nota: Validação completa da sessão seria complexa sem acesso ao SessionMiddleware. - Como o cookie é httpOnly e só é criado após login bem-sucedido, verificar sua - existência já fornece proteção básica. Em produção, considere usar tokens JWT - ou implementar validação mais robusta. - """ - session_cookie = cookies.get("session") - # Cookie de sessão só existe após login bem-sucedido - # Como é httpOnly, não pode ser falsificado via JavaScript - return session_cookie is not None and len(session_cookie) > 0 +connections: Dict[str, list] = {} @websocket_router.websocket("/progress/{job_id}") async def websocket_progress(websocket: WebSocket, job_id: str): """WebSocket para receber atualizações de progresso""" - # Verifica autenticação antes de aceitar conexão - # WebSocket não passa pelo middleware HTTP, então verificamos manualmente - if not _verify_websocket_session(websocket.cookies): - await websocket.close( - code=1008, # Policy Violation - reason="Autenticação necessária" - ) - return - - # Aceita conexão apenas se autenticado await websocket.accept() - + if job_id not in connections: connections[job_id] = [] - + connections[job_id].append(websocket) - + try: while True: - # Mantém conexão aberta - # O progresso será enviado de outras partes do código - data = await websocket.receive_text() - # Echo para manter conexão viva + await websocket.receive_text() await websocket.send_text(json.dumps({"type": "ping"})) except WebSocketDisconnect: if job_id in connections: @@ -72,7 +39,7 @@ async def broadcast_progress_async(job_id: str, progress: int, status: str, mess """Versão assíncrona do broadcast""" if job_id not in connections: return - + data = { "job_id": job_id, "progress": progress, @@ -80,15 +47,15 @@ async def broadcast_progress_async(job_id: str, progress: int, status: str, mess "message": message, } message_json = json.dumps(data) - + active_connections = [] for ws in connections[job_id]: try: await ws.send_text(message_json) active_connections.append(ws) except Exception: - pass # Conexão fechada, remove - + pass + connections[job_id] = active_connections @@ -97,11 +64,8 @@ def broadcast_progress(job_id: str, progress: int, status: str, message: str = N try: loop = asyncio.get_event_loop() if loop.is_running(): - # Se loop já está rodando, cria task asyncio.create_task(broadcast_progress_async(job_id, progress, status, message)) else: - # Se não está rodando, executa diretamente loop.run_until_complete(broadcast_progress_async(job_id, progress, status, message)) except RuntimeError: - # Sem loop, cria novo asyncio.run(broadcast_progress_async(job_id, progress, status, message)) diff --git a/lazier/core/auth.py b/lazier/core/auth.py @@ -1,247 +0,0 @@ -""" -Módulo de autenticação usando SQLite e bcrypt -""" - -import os -import sqlite3 -from pathlib import Path -from typing import Optional -from datetime import datetime -from passlib.context import CryptContext -try: - from dotenv import load_dotenv -except ImportError: # pragma: no cover - ambiente sem python-dotenv - def load_dotenv(): - return False - -# Monkey patch para evitar erro durante detect_wrap_bug do passlib -# O passlib tenta detectar um bug do bcrypt usando uma senha de teste que pode exceder 72 bytes -try: - import bcrypt as _bcrypt_module - _original_hashpw = _bcrypt_module.hashpw - - def _safe_hashpw(secret, salt): - """Wrapper que trunca secret para 72 bytes antes de chamar bcrypt""" - if isinstance(secret, str): - secret = secret.encode('utf-8') - if len(secret) > 72: - secret = secret[:72] - return _original_hashpw(secret, salt) - - _bcrypt_module.hashpw = _safe_hashpw - - # Monkey patch para fornecer __about__ ao bcrypt (evita aviso do passlib) - if not hasattr(_bcrypt_module, '__about__'): - # Cria objeto __about__ com __version__ se não existir - class _BcryptAbout: - def __init__(self): - # Tenta obter versão de diferentes formas - try: - import importlib.metadata - self.__version__ = importlib.metadata.version('bcrypt') - except: - try: - self.__version__ = _bcrypt_module.__version__ - except: - self.__version__ = 'unknown' - - _bcrypt_module.__about__ = _BcryptAbout() -except ImportError: - # Se bcrypt não estiver disponível, ignora o monkey patch - pass - -load_dotenv() - -# Contexto para hash de senhas (inicializado lazy para evitar problemas na importação) -_pwd_context = None - - -def get_pwd_context(): - """Retorna o contexto de hash de senhas, inicializando se necessário""" - global _pwd_context - if _pwd_context is None: - _pwd_context = CryptContext( - schemes=["bcrypt"], - deprecated="auto", - bcrypt__ident="2b", # Usa identificador bcrypt 2b (mais moderno) - bcrypt__rounds=12, # Número de rounds (padrão seguro) - bcrypt__truncate_error=False, # Não levanta erro, trunca silenciosamente durante detecção de bug - ) - return _pwd_context - - -def _normalize_password(password: str) -> str: - """ - Normaliza senha para compatibilidade com bcrypt (limite de 72 bytes) - - Args: - password: Senha em texto plano - - Returns: - Senha truncada para 72 bytes se necessário (retorna como string) - - Raises: - ValueError: Se senha estiver vazia - """ - if not password: - raise ValueError("Senha não pode estar vazia") - - # Converte para bytes UTF-8 para calcular tamanho correto - password_bytes = password.encode('utf-8') - - # Trunca para 72 bytes se necessário (limite do bcrypt) - if len(password_bytes) > 72: - # Avisa sobre truncamento (apenas em desenvolvimento/debug) - import warnings - warnings.warn( - "Senha truncada para 72 bytes devido à limitação do bcrypt. " - "Considere usar uma senha mais curta.", - UserWarning - ) - # Trunca para 72 bytes e converte de volta para string - password_bytes = password_bytes[:72] - # Decodifica de volta para string, ignorando erros de encoding - return password_bytes.decode('utf-8', errors='ignore') - - # Se não precisa truncar, retorna a senha original - return password - - -def get_db_path() -> Path: - """Retorna o caminho do banco de dados SQLite""" - data_dir = Path(os.getenv('LAZIER_DATA_DIR', '/app/data')) - data_dir.mkdir(parents=True, exist_ok=True) - return data_dir / "users.db" - - -def init_db() -> None: - """Inicializa o banco de dados criando a tabela users se não existir""" - db_path = get_db_path() - - with sqlite3.connect(db_path) as conn: - conn.execute(""" - CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - """) - conn.commit() - - -def create_user(username: str, password: str) -> bool: - """ - Cria um novo usuário com senha hasheada - - Args: - username: Nome de usuário - password: Senha em texto plano (será hasheada) - - Returns: - True se criado com sucesso, False se usuário já existe - """ - db_path = get_db_path() - - # Verifica se usuário já existe - if get_user(username): - return False - - # Normaliza senha antes do hash (bcrypt limita a 72 bytes) - normalized_password = _normalize_password(password) - pwd_context = get_pwd_context() - password_hash = pwd_context.hash(normalized_password) - - with sqlite3.connect(db_path) as conn: - conn.execute( - "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)", - (username, password_hash, datetime.now().isoformat()) - ) - conn.commit() - - return True - - -def verify_password(username: str, password: str) -> bool: - """ - Verifica se a senha está correta para o usuário - - Args: - username: Nome de usuário - password: Senha em texto plano - - Returns: - True se senha está correta, False caso contrário - """ - user = get_user(username) - if not user: - return False - - # Normaliza senha antes da verificação (bcrypt limita a 72 bytes) - normalized_password = _normalize_password(password) - pwd_context = get_pwd_context() - return pwd_context.verify(normalized_password, user['password_hash']) - - -def get_user(username: str) -> Optional[dict]: - """ - Busca um usuário pelo username - - Args: - username: Nome de usuário - - Returns: - Dicionário com dados do usuário ou None se não encontrado - """ - db_path = get_db_path() - - with sqlite3.connect(db_path) as conn: - conn.row_factory = sqlite3.Row - cursor = conn.execute( - "SELECT username, password_hash, created_at FROM users WHERE username = ?", - (username,) - ) - row = cursor.fetchone() - - if row: - return { - 'username': row['username'], - 'password_hash': row['password_hash'], - 'created_at': row['created_at'] - } - - return None - - -def user_count() -> int: - """Retorna o número de usuários no banco""" - db_path = get_db_path() - - with sqlite3.connect(db_path) as conn: - cursor = conn.execute("SELECT COUNT(*) as count FROM users") - row = cursor.fetchone() - return row[0] if row else 0 - - -def bootstrap_admin() -> None: - """ - Cria usuário admin na inicialização se tabela estiver vazia - - Requer ADMIN_USER e ADMIN_PASSWORD nas variáveis de ambiente. - Se tabela vazia e variáveis não existem, aborta com erro. - """ - init_db() - - count = user_count() - - if count == 0: - admin_user = os.getenv('ADMIN_USER') - admin_password = os.getenv('ADMIN_PASSWORD') - - if admin_user and admin_password: - create_user(admin_user, admin_password) - print(f"Usuário admin '{admin_user}' criado com sucesso") - else: - raise Exception( - "Banco de dados vazio e variáveis ADMIN_USER/ADMIN_PASSWORD não configuradas. " - "Configure essas variáveis no arquivo .env para criar o primeiro usuário." - ) diff --git a/lazier/web/templates/index.html b/lazier/web/templates/index.html @@ -4,91 +4,254 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Lazier</title> - <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=Fraunces:wght@500;700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"> + <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Ctext%20y%3D%22.85em%22%20x%3D%2250%25%22%20font-size%3D%2272%22%20text-anchor%3D%22middle%22%20dominant-baseline%3D%22middle%22%20font-family%3D%22system-ui%2CSegoe%20UI%20Emoji%2CApple%20Color%20Emoji%2CNoto%20Color%20Emoji%22%3E%F0%9F%93%A3%3C/text%3E%3C/svg%3E" /> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <style> - :root { --bg:#f5f1ea; --card:rgba(255,255,255,.9); --ink:#223437; --muted:#66787b; --line:rgba(34,52,55,.12); --accent:#2f7a71; --accent-soft:rgba(47,122,113,.1); --warn:#b85f49; } - * { box-sizing:border-box; margin:0; padding:0; } - body { font-family:'Manrope',sans-serif; color:var(--ink); background:radial-gradient(circle at top left, rgba(47,122,113,.08), transparent 26%), linear-gradient(180deg,#faf7f2 0%,#f2ede6 100%); min-height:100vh; min-height:100dvh; padding:20px; } - a { color:inherit; text-decoration:none; } - button,input,select { font:inherit; } - .shell { max-width:1180px; margin:0 auto; } - .topbar,.card,.job,.modal-card { background:var(--card); border:1px solid rgba(255,255,255,.9); box-shadow:0 18px 42px rgba(40,56,60,.08); backdrop-filter:blur(14px); } - .topbar { border-radius:999px; padding:16px 20px; display:flex; justify-content:space-between; gap:16px; align-items:center; margin-bottom:20px; flex-wrap:wrap; } - .brand { display:flex; gap:12px; align-items:center; flex:1; min-width:0; } - .mark { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; background:linear-gradient(135deg, rgba(47,122,113,.12), rgba(198,125,69,.12)); } - .brand h1,.title { font-family:'Fraunces',serif; letter-spacing:-.03em; } - .brand p,.subtle,.hero p,.field label,.meta,.empty,.error { color:var(--muted); } - .nav { display:flex; gap:8px; flex-wrap:wrap; } - .nav a,.nav button,.filter,.action { border:0; background:transparent; border-radius:999px; padding:10px 14px; cursor:pointer; } - .nav a.active,.nav a:hover,.nav button:hover,.filter.active { background:var(--accent-soft); color:var(--accent); } - .page { display:none; } - .page.active { display:block; } - .hero { display:grid; grid-template-columns:1.1fr .9fr; gap:18px; } - .card { border-radius:28px; padding:28px; } - .eyebrow { display:inline-block; background:var(--accent-soft); color:var(--accent); padding:8px 12px; border-radius:999px; font-size:.85rem; font-weight:700; } - .hero h2 { font-family:'Fraunces',serif; font-size:clamp(2rem,4vw,3.2rem); line-height:1.04; margin:16px 0; letter-spacing:-.04em; } - .hero p { line-height:1.7; max-width:56ch; } - .notes { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-top:22px; } - .note { border:1px solid var(--line); border-radius:18px; padding:14px; background:rgba(255,255,255,.6); } - .note strong { display:block; margin-bottom:6px; } - .stack { display:flex; flex-direction:column; gap:16px; } - .field { display:flex; flex-direction:column; gap:8px; } - .field label { font-size:.88rem; font-weight:700; } - .field input,.field select { width:100%; padding:14px 16px; border-radius:16px; border:1px solid var(--line); background:rgba(255,255,255,.95); } - .upload { padding:24px; border-radius:24px; border:1.5px dashed rgba(47,122,113,.28); background:linear-gradient(180deg, rgba(223,240,236,.55), rgba(255,255,255,.75)); cursor:pointer; } - .upload.dragover { transform:translateY(-1px); } - .file-list { display:grid; gap:10px; } - .file { display:grid; grid-template-columns:1fr auto auto; gap:10px; align-items:center; padding:12px 14px; border-radius:16px; background:#fff; border:1px solid var(--line); min-width:0; } - .file > div:first-child { min-width:0; overflow-wrap:anywhere; word-break:break-word; } - .remove { width:34px; height:34px; border-radius:999px; border:0; background:rgba(184,95,73,.1); color:var(--warn); cursor:pointer; } - .mode-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:10px; } - .mode { border:1px solid var(--line); border-radius:20px; padding:16px; cursor:pointer; background:rgba(255,255,255,.7); } - .mode.selected { border-color:rgba(47,122,113,.35); background:linear-gradient(180deg, rgba(223,240,236,.8), rgba(255,255,255,.9)); } - .mode input { display:none; } - .mode strong { display:block; margin-bottom:6px; } - .row { display:grid; grid-template-columns:1fr 220px; gap:12px; align-items:end; } - .btn { border:0; border-radius:16px; padding:14px 16px; background:linear-gradient(135deg,var(--accent),#225b55); color:#fff; font-weight:700; cursor:pointer; touch-action:manipulation; min-height:44px; } - .btn:disabled { opacity:.65; cursor:not-allowed; } - .section { margin-top:20px; } - .header { display:flex; justify-content:space-between; gap:14px; align-items:end; margin-bottom:14px; } - .list { display:grid; gap:12px; } - .job { border-radius:22px; padding:20px; } - .job-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; min-width:0; } - .job-head > div:first-child { min-width:0; flex:1; } - .job-title { font-weight:700; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; } - .chips,.actions,.filters { display:flex; gap:8px; flex-wrap:wrap; } - .chip,.status { border-radius:999px; padding:8px 12px; font-size:.82rem; } - .chip { background:rgba(34,52,55,.06); color:var(--muted); } - .status.pending,.status.processing { background:rgba(198,125,69,.14); color:#8f5a2d; } - .status.completed { background:rgba(47,122,113,.14); color:var(--accent); } - .status.failed,.status.interrupted { background:rgba(184,95,73,.12); color:var(--warn); } - .bar { height:8px; background:rgba(34,52,55,.08); border-radius:999px; overflow:hidden; margin-top:14px; } - .bar span { display:block; height:100%; background:linear-gradient(90deg,var(--accent),#c67d45); } - .actions,.filters { margin-top:14px; } - .action,.filter { border:1px solid var(--line); background:rgba(255,255,255,.9); } - .empty { padding:28px; border:1px dashed var(--line); border-radius:22px; text-align:center; background:rgba(255,255,255,.45); } - .error { margin-top:12px; color:var(--warn); } - .footer { text-align:center; font-size:.84rem; padding:22px 0 10px; color:var(--muted); } - .modal { position:fixed; inset:0; background:rgba(26,34,37,.48); display:none; align-items:center; justify-content:center; padding:24px; } - .modal.open { display:flex; } - .modal-card { width:min(900px,100%); max-height:85vh; overflow:auto; border-radius:24px; padding:24px; } - .modal-top { display:flex; justify-content:space-between; gap:12px; margin-bottom:16px; } - .close { width:40px; height:40px; border-radius:999px; border:0; background:rgba(34,52,55,.08); cursor:pointer; } - .preview { white-space:pre-wrap; line-height:1.7; } - .preview h1,.preview h2,.preview h3 { font-family:'Fraunces',serif; margin:16px 0 8px; } - .preview hr { border:0; border-top:1px solid var(--line); margin:18px 0; } - @media (max-width:980px) { .hero,.row { grid-template-columns:1fr; } .notes { grid-template-columns:1fr; } } - @media (max-width:640px) { - body { padding:14px; } - .mode-grid { grid-template-columns:1fr; } - .job-head,.header { flex-direction:column; align-items:flex-start; } - .topbar { border-radius:22px; } - .nav { width:100%; justify-content:flex-start; } - .nav a,.nav button { min-height:40px; display:inline-flex; align-items:center; } - .field input,.field select { font-size:16px; } + :root { + --bg: #f4f4f5; + --surface: #fafafa; + --border: #e4e4e7; + --text: #18181b; + --muted: #71717a; + --accent: #3f3f46; + --accent-hover: #27272a; + --danger: #b91c1c; + --radius: 6px; + } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif; + color: var(--text); + background: var(--bg); + min-height: 100vh; + min-height: 100dvh; + line-height: 1.5; + font-size: 15px; + } + a { color: inherit; text-decoration: none; } + button, input, select { font: inherit; } + + .shell { max-width: 720px; margin: 0 auto; padding: 0 20px 32px; } + + .topbar { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; + padding: 16px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 24px; + } + .brand h1 { font-size: 1.125rem; font-weight: 600; letter-spacing: -0.02em; } + .brand p { font-size: 0.8125rem; color: var(--muted); margin-top: 2px; } + + .nav { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; } + .nav a { + padding: 8px 12px; + border-radius: var(--radius); + font-size: 0.875rem; + color: var(--muted); + min-height: 40px; + display: inline-flex; + align-items: center; + } + .nav a:hover { color: var(--text); background: rgba(0,0,0,.04); } + .nav a.active { color: var(--text); font-weight: 500; background: rgba(0,0,0,.06); } + + .page { display: none; } + .page.active { display: block; } + + .section-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 12px; } + .section-lead { font-size: 0.8125rem; color: var(--muted); margin-bottom: 16px; } + + .stack { display: flex; flex-direction: column; gap: 14px; } + .field { display: flex; flex-direction: column; gap: 6px; } + .field label { font-size: 0.8125rem; font-weight: 500; color: var(--text); } + .field input, .field select { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + } + .field input:focus, .field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(63, 63, 70, 0.15); + } + + .upload { + padding: 20px; + border-radius: var(--radius); + border: 1px dashed var(--border); + background: var(--surface); + cursor: pointer; + text-align: center; + } + .upload.dragover { border-color: var(--accent); background: #fff; } + .upload strong { font-size: 0.875rem; font-weight: 500; } + .upload .subtle { margin-top: 6px; } + + .file-list { display: grid; gap: 8px; margin-top: 12px; text-align: left; } + .file { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + min-width: 0; + } + .file > div:first-child { min-width: 0; overflow-wrap: anywhere; word-break: break-word; } + .remove { + width: 36px; height: 36px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--danger); + cursor: pointer; + } + + .mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .mode { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + cursor: pointer; + background: var(--surface); + text-align: left; + } + .mode.selected { border-color: var(--accent); background: #fff; box-shadow: inset 0 0 0 1px var(--accent); } + .mode input { display: none; } + .mode strong { display: block; font-size: 0.875rem; margin-bottom: 4px; } + + .row-actions { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; } + .row-actions .field { flex: 1; min-width: 140px; } + + .btn { + border: 0; + border-radius: var(--radius); + padding: 10px 18px; + background: var(--accent); + color: #fff; + font-weight: 500; + cursor: pointer; + min-height: 44px; + flex-shrink: 0; + } + .btn:hover:not(:disabled) { background: var(--accent-hover); } + .btn:disabled { opacity: .55; cursor: not-allowed; } + + .subtle, .meta, .empty, .error { color: var(--muted); font-size: 0.8125rem; } + + .divider { height: 1px; background: var(--border); margin: 28px 0; } + + .filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; } + .filter { + padding: 8px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + font-size: 0.8125rem; + cursor: pointer; + color: var(--muted); + min-height: 36px; + } + .filter:hover { color: var(--text); } + .filter.active { border-color: var(--accent); color: var(--text); font-weight: 500; background: #fff; } + + .list { border-top: 1px solid var(--border); } + + .job { + padding: 14px 0; + border-bottom: 1px solid var(--border); + } + .job-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; min-width: 0; } + .job-head > div:first-child { min-width: 0; flex: 1; } + .job-title { font-weight: 500; font-size: 0.9375rem; overflow-wrap: anywhere; word-break: break-word; } + .chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .chip { + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 4px; + background: #e4e4e7; + color: var(--muted); + } + .status { + font-size: 0.75rem; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + flex-shrink: 0; + } + .status.pending, .status.processing { background: #fef3c7; color: #92400e; } + .status.completed { background: #dcfce7; color: #166534; } + .status.failed, .status.interrupted { background: #fee2e2; color: #991b1b; } + + .bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 12px; } + .bar span { display: block; height: 100%; background: var(--accent); } + + .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + .action { + font-size: 0.8125rem; + padding: 8px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + cursor: pointer; + color: var(--text); + min-height: 36px; + display: inline-flex; + align-items: center; + } + .action:hover { border-color: var(--accent); } + + .empty { padding: 24px 0; text-align: center; border-bottom: 1px solid var(--border); } + .error { margin-top: 10px; color: var(--danger); font-size: 0.8125rem; } + + .footer { text-align: center; font-size: 0.75rem; color: var(--muted); padding-top: 28px; } + + .modal { + position: fixed; + inset: 0; + background: rgba(24, 24, 27, 0.45); + display: none; + align-items: center; + justify-content: center; + padding: 20px; + } + .modal.open { display: flex; } + .modal-panel { + width: min(720px, 100%); + max-height: 85vh; + overflow: auto; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + padding: 20px; + } + .modal-top { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 16px; } + .close { + width: 40px; height: 40px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + cursor: pointer; + flex-shrink: 0; + } + .preview { white-space: pre-wrap; line-height: 1.65; font-size: 0.9375rem; } + .preview h1, .preview h2, .preview h3 { margin: 16px 0 8px; font-size: 1.05rem; font-weight: 600; } + .preview hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; } + + @media (max-width: 560px) { + .mode-grid { grid-template-columns: 1fr; } + .row-actions { flex-direction: column; align-items: stretch; } + .btn { width: 100%; } + .field input, .field select { font-size: 16px; } } </style> </head> @@ -96,101 +259,74 @@ <div class="shell"> <header class="topbar"> <div class="brand"> - <div class="mark">🎧</div> - <div> - <h1>Lazier</h1> - <p>Transcrição ou sumário, sempre em português do Brasil.</p> - </div> + <h1>Lazier</h1> + <p>Transcrição ou sumário em português do Brasil.</p> </div> <nav class="nav"> <a href="#" class="active" onclick="showPage('process');return false;">Processar</a> <a href="#" onclick="showPage('history');return false;">Histórico</a> <a href="#" onclick="showPage('downloads');return false;">Downloads</a> - <button onclick="logout();return false;">Sair</button> </nav> </header> <section id="page-process" class="page active"> - <div class="hero"> - <div class="card"> - <div class="eyebrow">Fluxo simplificado</div> - <h2>Escolha um modo e receba o resultado final em português.</h2> - <p>Envie arquivos, vídeos, links, páginas, textos ou PDFs. O Lazier detecta o idioma, converte o conteúdo e salva o artefato em uma estrutura de saída mais organizada.</p> - <div class="notes"> - <div class="note"><strong>Um objetivo por vez</strong><span class="meta">Agora o fluxo é transcrever ou resumir.</span></div> - <div class="note"><strong>Saídas mais limpas</strong><span class="meta">Pastas por data e job com nomes previsíveis.</span></div> - <div class="note"><strong>Histórico persistente</strong><span class="meta">Jobs continuam visíveis após reiniciar o servidor.</span></div> - </div> - </div> + <div class="section-title">Novo processamento</div> + <p class="section-lead">A saída final será em português do Brasil.</p> - <div class="card stack"> - <div> - <div class="title">Novo processamento</div> - <p class="subtle">A saída final sempre será em português do Brasil.</p> + <div class="stack"> + <div class="upload" id="uploadArea"><input type="file" id="fileInput" hidden multiple accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.3gp,.3g2,.amr,.au,.caf,.mka,.ra,.rm,.spx,.tta,.wv,.mp4,.avi,.mkv,.mov,.wmv,.flv,.webm,.m4v,.asf,.f4v,.m2v,.mts,.m2ts,.ogv,.rmvb,.ts,.vob,.pdf,.txt,.md,.html,.htm"></div> + <div class="field"><label for="urlInput">URL (opcional)</label><input type="text" id="urlInput" placeholder="YouTube, página, áudio em linha…"></div> + <div class="field"> + <label>Modo</label> + <div class="mode-grid"> + <div class="mode selected" onclick="selectMode('transcribe',this)"><input type="radio" checked value="transcribe"><strong>Transcrever</strong><span class="meta">Texto completo em PT-BR.</span></div> + <div class="mode" onclick="selectMode('summarize',this)"><input type="radio" value="summarize"><strong>Resumir</strong><span class="meta">Sumário em PT-BR.</span></div> </div> - <div class="upload" id="uploadArea"><input type="file" id="fileInput" hidden multiple accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.3gp,.3g2,.amr,.au,.caf,.mka,.ra,.rm,.spx,.tta,.wv,.mp4,.avi,.mkv,.mov,.wmv,.flv,.webm,.m4v,.asf,.f4v,.m2v,.mts,.m2ts,.ogv,.rmvb,.ts,.vob,.pdf,.txt,.md,.html,.htm"></div> - <div class="field"><label for="urlInput">Ou cole uma URL</label><input type="text" id="urlInput" placeholder="YouTube, TED, Vimeo, página web, artigo, etc."></div> + </div> + <div class="row-actions"> <div class="field"> - <label>Modo</label> - <div class="mode-grid"> - <div class="mode selected" onclick="selectMode('transcribe',this)"><input type="radio" checked value="transcribe"><strong>Apenas transcrever</strong><span class="meta">Entrega o conteúdo completo em português.</span></div> - <div class="mode" onclick="selectMode('summarize',this)"><input type="radio" value="summarize"><strong>Apenas resumir</strong><span class="meta">Entrega um sumário em português.</span></div> - </div> - </div> - <div class="row"> - <div class="field"> - <label for="formatSelect">Formato de saída</label> - <select id="formatSelect"><option value="docx">DOCX</option><option value="txt">TXT</option><option value="md">Markdown</option><option value="json">JSON</option><option value="pdf">PDF</option></select> - </div> - <button class="btn" id="processBtn" onclick="processFiles()">Processar</button> + <label for="formatSelect">Formato</label> + <select id="formatSelect"><option value="docx">DOCX</option><option value="txt">TXT</option><option value="md">Markdown</option><option value="json">JSON</option><option value="pdf">PDF</option></select> </div> + <button class="btn" id="processBtn" onclick="processFiles()">Processar</button> </div> </div> - <div class="card section"> - <div class="header"> - <div> - <div class="title">Jobs em andamento</div> - <p class="subtle">Acompanhe progresso, visualize conteúdo e faça download dos artefatos.</p> - </div> - </div> - <div class="list" id="jobsList"></div> - </div> + <div class="divider"></div> + + <div class="section-title">Jobs em andamento</div> + <div class="list" id="jobsList"></div> </section> <section id="page-history" class="page"> - <div class="card"> - <div class="header"> - <div><div class="title">Histórico</div><p class="subtle">Jobs persistidos, inclusive após reinício do servidor.</p></div> - </div> - <div class="filters"> - <button class="filter active" onclick="filterHistory('all',this)">Todos</button> - <button class="filter" onclick="filterHistory('completed',this)">Concluídos</button> - <button class="filter" onclick="filterHistory('processing',this)">Processando</button> - <button class="filter" onclick="filterHistory('failed',this)">Falhados</button> - <button class="filter" onclick="filterHistory('interrupted',this)">Interrompidos</button> - </div> - <div class="list" id="historyList"></div> + <div class="section-title">Histórico</div> + <div class="filters"> + <button type="button" class="filter active" onclick="filterHistory('all',this)">Todos</button> + <button type="button" class="filter" onclick="filterHistory('completed',this)">Concluídos</button> + <button type="button" class="filter" onclick="filterHistory('processing',this)">Processando</button> + <button type="button" class="filter" onclick="filterHistory('failed',this)">Falhados</button> + <button type="button" class="filter" onclick="filterHistory('interrupted',this)">Interrompidos</button> </div> + <div class="list" id="historyList"></div> </section> <section id="page-downloads" class="page"> - <div class="card"> - <div class="header"> - <div><div class="title">Downloads</div><p class="subtle">Acesso rápido aos arquivos prontos.</p></div> - </div> - <div class="list" id="downloadsList"></div> - </div> + <div class="section-title">Downloads</div> + <p class="section-lead">Arquivos prontos para baixar.</p> + <div class="list" id="downloadsList"></div> </section> - <footer class="footer">Desenvolvido por Pablo Murad · <span id="currentYear"></span></footer> + <footer class="footer">Pablo Murad · <span id="currentYear"></span></footer> </div> <div class="modal" id="previewModal" onclick="closePreview(event)"> - <div class="modal-card" onclick="event.stopPropagation()"> + <div class="modal-panel" onclick="event.stopPropagation()"> <div class="modal-top"> - <div><div class="title" id="previewTitle">Pré-visualização</div><p class="subtle">Conteúdo persistido do job selecionado.</p></div> - <button class="close" onclick="closePreview()">✕</button> + <div> + <div class="section-title" style="margin-bottom:4px;" id="previewTitle">Pré-visualização</div> + <p class="subtle">Conteúdo persistido do job.</p> + </div> + <button type="button" class="close" onclick="closePreview()" aria-label="Fechar">✕</button> </div> <div class="preview" id="previewContent"></div> </div> @@ -212,10 +348,10 @@ function renderUpload() { const area = document.getElementById('uploadArea'); if (!selectedFiles.length) { - area.innerHTML = '<strong>Arraste arquivos aqui ou clique para selecionar</strong><p class="subtle" style="margin-top:10px;">Suporta áudio, vídeo, PDF, texto e HTML. O resultado final será salvo em português do Brasil.</p>'; + area.innerHTML = '<strong>Arrastar ficheiros ou clicar para escolher</strong><p class="subtle" style="margin-top:8px;">Áudio, vídeo, PDF ou texto.</p>'; return; } - area.innerHTML = `<div class="file-list">${selectedFiles.map((file, index) => `<div class="file"><div><strong>${escapeHtml(file.name)}</strong><div class="meta">${(file.size / 1024 / 1024).toFixed(2)} MB</div></div><div class="chip">${file.type || 'arquivo'}</div><button class="remove" onclick="removeFile(${index})">✕</button></div>`).join('')}</div>`; + area.innerHTML = `<div class="file-list">${selectedFiles.map((file, index) => `<div class="file"><div><strong>${escapeHtml(file.name)}</strong><div class="meta">${(file.size / 1024 / 1024).toFixed(2)} MB</div></div><div class="chip">${file.type || 'ficheiro'}</div><button type="button" class="remove" onclick="removeFile(${index})">✕</button></div>`).join('')}</div>`; } function bindUpload() { @@ -229,8 +365,11 @@ } function removeFile(index) { selectedFiles.splice(index, 1); document.getElementById('fileInput').value = ''; renderUpload(); } - function selectMode(mode, element) { processingMode = mode; document.querySelectorAll('.mode').forEach((node) => node.classList.remove('selected')); element.classList.add('selected'); } - async function logout() { try { await fetch('/logout', { method:'POST', credentials:'include' }); } finally { window.location.href = '/login'; } } + function selectMode(mode, element) { + processingMode = mode; + document.querySelectorAll('.mode').forEach((node) => node.classList.remove('selected')); + element.classList.add('selected'); + } function showPage(page) { document.querySelectorAll('.page').forEach((node) => node.classList.remove('active')); @@ -248,7 +387,7 @@ const format = document.getElementById('formatSelect').value; const url = document.getElementById('urlInput').value.trim(); button.disabled = true; - button.textContent = 'Processando...'; + button.textContent = 'A processar…'; try { if (url) { const response = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ url, format, mode: processingMode }), credentials:'include' }); @@ -263,10 +402,10 @@ formData.append('mode', processingMode); const response = await fetch('/api/upload', { method:'POST', body:formData, credentials:'include' }); const data = await response.json(); - if (!response.ok) throw new Error(data.detail || 'Falha ao enviar arquivos'); - data.job_ids.forEach((jobId, index) => { addJob(jobId, selectedFiles[index] ? selectedFiles[index].name : 'Arquivo', processingMode); startPolling(jobId); }); + if (!response.ok) throw new Error(data.detail || 'Falha ao enviar ficheiros'); + data.job_ids.forEach((jobId, index) => { addJob(jobId, selectedFiles[index] ? selectedFiles[index].name : 'Ficheiro', processingMode); startPolling(jobId); }); } else { - throw new Error('Selecione arquivos ou informe uma URL.'); + throw new Error('Selecione ficheiros ou indique uma URL.'); } } catch (error) { alert(error.message); @@ -279,17 +418,17 @@ function renderJob(job) { const title = job.title || job.url || job.file_path || `Job ${job.id}`; - const statusLabel = { pending:'Pendente', processing:'Processando', completed:'Concluído', failed:'Falhou', interrupted:'Interrompido' }[job.status] || job.status; - return `<div class="job-head"><div><div class="job-title">${escapeHtml(title)}</div><div class="chips" style="margin-top:10px;">${job.mode ? `<span class="chip">${job.mode === 'transcribe' ? 'Transcrição' : 'Sumário'}</span>` : ''}${job.format ? `<span class="chip">${job.format.toUpperCase()}</span>` : ''}${job.created_at ? `<span class="chip">${new Date(job.created_at).toLocaleString('pt-BR')}</span>` : ''}</div></div><span class="status ${job.status}">${statusLabel}</span></div><div class="bar"><span style="width:${job.progress || 0}%"></span></div>${job.error ? `<div class="error">${escapeHtml(job.error)}</div>` : ''}${renderActions(job)}`; + const statusLabel = { pending:'Pendente', processing:'A processar', completed:'Concluído', failed:'Falhou', interrupted:'Interrompido' }[job.status] || job.status; + return `<div class="job-head"><div><div class="job-title">${escapeHtml(title)}</div><div class="chips">${job.mode ? `<span class="chip">${job.mode === 'transcribe' ? 'Transcrição' : 'Sumário'}</span>` : ''}${job.format ? `<span class="chip">${job.format.toUpperCase()}</span>` : ''}${job.created_at ? `<span class="chip">${new Date(job.created_at).toLocaleString('pt-BR')}</span>` : ''}</div></div><span class="status ${job.status}">${statusLabel}</span></div><div class="bar"><span style="width:${job.progress || 0}%"></span></div>${job.error ? `<div class="error">${escapeHtml(job.error)}</div>` : ''}${renderActions(job)}`; } function renderActions(job) { if (job.status !== 'completed' && job.status !== 'interrupted') return ''; let html = '<div class="actions">'; - if (job.result_path) html += `<a class="action" href="/api/jobs/${job.id}/download">Baixar principal</a>`; - if (job.has_transcription) html += `<a class="action" href="/api/jobs/${job.id}/transcription">Baixar transcrição</a>`; - if (job.has_summary) html += `<a class="action" href="/api/jobs/${job.id}/summary">Baixar sumário</a>`; - html += `<button class="action" onclick="viewJobDetails('${job.id}')">Visualizar</button></div>`; + if (job.result_path) html += `<a class="action" href="/api/jobs/${job.id}/download">Principal</a>`; + if (job.has_transcription) html += `<a class="action" href="/api/jobs/${job.id}/transcription">Transcrição</a>`; + if (job.has_summary) html += `<a class="action" href="/api/jobs/${job.id}/summary">Sumário</a>`; + html += `<button type="button" class="action" onclick="viewJobDetails('${job.id}')">Visualizar</button></div>`; return html; } @@ -356,13 +495,13 @@ function renderHistory() { const container = document.getElementById('historyList'); const jobs = currentFilter === 'all' ? allJobs : allJobs.filter((job) => job.status === currentFilter); - container.innerHTML = jobs.length ? jobs.map((job) => `<div class="job">${renderJob(job)}</div>`).join('') : '<div class="empty">Nenhum job encontrado para este filtro.</div>'; + container.innerHTML = jobs.length ? jobs.map((job) => `<div class="job">${renderJob(job)}</div>`).join('') : '<div class="empty">Nenhum job neste filtro.</div>'; } function loadDownloads() { const container = document.getElementById('downloadsList'); const jobs = allJobs.filter((job) => job.status === 'completed' && job.result_path); - container.innerHTML = jobs.length ? jobs.map((job) => `<div class="job">${renderJob(job)}</div>`).join('') : '<div class="empty">Ainda não há arquivos prontos para download.</div>'; + container.innerHTML = jobs.length ? jobs.map((job) => `<div class="job">${renderJob(job)}</div>`).join('') : '<div class="empty">Sem ficheiros prontos.</div>'; } async function viewJobDetails(jobId) { @@ -374,7 +513,7 @@ let content = ''; if (data.summary) { content += '<h3>Sumário</h3>' + renderPreview(data.summary, data.format) + '<hr>'; } if (data.transcription) { content += '<h3>Transcrição</h3>' + renderPreview(data.transcription, data.format); } - document.getElementById('previewContent').innerHTML = content || '<div class="empty">Nenhum conteúdo persistido para este job.</div>'; + document.getElementById('previewContent').innerHTML = content || '<div class="empty">Sem conteúdo persistido.</div>'; document.getElementById('previewModal').classList.add('open'); } catch (error) { alert(error.message); diff --git a/lazier/web/templates/login.html b/lazier/web/templates/login.html @@ -1,162 +0,0 @@ -<!DOCTYPE html> -<html lang="pt-BR"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Login · Lazier</title> - <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=Fraunces:wght@500;700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"> - <style> - :root { - --bg: #f5f1ea; - --card: rgba(255, 255, 255, 0.92); - --ink: #223437; - --muted: #66787b; - --line: rgba(34, 52, 55, 0.12); - --accent: #2f7a71; - --accent-soft: rgba(47, 122, 113, 0.12); - --warn: #b85f49; - } - * { box-sizing: border-box; margin: 0; padding: 0; } - body { - min-height: 100vh; - min-height: 100dvh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: clamp(16px, 4vw, 28px); - font-family: 'Manrope', sans-serif; - color: var(--ink); - background: radial-gradient(circle at top left, rgba(47, 122, 113, 0.08), transparent 26%), - linear-gradient(180deg, #faf7f2 0%, #f2ede6 100%); - } - .card { - width: 100%; - max-width: 420px; - background: var(--card); - border: 1px solid rgba(255, 255, 255, 0.95); - border-radius: 24px; - padding: clamp(22px, 5vw, 32px); - box-shadow: 0 18px 42px rgba(40, 56, 60, 0.08); - backdrop-filter: blur(14px); - } - .eyebrow { - display: inline-block; - background: var(--accent-soft); - color: var(--accent); - padding: 6px 12px; - border-radius: 999px; - font-size: 0.8rem; - font-weight: 700; - margin-bottom: 12px; - } - h1 { - font-family: 'Fraunces', serif; - font-size: clamp(1.45rem, 4vw, 1.75rem); - letter-spacing: -0.03em; - line-height: 1.2; - margin-bottom: 8px; - } - .sub { - color: var(--muted); - font-size: 0.9rem; - line-height: 1.5; - margin-bottom: 22px; - } - .field { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 16px; - } - label { - font-size: 0.88rem; - font-weight: 700; - color: var(--ink); - } - input { - width: 100%; - padding: 14px 16px; - border-radius: 14px; - border: 1px solid var(--line); - background: rgba(255, 255, 255, 0.95); - color: var(--ink); - font-size: 16px; - } - input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - button[type="submit"] { - width: 100%; - border: 0; - border-radius: 14px; - padding: 14px 16px; - min-height: 44px; - margin-top: 8px; - background: linear-gradient(135deg, var(--accent), #225b55); - color: #fff; - font-weight: 700; - font-size: 1rem; - cursor: pointer; - touch-action: manipulation; - } - button[type="submit"]:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - .error { - margin-top: 14px; - padding: 12px 14px; - border-radius: 12px; - color: var(--warn); - background: rgba(184, 95, 73, 0.1); - border: 1px solid rgba(184, 95, 73, 0.2); - font-size: 0.9rem; - line-height: 1.45; - word-break: break-word; - } - .footer { - margin-top: 24px; - text-align: center; - font-size: 0.82rem; - color: var(--muted); - } - </style> -</head> -<body> - <main class="card"> - <div class="eyebrow">Lazier</div> - <h1>Acesso privado</h1> - <p class="sub">Uso pessoal. Entre com o usuário e a senha configurados no servidor.</p> - <form method="POST" action="/login"> - <div class="field"> - <label for="username">Usuário</label> - <input type="text" id="username" name="username" required autofocus autocomplete="username" placeholder="Usuário"> - </div> - <div class="field"> - <label for="password">Senha</label> - <input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Senha"> - </div> - <button type="submit">Entrar</button> - <div id="login-error" class="error" role="alert" hidden></div> - </form> - </main> - <p class="footer">Lazier · <span id="currentYear"></span></p> - <script> - document.getElementById('currentYear').textContent = new Date().getFullYear(); - (function () { - var params = new URLSearchParams(window.location.search); - var err = params.get('error'); - var box = document.getElementById('login-error'); - if (!box) return; - if (err === 'invalid') { - box.textContent = 'Usuário ou senha incorretos.'; - box.removeAttribute('hidden'); - } - })(); - </script> -</body> -</html> diff --git a/requirements.txt b/requirements.txt @@ -18,6 +18,4 @@ redis>=5.0.0 hiredis>=2.2.0 playwright>=1.40.0 chardet>=5.0.0 -passlib[bcrypt]>=1.7.4 -itsdangerous>=2.1.2 reportlab>=4.0.0 diff --git a/tests/test_api.py b/tests/test_api.py @@ -18,9 +18,6 @@ class ApiTests(unittest.TestCase): self.temp_dir = Path(os.getcwd()) / ".tmp-tests" / f"api-{uuid.uuid4().hex[:8]}" self.temp_dir.mkdir(parents=True, exist_ok=True) os.environ["OPENAI_API_KEY"] = "test-key" - os.environ["SESSION_SECRET_KEY"] = "test-session-secret" - os.environ["ADMIN_USER"] = "admin" - os.environ["ADMIN_PASSWORD"] = "secret" os.environ["LAZIER_DATA_DIR"] = str(self.temp_dir / "data") os.environ["LAZIER_UPLOAD_DIR"] = str(self.temp_dir / "uploads") os.environ["LAZIER_OUTPUT_DIR"] = str(self.temp_dir / "outputs") @@ -29,7 +26,6 @@ class ApiTests(unittest.TestCase): self.main_module = importlib.reload(main_module) self.client = TestClient(self.main_module.create_app()) - self.client.post("/login", data={"username": "admin", "password": "secret"}) def tearDown(self): shutil.rmtree(self.temp_dir, ignore_errors=True) @@ -180,7 +176,6 @@ class ApiTests(unittest.TestCase): reloaded_main = importlib.reload(main_module) second_client = TestClient(reloaded_main.create_app()) - second_client.post("/login", data={"username": "admin", "password": "secret"}) history = second_client.get("/api/history") jobs = history.json()["jobs"]