commit 4a65626314b4ebc0e8bc9761a5362f938a7563b8
parent eaaffd9109107ba84b5b1a85c3d75e720a26a218
Author: Pablo Murad <pblmrd@gmail.com>
Date: Tue, 5 May 2026 17:18:18 -0300
klance
Diffstat:
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"]