primeiro commit
This commit is contained in:
parent
0febca7c9b
commit
e681295b55
9 changed files with 3037 additions and 2185 deletions
679
app.py
679
app.py
|
|
@ -1,340 +1,445 @@
|
|||
import os
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from flask import Flask, jsonify, request, render_template, session
|
||||
from flask_cors import CORS
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from flask import Flask, jsonify, render_template, request, session
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
import click
|
||||
|
||||
import database
|
||||
import ics_sync
|
||||
import urllib3
|
||||
from security import (
|
||||
CATEGORIES,
|
||||
RateLimiter,
|
||||
ValidationError,
|
||||
csrf_token,
|
||||
integer,
|
||||
iso_datetime,
|
||||
require_json,
|
||||
text,
|
||||
valid_csrf,
|
||||
validate_credentials,
|
||||
validate_event,
|
||||
validate_feed,
|
||||
validate_public_url,
|
||||
)
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
app = Flask(__name__, static_folder='static', template_folder='templates')
|
||||
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'super-secret-key-calendar-study')
|
||||
CORS(app, supports_credentials=True)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO'))
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
def run_sync_job():
|
||||
with app.app_context():
|
||||
ics_sync.sync_all_feeds()
|
||||
def _env_bool(name, default):
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
if value.lower() not in {'true', 'false'}:
|
||||
raise RuntimeError(f'{name} deve ser true ou false.')
|
||||
return value.lower() == 'true'
|
||||
|
||||
# Inicializa banco de dados
|
||||
database.init_db()
|
||||
|
||||
# Inicializa agendador apenas no processo principal (evita duplicação com Flask reloader)
|
||||
if not os.environ.get("WERKZEUG_RUN_MAIN"):
|
||||
scheduler.add_job(func=run_sync_job, trigger="interval", minutes=30, id='sync_feeds')
|
||||
scheduler.start()
|
||||
def _secret(environment, supplied=None):
|
||||
value = supplied or os.environ.get('FLASK_SECRET_KEY')
|
||||
if environment == 'production':
|
||||
if (not value or len(value) < 32 or len(set(value)) < 8
|
||||
or value in {'super-secret-key-calendar-study', 'changeme'}):
|
||||
raise RuntimeError('FLASK_SECRET_KEY forte (mínimo de 32 caracteres) é obrigatória em produção.')
|
||||
return value
|
||||
return value or secrets.token_hex(32)
|
||||
|
||||
# Sincroniza feeds na primeira execução
|
||||
try:
|
||||
ics_sync.sync_all_feeds()
|
||||
except Exception as e:
|
||||
logger.warning(f"Erro na sincronização inicial: {e}")
|
||||
|
||||
def success_response(data=None, message="Operação realizada com sucesso."):
|
||||
return jsonify({"success": True, "data": data, "message": message})
|
||||
def create_app(test_config=None):
|
||||
environment = os.environ.get('APP_ENV', 'production').lower()
|
||||
if environment not in {'production', 'development', 'test'}:
|
||||
raise RuntimeError('APP_ENV deve ser production, development ou test.')
|
||||
supplied_secret = (test_config or {}).get('SECRET_KEY')
|
||||
trusted_hosts_value = os.environ.get('TRUSTED_HOSTS', '')
|
||||
trusted_hosts = [host.strip() for host in trusted_hosts_value.split(',') if host.strip()]
|
||||
if environment == 'production' and not trusted_hosts and not (test_config or {}).get('TRUSTED_HOSTS'):
|
||||
raise RuntimeError('TRUSTED_HOSTS é obrigatório em produção.')
|
||||
application = Flask(__name__, static_folder='static', template_folder='templates')
|
||||
application.config.update(
|
||||
SECRET_KEY=_secret(environment, supplied_secret),
|
||||
MAX_CONTENT_LENGTH=int(os.environ.get('MAX_CONTENT_LENGTH', str(64 * 1024))),
|
||||
MAX_FORM_MEMORY_SIZE=int(os.environ.get('MAX_FORM_MEMORY_SIZE', str(16 * 1024))),
|
||||
MAX_FORM_PARTS=20,
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=_env_bool('SESSION_COOKIE_SECURE', environment == 'production'),
|
||||
SESSION_COOKIE_SAMESITE=os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax'),
|
||||
SESSION_COOKIE_NAME='__Host-studycalendar' if environment == 'production' else 'studycalendar',
|
||||
SESSION_COOKIE_PATH='/',
|
||||
SESSION_REFRESH_EACH_REQUEST=False,
|
||||
PERMANENT_SESSION_LIFETIME=86400,
|
||||
DATABASE_PATH=os.environ.get(
|
||||
'DATABASE_PATH', os.path.join(application.root_path, 'calendario.db')
|
||||
),
|
||||
ICS_HTTPS_ONLY=_env_bool('ICS_HTTPS_ONLY', True),
|
||||
ENABLE_HSTS=_env_bool('ENABLE_HSTS', environment == 'production'),
|
||||
RATELIMIT_ENABLED=True,
|
||||
ALLOW_REGISTRATION=_env_bool('ALLOW_REGISTRATION', environment != 'production'),
|
||||
MAX_FEEDS_PER_USER=int(os.environ.get('MAX_FEEDS_PER_USER', '20')),
|
||||
MAX_MANUAL_EVENTS_PER_USER=int(os.environ.get('MAX_MANUAL_EVENTS_PER_USER', '10000')),
|
||||
MAX_NOTIFICATION_RULES_PER_USER=int(os.environ.get('MAX_NOTIFICATION_RULES_PER_USER', '20')),
|
||||
MAX_EVENT_RESULTS=int(os.environ.get('MAX_EVENT_RESULTS', '5000')),
|
||||
TRUSTED_HOSTS=trusted_hosts or None,
|
||||
)
|
||||
if test_config:
|
||||
application.config.update(test_config)
|
||||
|
||||
def error_response(message="Ocorreu um erro.", status_code=400):
|
||||
return jsonify({"success": False, "data": None, "message": message}), status_code
|
||||
bounded_config = {
|
||||
'MAX_FEEDS_PER_USER': (1, 100),
|
||||
'MAX_MANUAL_EVENTS_PER_USER': (1, 100000),
|
||||
'MAX_NOTIFICATION_RULES_PER_USER': (1, 100),
|
||||
'MAX_EVENT_RESULTS': (1, 10000),
|
||||
}
|
||||
for name, (minimum, maximum) in bounded_config.items():
|
||||
if not minimum <= application.config[name] <= maximum:
|
||||
raise RuntimeError(f'{name} deve estar entre {minimum} e {maximum}.')
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return error_response("Não autorizado.", 401)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
same_site = application.config['SESSION_COOKIE_SAMESITE']
|
||||
if same_site not in {'Strict', 'Lax', 'None'}:
|
||||
raise RuntimeError('SESSION_COOKIE_SAMESITE deve ser Strict, Lax ou None.')
|
||||
if same_site == 'None' and not application.config['SESSION_COOKIE_SECURE']:
|
||||
raise RuntimeError('SESSION_COOKIE_SAMESITE=None exige SESSION_COOKIE_SECURE=true.')
|
||||
if environment == 'production' and not application.config['SESSION_COOKIE_SECURE']:
|
||||
raise RuntimeError('SESSION_COOKIE_SECURE deve permanecer true em produção.')
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
trusted_proxies = int(os.environ.get('TRUSTED_PROXY_COUNT', '0'))
|
||||
if trusted_proxies < 0 or trusted_proxies > 5:
|
||||
raise RuntimeError('TRUSTED_PROXY_COUNT deve estar entre 0 e 5.')
|
||||
if trusted_proxies:
|
||||
application.wsgi_app = ProxyFix(
|
||||
application.wsgi_app, x_for=trusted_proxies, x_proto=trusted_proxies,
|
||||
x_host=trusted_proxies, x_port=trusted_proxies
|
||||
)
|
||||
|
||||
# ===================== Auth =====================
|
||||
database.configure(application.config['DATABASE_PATH'])
|
||||
database.init_db()
|
||||
limiter = RateLimiter()
|
||||
|
||||
@app.route('/api/auth/register', methods=['POST'])
|
||||
def register():
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
display_name = data.get('display_name', username)
|
||||
|
||||
if not username or not password:
|
||||
return error_response("Usuário e senha são obrigatórios.")
|
||||
|
||||
user_id = database.create_user(username, password, display_name)
|
||||
if user_id:
|
||||
def success_response(data=None, message='Operação realizada com sucesso.', status_code=200):
|
||||
return jsonify({'success': True, 'data': data, 'message': message}), status_code
|
||||
|
||||
def error_response(message='Ocorreu um erro.', status_code=400, headers=None):
|
||||
response = jsonify({'success': False, 'data': None, 'message': message})
|
||||
return (response, status_code, headers or {})
|
||||
|
||||
def login_required(function):
|
||||
@wraps(function)
|
||||
def decorated(*args, **kwargs):
|
||||
user_id = session.get('user_id')
|
||||
session_version = session.get('session_version')
|
||||
if user_id is None or session_version is None:
|
||||
return error_response('Não autorizado.', 401)
|
||||
if database.get_user_session_version(user_id) != session_version:
|
||||
session.clear()
|
||||
return error_response('Sessão expirada.', 401)
|
||||
return function(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
def not_found_if_false(found, message):
|
||||
return None if found else error_response(message, 404)
|
||||
|
||||
@application.before_request
|
||||
def protect_request():
|
||||
if request.path.startswith('/api/') and not valid_csrf(request.host_url):
|
||||
return error_response('Token CSRF ausente ou origem inválida.', 403)
|
||||
if not application.config['RATELIMIT_ENABLED'] or not request.path.startswith('/api/'):
|
||||
return None
|
||||
identity = session.get('user_id') or request.remote_addr or 'unknown'
|
||||
limit = 120
|
||||
bucket = 'global'
|
||||
if request.path in {'/api/auth/login', '/api/auth/register'}:
|
||||
identity = request.remote_addr or 'unknown'
|
||||
limit, bucket = 10, 'auth'
|
||||
elif '/sync' in request.path or (request.path == '/api/feeds' and request.method == 'POST'):
|
||||
limit, bucket = 15, 'feed'
|
||||
allowed, retry = limiter.check((bucket, identity), limit)
|
||||
if not allowed:
|
||||
return error_response('Muitas requisições. Tente novamente depois.', 429,
|
||||
{'Retry-After': str(retry)})
|
||||
return None
|
||||
|
||||
@application.after_request
|
||||
def secure_headers(response):
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; "
|
||||
"form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; connect-src 'self'; manifest-src 'self'"
|
||||
)
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||
)
|
||||
if application.config['ENABLE_HSTS']:
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
if request.path.startswith('/api/'):
|
||||
response.headers['Cache-Control'] = 'no-store, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
return response
|
||||
|
||||
@application.errorhandler(ValidationError)
|
||||
def validation_error(error):
|
||||
return error_response(str(error), 400)
|
||||
|
||||
@application.errorhandler(413)
|
||||
def too_large(_error):
|
||||
return error_response('Corpo da requisição excede o limite permitido.', 413)
|
||||
|
||||
@application.errorhandler(404)
|
||||
def route_not_found(_error):
|
||||
if request.path.startswith('/api/'):
|
||||
return error_response('Recurso não encontrado.', 404)
|
||||
return _error
|
||||
|
||||
@application.errorhandler(500)
|
||||
def internal_error(error):
|
||||
if request.path.startswith('/api/'):
|
||||
logger.error('Erro interno na API (path=%s, tipo=%s).', request.path,
|
||||
type(getattr(error, 'original_exception', error)).__name__)
|
||||
return error_response('Erro interno do servidor.', 500)
|
||||
return error
|
||||
|
||||
@application.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@application.cli.command('create-user')
|
||||
@click.option('--username', prompt=True)
|
||||
@click.option('--display-name', prompt='Nome de exibição')
|
||||
@click.password_option(confirmation_prompt=True)
|
||||
def create_user_command(username, display_name, password):
|
||||
"""Cria um usuário sem habilitar cadastro público."""
|
||||
try:
|
||||
normalized, validated_password, validated_name = validate_credentials({
|
||||
'username': username,
|
||||
'password': password,
|
||||
'display_name': display_name,
|
||||
}, True)
|
||||
except ValidationError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
if not database.create_user(normalized, validated_password, validated_name):
|
||||
raise click.ClickException('Não foi possível criar o usuário.')
|
||||
click.echo('Usuário criado.')
|
||||
|
||||
@application.get('/api/auth/csrf')
|
||||
def get_csrf():
|
||||
return success_response({
|
||||
'csrf_token': csrf_token(),
|
||||
'registration_enabled': application.config['ALLOW_REGISTRATION'],
|
||||
})
|
||||
|
||||
@application.post('/api/auth/register')
|
||||
def register():
|
||||
if not application.config['ALLOW_REGISTRATION']:
|
||||
return error_response('Cadastro público desativado.', 403)
|
||||
username, password, display_name = validate_credentials(require_json(), True)
|
||||
user_id = database.create_user(username, password, display_name)
|
||||
if not user_id:
|
||||
return error_response('Não foi possível criar a conta com os dados informados.', 409)
|
||||
request_csrf = session.get('csrf_token')
|
||||
session.clear()
|
||||
session['user_id'] = user_id
|
||||
session['username'] = username
|
||||
return success_response({"id": user_id, "username": username}, "Usuário registrado com sucesso.")
|
||||
return error_response("Nome de usuário já existe.")
|
||||
session['session_version'] = database.get_user_session_version(user_id)
|
||||
session['csrf_token'] = request_csrf or csrf_token()
|
||||
return success_response({'id': user_id, 'username': username}, 'Usuário registrado com sucesso.')
|
||||
|
||||
@app.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
user = database.verify_user_password(username, password)
|
||||
if user:
|
||||
@application.post('/api/auth/login')
|
||||
def login():
|
||||
username, password, _ = validate_credentials(require_json())
|
||||
user = database.verify_user_password(username, password)
|
||||
if not user:
|
||||
return error_response('Usuário ou senha inválidos.', 401)
|
||||
request_csrf = session.get('csrf_token')
|
||||
session.clear()
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
return success_response({"id": user['id'], "username": user['username'], "display_name": user['display_name']}, "Login realizado.")
|
||||
return error_response("Usuário ou senha inválidos.", 401)
|
||||
session['session_version'] = user['session_version']
|
||||
session['csrf_token'] = request_csrf or csrf_token()
|
||||
return success_response(
|
||||
{'id': user['id'], 'username': user['username'], 'display_name': user['display_name']},
|
||||
'Login realizado.'
|
||||
)
|
||||
|
||||
@app.route('/api/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
session.clear()
|
||||
return success_response(message="Logout realizado.")
|
||||
@application.post('/api/auth/logout')
|
||||
def logout():
|
||||
user_id = session.get('user_id')
|
||||
if user_id is not None:
|
||||
database.revoke_user_sessions(user_id)
|
||||
session.clear()
|
||||
return success_response(message='Logout realizado.')
|
||||
|
||||
@app.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def get_me():
|
||||
user = database.get_user_by_id(session['user_id'])
|
||||
if user:
|
||||
return success_response(user)
|
||||
return error_response("Usuário não encontrado.", 404)
|
||||
@application.get('/api/auth/me')
|
||||
@login_required
|
||||
def get_me():
|
||||
user = database.get_user_by_id(session['user_id'])
|
||||
return success_response(user) if user else error_response('Usuário não encontrado.', 404)
|
||||
|
||||
# ===================== Eventos =====================
|
||||
|
||||
@app.route('/api/events', methods=['GET'])
|
||||
@login_required
|
||||
def list_events():
|
||||
try:
|
||||
start_iso = request.args.get('start')
|
||||
end_iso = request.args.get('end')
|
||||
@application.get('/api/events')
|
||||
@login_required
|
||||
def list_events():
|
||||
start = iso_datetime(request.args.get('start'), 'start')
|
||||
end = iso_datetime(request.args.get('end'), 'end')
|
||||
if start and end:
|
||||
start_value = datetime.fromisoformat(start)
|
||||
end_value = datetime.fromisoformat(end)
|
||||
if bool(start_value.tzinfo) != bool(end_value.tzinfo) or start_value > end_value:
|
||||
raise ValidationError('end não pode ser anterior a start e deve usar o mesmo fuso.')
|
||||
category = request.args.get('category')
|
||||
if category and category not in CATEGORIES | {'todas'}:
|
||||
raise ValidationError('category inválida.')
|
||||
search = request.args.get('search')
|
||||
|
||||
events = database.get_events(session['user_id'], start_iso, end_iso, category, search)
|
||||
return success_response(events)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro listando eventos: {str(e)}")
|
||||
return error_response("Erro ao listar eventos.", 500)
|
||||
if search is not None and len(search) > 100:
|
||||
raise ValidationError('search excede 100 caracteres.')
|
||||
return success_response(database.get_events(
|
||||
session['user_id'], start, end, category, search,
|
||||
limit=application.config['MAX_EVENT_RESULTS']
|
||||
))
|
||||
|
||||
@app.route('/api/events', methods=['POST'])
|
||||
@login_required
|
||||
def create_event():
|
||||
try:
|
||||
data = request.json
|
||||
@application.post('/api/events')
|
||||
@login_required
|
||||
def create_event():
|
||||
values = validate_event(require_json())
|
||||
event_id = database.add_event(
|
||||
user_id=session['user_id'],
|
||||
title=data.get('title'),
|
||||
start_datetime=data.get('start_datetime'),
|
||||
end_datetime=data.get('end_datetime'),
|
||||
description=data.get('description'),
|
||||
location=data.get('location'),
|
||||
all_day=data.get('all_day', False),
|
||||
category=data.get('category', 'geral'),
|
||||
priority=data.get('priority', 'normal')
|
||||
user_id=session['user_id'], max_count=application.config['MAX_MANUAL_EVENTS_PER_USER'],
|
||||
**values
|
||||
)
|
||||
return success_response({"id": event_id}, "Evento criado com sucesso.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro criando evento: {str(e)}")
|
||||
return error_response("Erro ao criar evento.", 500)
|
||||
if not event_id:
|
||||
return error_response('Limite de eventos manuais atingido.', 409)
|
||||
return success_response({'id': event_id}, 'Evento criado com sucesso.')
|
||||
|
||||
@app.route('/api/events/<int:event_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_event_route(event_id):
|
||||
try:
|
||||
data = request.json
|
||||
database.update_event(
|
||||
event_id=event_id,
|
||||
user_id=session['user_id'],
|
||||
title=data.get('title'),
|
||||
start_datetime=data.get('start_datetime'),
|
||||
end_datetime=data.get('end_datetime'),
|
||||
description=data.get('description'),
|
||||
location=data.get('location'),
|
||||
all_day=data.get('all_day', False),
|
||||
category=data.get('category', 'geral'),
|
||||
priority=data.get('priority', 'normal')
|
||||
)
|
||||
return success_response(message="Evento atualizado com sucesso.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro atualizando evento: {str(e)}")
|
||||
return error_response("Erro ao atualizar evento.", 500)
|
||||
@application.put('/api/events/<int:event_id>')
|
||||
@login_required
|
||||
def update_event_route(event_id):
|
||||
values = validate_event(require_json())
|
||||
if not database.update_event(event_id=event_id, user_id=session['user_id'], **values):
|
||||
return error_response('Evento não encontrado.', 404)
|
||||
return success_response(message='Evento atualizado com sucesso.')
|
||||
|
||||
@app.route('/api/events/<int:event_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_event_route(event_id):
|
||||
try:
|
||||
database.delete_event(event_id, session['user_id'])
|
||||
return success_response(message="Evento excluído com sucesso.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro excluindo evento: {str(e)}")
|
||||
return error_response("Erro ao excluir evento.", 500)
|
||||
@application.delete('/api/events/<int:event_id>')
|
||||
@login_required
|
||||
def delete_event_route(event_id):
|
||||
missing = not_found_if_false(database.delete_event(event_id, session['user_id']), 'Evento não encontrado.')
|
||||
return missing or success_response(message='Evento excluído com sucesso.')
|
||||
|
||||
@app.route('/api/events/<int:event_id>/toggle', methods=['PATCH'])
|
||||
@login_required
|
||||
def toggle_event(event_id):
|
||||
try:
|
||||
database.toggle_event_completed(event_id, session['user_id'])
|
||||
return success_response(message="Status do evento alterado.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro alterando status do evento: {str(e)}")
|
||||
return error_response("Erro ao alterar status do evento.", 500)
|
||||
@application.patch('/api/events/<int:event_id>/toggle')
|
||||
@login_required
|
||||
def toggle_event(event_id):
|
||||
if not database.toggle_event_completed(event_id, session['user_id']):
|
||||
return error_response('Evento não encontrado.', 404)
|
||||
return success_response(message='Status do evento alterado.')
|
||||
|
||||
# ===================== Feeds =====================
|
||||
@application.get('/api/feeds')
|
||||
@login_required
|
||||
def list_feeds():
|
||||
return success_response(database.get_feeds(session['user_id']))
|
||||
|
||||
@app.route('/api/feeds', methods=['GET'])
|
||||
@login_required
|
||||
def list_feeds():
|
||||
try:
|
||||
feeds = database.get_feeds(session['user_id'])
|
||||
return success_response(feeds)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro listando feeds: {str(e)}")
|
||||
return error_response("Erro ao listar feeds.", 500)
|
||||
|
||||
@app.route('/api/feeds', methods=['POST'])
|
||||
@login_required
|
||||
def add_feed_route():
|
||||
try:
|
||||
data = request.json
|
||||
@application.post('/api/feeds')
|
||||
@login_required
|
||||
def add_feed_route():
|
||||
values = validate_feed(require_json(), application.config['ICS_HTTPS_ONLY'])
|
||||
validate_public_url(values['url'], application.config['ICS_HTTPS_ONLY'])
|
||||
feed_id = database.add_feed(
|
||||
user_id=session['user_id'],
|
||||
url=data.get('url'),
|
||||
name=data.get('name'),
|
||||
color=data.get('color', '#4A90D9'),
|
||||
sync_interval_minutes=data.get('sync_interval_minutes', 30)
|
||||
user_id=session['user_id'], max_count=application.config['MAX_FEEDS_PER_USER'],
|
||||
**values
|
||||
)
|
||||
if not feed_id:
|
||||
return error_response('Feed duplicado ou limite de feeds atingido.', 409)
|
||||
return success_response(
|
||||
{'id': feed_id, 'sync_queued': True}, 'Feed adicionado; sincronização agendada.', 202
|
||||
)
|
||||
if feed_id:
|
||||
ics_sync.sync_feed(feed_id)
|
||||
return success_response({"id": feed_id}, "Feed adicionado com sucesso.")
|
||||
else:
|
||||
return error_response("Feed com esta URL já existe para este usuário.", 400)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro adicionando feed: {str(e)}")
|
||||
return error_response("Erro ao adicionar feed.", 500)
|
||||
|
||||
@app.route('/api/feeds/<int:feed_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_feed_route(feed_id):
|
||||
try:
|
||||
database.delete_feed(feed_id, session['user_id'])
|
||||
return success_response(message="Feed excluído com sucesso.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro excluindo feed: {str(e)}")
|
||||
return error_response("Erro ao excluir feed.", 500)
|
||||
@application.delete('/api/feeds/<int:feed_id>')
|
||||
@login_required
|
||||
def delete_feed_route(feed_id):
|
||||
missing = not_found_if_false(database.delete_feed(feed_id, session['user_id']), 'Feed não encontrado.')
|
||||
return missing or success_response(message='Feed excluído com sucesso.')
|
||||
|
||||
@app.route('/api/feeds/<int:feed_id>/sync', methods=['POST'])
|
||||
@login_required
|
||||
def sync_feed_route(feed_id):
|
||||
try:
|
||||
# Check ownership
|
||||
feed = database.get_feed(feed_id, session['user_id'])
|
||||
if not feed:
|
||||
return error_response("Feed não encontrado.", 404)
|
||||
|
||||
success = ics_sync.sync_feed(feed_id)
|
||||
if success:
|
||||
return success_response(message="Feed sincronizado com sucesso.")
|
||||
else:
|
||||
return error_response("Falha ao sincronizar feed.", 500)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro sincronizando feed: {str(e)}")
|
||||
return error_response("Erro ao sincronizar feed.", 500)
|
||||
@application.post('/api/feeds/<int:feed_id>/sync')
|
||||
@login_required
|
||||
def sync_feed_route(feed_id):
|
||||
if not database.request_feed_sync(feed_id, session['user_id']):
|
||||
return error_response('Feed não encontrado.', 404)
|
||||
return success_response(message='Sincronização agendada.', status_code=202)
|
||||
|
||||
@app.route('/api/feeds/sync-all', methods=['POST'])
|
||||
@login_required
|
||||
def sync_all_feeds_route():
|
||||
try:
|
||||
@application.post('/api/feeds/sync-all')
|
||||
@login_required
|
||||
def sync_all_feeds_route():
|
||||
feeds = database.get_feeds(session['user_id'])
|
||||
for feed in feeds:
|
||||
ics_sync.sync_feed(feed['id'])
|
||||
return success_response(message="Seus feeds foram sincronizados.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro sincronizando todos os feeds: {str(e)}")
|
||||
return error_response("Erro ao sincronizar todos os feeds.", 500)
|
||||
database.request_feed_sync(feed['id'], session['user_id'])
|
||||
return success_response(message='Sincronizações agendadas.', status_code=202)
|
||||
|
||||
# ===================== Notificações =====================
|
||||
@application.get('/api/notifications/upcoming')
|
||||
@login_required
|
||||
def get_upcoming_notifications():
|
||||
return success_response(database.get_upcoming_notifications(session['user_id']))
|
||||
|
||||
@app.route('/api/notifications/upcoming', methods=['GET'])
|
||||
@login_required
|
||||
def get_upcoming_notifications():
|
||||
try:
|
||||
events = database.get_upcoming_notifications(session['user_id'])
|
||||
return success_response(events)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro obtendo notificações: {str(e)}")
|
||||
return error_response("Erro ao obter notificações.", 500)
|
||||
@application.post('/api/notifications/mark-notified')
|
||||
@login_required
|
||||
def mark_notified():
|
||||
data = require_json()
|
||||
unknown = set(data) - {'event_id', 'rule_id'}
|
||||
if unknown:
|
||||
raise ValidationError(f"Campos desconhecidos: {', '.join(sorted(unknown))}.")
|
||||
event_id = integer(data, 'event_id', minimum=1, maximum=2 ** 63 - 1)
|
||||
rule_id = integer(data, 'rule_id', minimum=1, maximum=2 ** 63 - 1)
|
||||
if not database.mark_event_notified(event_id, rule_id, session['user_id']):
|
||||
return error_response('Evento ou regra não encontrado.', 404)
|
||||
return success_response(message='Evento marcado como notificado para a regra.')
|
||||
|
||||
@app.route('/api/notifications/mark-notified', methods=['POST'])
|
||||
@login_required
|
||||
def mark_notified():
|
||||
try:
|
||||
data = request.json
|
||||
event_id = data.get('event_id')
|
||||
rule_id = data.get('rule_id')
|
||||
if not event_id or not rule_id:
|
||||
return error_response("event_id e rule_id são obrigatórios.", 400)
|
||||
database.mark_event_notified(event_id, rule_id)
|
||||
return success_response(message="Evento marcado como notificado para a regra.")
|
||||
except Exception as e:
|
||||
logger.error(f"Erro marcando notificação: {str(e)}")
|
||||
return error_response("Erro ao marcar notificação.", 500)
|
||||
@application.get('/api/notifications/rules')
|
||||
@login_required
|
||||
def get_notification_rules_route():
|
||||
return success_response(database.get_notification_rules(session['user_id']))
|
||||
|
||||
@app.route('/api/notifications/rules', methods=['GET'])
|
||||
@login_required
|
||||
def get_notification_rules_route():
|
||||
try:
|
||||
rules = database.get_notification_rules(session['user_id'])
|
||||
return success_response(rules)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro obtendo regras: {str(e)}")
|
||||
return error_response("Erro ao obter regras.", 500)
|
||||
@application.post('/api/notifications/rules')
|
||||
@login_required
|
||||
def add_notification_rule_route():
|
||||
data = require_json()
|
||||
unknown = set(data) - {'minutes_before', 'label'}
|
||||
if unknown:
|
||||
raise ValidationError(f"Campos desconhecidos: {', '.join(sorted(unknown))}.")
|
||||
minutes = integer(data, 'minutes_before', minimum=1, maximum=10080, default=30)
|
||||
label = text(data, 'label', required=True, minimum=1, maximum=100)
|
||||
rule_id = database.add_notification_rule(
|
||||
session['user_id'], minutes, label,
|
||||
max_count=application.config['MAX_NOTIFICATION_RULES_PER_USER']
|
||||
)
|
||||
if not rule_id:
|
||||
return error_response('Regra duplicada ou limite de regras atingido.', 409)
|
||||
return success_response({'id': rule_id}, 'Regra criada.')
|
||||
|
||||
@app.route('/api/notifications/rules', methods=['POST'])
|
||||
@login_required
|
||||
def add_notification_rule_route():
|
||||
try:
|
||||
data = request.json
|
||||
minutes = int(data.get('minutes_before', 30))
|
||||
label = data.get('label', f"{minutes} minutos antes")
|
||||
|
||||
rule_id = database.add_notification_rule(session['user_id'], minutes, label)
|
||||
if rule_id:
|
||||
return success_response({"id": rule_id}, "Regra criada.")
|
||||
return error_response("Regra com este tempo já existe.", 400)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro criando regra: {str(e)}")
|
||||
return error_response("Erro ao criar regra.", 500)
|
||||
@application.delete('/api/notifications/rules/<int:rule_id>')
|
||||
@login_required
|
||||
def delete_notification_rule_route(rule_id):
|
||||
if not database.delete_notification_rule(rule_id, session['user_id']):
|
||||
return error_response('Regra não encontrada.', 404)
|
||||
return success_response(message='Regra removida.')
|
||||
|
||||
@app.route('/api/notifications/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_notification_rule_route(rule_id):
|
||||
try:
|
||||
database.delete_notification_rule(rule_id, session['user_id'])
|
||||
return success_response(message="Regra removida.")
|
||||
except Exception as e:
|
||||
return error_response("Erro ao remover regra.", 500)
|
||||
@application.patch('/api/notifications/rules/<int:rule_id>/toggle')
|
||||
@login_required
|
||||
def toggle_notification_rule_route(rule_id):
|
||||
if not database.toggle_notification_rule(rule_id, session['user_id']):
|
||||
return error_response('Regra não encontrada.', 404)
|
||||
return success_response(message='Status da regra alterado.')
|
||||
|
||||
@app.route('/api/notifications/rules/<int:rule_id>/toggle', methods=['PATCH'])
|
||||
@login_required
|
||||
def toggle_notification_rule_route(rule_id):
|
||||
try:
|
||||
database.toggle_notification_rule(rule_id, session['user_id'])
|
||||
return success_response(message="Status da regra alterado.")
|
||||
except Exception as e:
|
||||
return error_response("Erro ao alterar regra.", 500)
|
||||
@application.get('/api/stats')
|
||||
@login_required
|
||||
def get_stats_route():
|
||||
return success_response(database.get_stats(session['user_id']))
|
||||
|
||||
# ===================== Estatísticas =====================
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.route('/api/stats', methods=['GET'])
|
||||
@login_required
|
||||
def get_stats_route():
|
||||
try:
|
||||
stats = database.get_stats(session['user_id'])
|
||||
return success_response(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Erro obtendo estatísticas: {str(e)}")
|
||||
return error_response("Erro ao obter estatísticas.", 500)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
if os.environ.get('APP_ENV') != 'development':
|
||||
raise RuntimeError('O servidor embutido só pode ser usado com APP_ENV=development.')
|
||||
app.run(host=os.environ.get('DEV_HOST', '127.0.0.1'), port=int(os.environ.get('PORT', '5000')), debug=False)
|
||||
|
|
|
|||
323
database.py
323
database.py
|
|
@ -6,15 +6,27 @@ Suporte a múltiplos usuários, regras de notificação, e CRUD completo.
|
|||
import sqlite3
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
DATABASE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'calendario.db')
|
||||
|
||||
_DUMMY_PASSWORD_HASH = generate_password_hash(secrets.token_urlsafe(32), method='scrypt')
|
||||
|
||||
DATABASE_PATH = os.environ.get(
|
||||
'DATABASE_PATH', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'calendario.db')
|
||||
)
|
||||
|
||||
|
||||
def configure(path):
|
||||
"""Configura o arquivo SQLite antes de inicializar a aplicação."""
|
||||
global DATABASE_PATH
|
||||
DATABASE_PATH = os.path.abspath(path)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Retorna uma conexão com o banco de dados SQLite."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
os.chmod(DATABASE_PATH, 0o600)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
|
@ -37,6 +49,10 @@ def init_db():
|
|||
)
|
||||
''')
|
||||
|
||||
user_columns = {row[1] for row in cur.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if 'session_version' not in user_columns:
|
||||
cur.execute("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# Tabela de feeds ICS
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
|
|
@ -52,6 +68,11 @@ def init_db():
|
|||
UNIQUE(user_id, url)
|
||||
)
|
||||
''')
|
||||
feed_columns = {row[1] for row in cur.execute("PRAGMA table_info(feeds)").fetchall()}
|
||||
if 'sync_requested_at' not in feed_columns:
|
||||
cur.execute("ALTER TABLE feeds ADD COLUMN sync_requested_at TIMESTAMP")
|
||||
if 'last_attempted' not in feed_columns:
|
||||
cur.execute("ALTER TABLE feeds ADD COLUMN last_attempted TIMESTAMP")
|
||||
|
||||
# Tabela de eventos
|
||||
cur.execute('''
|
||||
|
|
@ -78,6 +99,13 @@ def init_db():
|
|||
)
|
||||
''')
|
||||
|
||||
event_columns = {row[1] for row in cur.execute("PRAGMA table_info(events)").fetchall()}
|
||||
current_feed_columns = {row[1] for row in cur.execute("PRAGMA table_info(feeds)").fetchall()}
|
||||
if 'user_id' not in event_columns or 'user_id' not in current_feed_columns:
|
||||
raise RuntimeError(
|
||||
'Banco legado sem proprietário. Execute uma migração offline definindo user_id.'
|
||||
)
|
||||
|
||||
# Regras de notificação (múltiplas por usuário)
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS notification_rules (
|
||||
|
|
@ -92,6 +120,12 @@ def init_db():
|
|||
)
|
||||
''')
|
||||
|
||||
notification_columns = {
|
||||
row[1] for row in cur.execute("PRAGMA table_info(notification_log)").fetchall()
|
||||
}
|
||||
if notification_columns and 'rule_id' not in notification_columns:
|
||||
cur.execute("DROP TABLE notification_log")
|
||||
|
||||
# Log de notificações enviadas (por evento + regra)
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS notification_log (
|
||||
|
|
@ -104,9 +138,7 @@ def init_db():
|
|||
UNIQUE(event_id, rule_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Migração: tabelas antigas sem user_id
|
||||
_migrate_legacy_tables(cur)
|
||||
cur.execute("PRAGMA user_version = 3")
|
||||
|
||||
# Índices
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start_datetime)")
|
||||
|
|
@ -116,38 +148,30 @@ def init_db():
|
|||
cur.execute("CREATE INDEX IF NOT EXISTS idx_notif_rules_user ON notification_rules(user_id)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_notif_log_event ON notification_log(event_id)")
|
||||
|
||||
# Garante que evento e regra do log pertençam ao mesmo usuário.
|
||||
cur.execute('''
|
||||
CREATE TRIGGER IF NOT EXISTS notification_log_same_owner_insert
|
||||
BEFORE INSERT ON notification_log
|
||||
BEGIN
|
||||
SELECT CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM events e JOIN notification_rules r ON r.id = NEW.rule_id
|
||||
WHERE e.id = NEW.event_id AND e.user_id = r.user_id
|
||||
) THEN RAISE(ABORT, 'notification ownership mismatch') END;
|
||||
END
|
||||
''')
|
||||
cur.execute('''
|
||||
DELETE FROM notification_log
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM events e JOIN notification_rules r
|
||||
ON r.id = notification_log.rule_id
|
||||
WHERE e.id = notification_log.event_id AND e.user_id = r.user_id
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate_legacy_tables(cur):
|
||||
"""Migra dados de tabelas antigas (sem user_id) para o novo schema."""
|
||||
# Verificar se existem tabelas antigas sem user_id
|
||||
try:
|
||||
cur.execute("SELECT user_id FROM events LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
# Coluna user_id não existe - tabela antiga
|
||||
try:
|
||||
cur.execute("ALTER TABLE events ADD COLUMN user_id INTEGER DEFAULT 1")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
try:
|
||||
cur.execute("SELECT user_id FROM feeds LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute("ALTER TABLE feeds ADD COLUMN user_id INTEGER DEFAULT 1")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Remover tabelas de notificação antigas
|
||||
try:
|
||||
cur.execute("SELECT notify_before_minutes FROM notification_settings LIMIT 1")
|
||||
cur.execute("DROP TABLE IF EXISTS notification_settings")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
# ===================== Usuários =====================
|
||||
|
||||
def create_user(username, password, display_name):
|
||||
|
|
@ -185,9 +209,10 @@ def create_user(username, password, display_name):
|
|||
def get_user_by_username(username):
|
||||
"""Retorna dados do usuário pelo username."""
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username.strip().lower(),)
|
||||
).fetchone()
|
||||
if not isinstance(username, str):
|
||||
conn.close()
|
||||
return None
|
||||
user = conn.execute("SELECT * FROM users WHERE username = ?", (username.strip().lower(),)).fetchone()
|
||||
conn.close()
|
||||
return dict(user) if user else None
|
||||
|
||||
|
|
@ -200,10 +225,30 @@ def get_user_by_id(user_id):
|
|||
return dict(user) if user else None
|
||||
|
||||
|
||||
def get_user_session_version(user_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT session_version FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return row['session_version'] if row else None
|
||||
|
||||
|
||||
def revoke_user_sessions(user_id):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def verify_user_password(username, password):
|
||||
"""Verifica username e senha. Retorna o usuário ou None."""
|
||||
user = get_user_by_username(username)
|
||||
if user and check_password_hash(user['password_hash'], password):
|
||||
if not user:
|
||||
check_password_hash(_DUMMY_PASSWORD_HASH, password)
|
||||
return None
|
||||
if check_password_hash(user['password_hash'], password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
|
@ -214,12 +259,21 @@ def get_feeds(user_id):
|
|||
"""Retorna todos os feeds de um usuário."""
|
||||
conn = get_db()
|
||||
feeds = conn.execute(
|
||||
"SELECT * FROM feeds WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
|
||||
"""SELECT id, user_id, name, color, sync_interval_minutes, last_synced,
|
||||
last_attempted, sync_requested_at, created_at
|
||||
FROM feeds WHERE user_id = ? ORDER BY created_at DESC""", (user_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(f) for f in feeds]
|
||||
|
||||
|
||||
def count_feeds(user_id):
|
||||
conn = get_db()
|
||||
count = conn.execute("SELECT COUNT(*) FROM feeds WHERE user_id = ?", (user_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def get_feed(feed_id, user_id=None):
|
||||
"""Retorna um feed pelo ID, verificando propriedade se user_id fornecido."""
|
||||
conn = get_db()
|
||||
|
|
@ -241,13 +295,24 @@ def get_all_feeds():
|
|||
return [dict(f) for f in feeds]
|
||||
|
||||
|
||||
def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30):
|
||||
def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30, max_count=None):
|
||||
"""Cria um novo feed ICS."""
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if max_count is not None:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM feeds WHERE user_id = ?", (user_id,)
|
||||
).fetchone()[0]
|
||||
if count >= max_count:
|
||||
conn.rollback()
|
||||
return None
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO feeds (user_id, url, name, color, sync_interval_minutes) VALUES (?, ?, ?, ?, ?)",
|
||||
(user_id, url, name, color, sync_interval_minutes)
|
||||
"""INSERT INTO feeds
|
||||
(user_id, url, name, color, sync_interval_minutes, sync_requested_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, url, name, color, sync_interval_minutes, now)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
|
@ -260,53 +325,90 @@ def add_feed(user_id, url, name, color='#4A90D9', sync_interval_minutes=30):
|
|||
def delete_feed(feed_id, user_id):
|
||||
"""Remove um feed verificando propriedade."""
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id))
|
||||
cur = conn.execute("DELETE FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def update_feed_sync_time(feed_id):
|
||||
"""Atualiza o timestamp de última sincronização."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE feeds SET last_synced = ? WHERE id = ?",
|
||||
(datetime.now().isoformat(), feed_id)
|
||||
cur = conn.execute(
|
||||
"UPDATE feeds SET last_synced = ?, sync_requested_at = NULL WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), feed_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def request_feed_sync(feed_id, user_id):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"UPDATE feeds SET sync_requested_at = ? WHERE id = ? AND user_id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), feed_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def mark_feed_sync_attempt(feed_id):
|
||||
"""Registra uma tentativa e retira o pedido da fila para aplicar backoff pelo intervalo."""
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"UPDATE feeds SET last_attempted = ?, sync_requested_at = NULL WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), feed_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ===================== Eventos =====================
|
||||
|
||||
def get_events(user_id, start_iso=None, end_iso=None, category=None, search=None):
|
||||
def get_events(user_id, start_iso=None, end_iso=None, category=None, search=None, limit=5000):
|
||||
"""Retorna eventos de um usuário com filtros."""
|
||||
conn = get_db()
|
||||
query = "SELECT * FROM events WHERE user_id = ?"
|
||||
query = """SELECT events.*, feeds.color AS feed_color
|
||||
FROM events LEFT JOIN feeds ON feeds.id = events.feed_id
|
||||
WHERE events.user_id = ?"""
|
||||
params = [user_id]
|
||||
|
||||
if start_iso:
|
||||
query += " AND (end_datetime >= ? OR (end_datetime IS NULL AND start_datetime >= ?))"
|
||||
query += " AND (julianday(events.end_datetime) > julianday(?) OR (events.end_datetime IS NULL AND julianday(events.start_datetime) >= julianday(?)))"
|
||||
params.extend([start_iso, start_iso])
|
||||
if end_iso:
|
||||
query += " AND start_datetime <= ?"
|
||||
query += " AND julianday(events.start_datetime) < julianday(?)"
|
||||
params.append(end_iso)
|
||||
if category and category != 'todas':
|
||||
query += " AND category = ?"
|
||||
query += " AND events.category = ?"
|
||||
params.append(category)
|
||||
if search:
|
||||
query += " AND (title LIKE ? OR description LIKE ?)"
|
||||
query += " AND (events.title LIKE ? OR events.description LIKE ?)"
|
||||
params.extend([f'%{search}%', f'%{search}%'])
|
||||
|
||||
query += " ORDER BY start_datetime ASC"
|
||||
query += " ORDER BY julianday(events.start_datetime) ASC LIMIT ?"
|
||||
params.append(limit)
|
||||
events = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(e) for e in events]
|
||||
|
||||
|
||||
def count_manual_events(user_id):
|
||||
conn = get_db()
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND source = 'manual'", (user_id,)
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def get_events_for_day(user_id, date_str):
|
||||
"""Retorna eventos de um dia específico (formato: YYYY-MM-DD)."""
|
||||
day_start = f"{date_str}T00:00:00"
|
||||
day_end = f"{date_str}T23:59:59"
|
||||
day_end = (datetime.fromisoformat(date_str) + timedelta(days=1)).isoformat()
|
||||
return get_events(user_id, start_iso=day_start, end_iso=day_end)
|
||||
|
||||
|
||||
|
|
@ -321,10 +423,19 @@ def get_event(event_id, user_id):
|
|||
|
||||
|
||||
def add_event(user_id, title, start_datetime, end_datetime=None, description=None,
|
||||
location=None, all_day=False, category='geral', priority='normal'):
|
||||
location=None, all_day=False, category='geral', priority='normal', max_count=None):
|
||||
"""Cria um evento manual."""
|
||||
conn = get_db()
|
||||
now = datetime.now().isoformat()
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if max_count is not None:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND source = 'manual'", (user_id,)
|
||||
).fetchone()[0]
|
||||
if count >= max_count:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return None
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO events (user_id, title, start_datetime, end_datetime, description,
|
||||
location, all_day, category, priority, source, created_at, updated_at)
|
||||
|
|
@ -342,24 +453,26 @@ def update_event(event_id, user_id, title, start_datetime, end_datetime=None,
|
|||
description=None, location=None, all_day=False, category='geral', priority='normal'):
|
||||
"""Atualiza um evento verificando propriedade."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"""UPDATE events SET title=?, start_datetime=?, end_datetime=?, description=?,
|
||||
location=?, all_day=?, category=?, priority=?, updated_at=?
|
||||
WHERE id=? AND user_id=?""",
|
||||
(title, start_datetime, end_datetime, description, location,
|
||||
1 if all_day else 0, category, priority, datetime.now().isoformat(),
|
||||
1 if all_day else 0, category, priority, datetime.now(timezone.utc).isoformat(),
|
||||
event_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_event(event_id, user_id):
|
||||
"""Remove um evento verificando propriedade."""
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM events WHERE id = ? AND user_id = ?", (event_id, user_id))
|
||||
cur = conn.execute("DELETE FROM events WHERE id = ? AND user_id = ?", (event_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def toggle_event_completed(event_id, user_id):
|
||||
|
|
@ -367,7 +480,7 @@ def toggle_event_completed(event_id, user_id):
|
|||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE events SET completed = NOT completed, updated_at = ? WHERE id = ? AND user_id = ?",
|
||||
(datetime.now().isoformat(), event_id, user_id)
|
||||
(datetime.now(timezone.utc).isoformat(), event_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
event = conn.execute("SELECT completed FROM events WHERE id = ? AND user_id = ?", (event_id, user_id)).fetchone()
|
||||
|
|
@ -379,7 +492,7 @@ def upsert_ics_event(feed_id, user_id, uid, title, start_datetime, end_datetime,
|
|||
description, location, all_day):
|
||||
"""Insere ou atualiza evento de feed ICS."""
|
||||
conn = get_db()
|
||||
now = datetime.now().isoformat()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM events WHERE uid = ? AND feed_id = ?", (uid, feed_id)
|
||||
).fetchone()
|
||||
|
|
@ -403,6 +516,55 @@ def upsert_ics_event(feed_id, user_id, uid, title, start_datetime, end_datetime,
|
|||
conn.close()
|
||||
|
||||
|
||||
def replace_ics_events(feed_id, user_id, events):
|
||||
"""Atualiza um feed inteiro em uma única transação e remove itens ausentes."""
|
||||
conn = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
owner = conn.execute(
|
||||
"SELECT 1 FROM feeds WHERE id = ? AND user_id = ?", (feed_id, user_id)
|
||||
).fetchone()
|
||||
if not owner:
|
||||
return False
|
||||
uids = []
|
||||
for event in events:
|
||||
uids.append(event['uid'])
|
||||
conn.execute(
|
||||
"""INSERT INTO events
|
||||
(user_id, feed_id, uid, title, start_datetime, end_datetime, description,
|
||||
location, all_day, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ics', ?, ?)
|
||||
ON CONFLICT(uid, feed_id) DO UPDATE SET
|
||||
title=excluded.title, start_datetime=excluded.start_datetime,
|
||||
end_datetime=excluded.end_datetime, description=excluded.description,
|
||||
location=excluded.location, all_day=excluded.all_day,
|
||||
updated_at=excluded.updated_at""",
|
||||
(user_id, feed_id, event['uid'], event['title'], event['start_datetime'],
|
||||
event['end_datetime'], event['description'], event['location'],
|
||||
1 if event['all_day'] else 0, now, now)
|
||||
)
|
||||
if uids:
|
||||
placeholders = ','.join('?' for _ in uids)
|
||||
conn.execute(
|
||||
f"DELETE FROM events WHERE feed_id = ? AND uid NOT IN ({placeholders})",
|
||||
[feed_id, *uids]
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM events WHERE feed_id = ?", (feed_id,))
|
||||
conn.execute(
|
||||
"""UPDATE feeds SET last_synced = ?, last_attempted = ?, sync_requested_at = NULL
|
||||
WHERE id = ?""",
|
||||
(now, now, feed_id)
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ===================== Regras de Notificação =====================
|
||||
|
||||
def get_notification_rules(user_id):
|
||||
|
|
@ -416,10 +578,18 @@ def get_notification_rules(user_id):
|
|||
return [dict(r) for r in rules]
|
||||
|
||||
|
||||
def add_notification_rule(user_id, minutes_before, label):
|
||||
def add_notification_rule(user_id, minutes_before, label, max_count=None):
|
||||
"""Adiciona uma regra de notificação."""
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if max_count is not None:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM notification_rules WHERE user_id = ?", (user_id,)
|
||||
).fetchone()[0]
|
||||
if count >= max_count:
|
||||
conn.rollback()
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"INSERT INTO notification_rules (user_id, minutes_before, label) VALUES (?, ?, ?)",
|
||||
(user_id, minutes_before, label)
|
||||
|
|
@ -435,11 +605,12 @@ def add_notification_rule(user_id, minutes_before, label):
|
|||
def delete_notification_rule(rule_id, user_id):
|
||||
"""Remove uma regra de notificação."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"DELETE FROM notification_rules WHERE id = ? AND user_id = ?", (rule_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def toggle_notification_rule(rule_id, user_id):
|
||||
|
|
@ -464,7 +635,7 @@ def get_upcoming_notifications(user_id):
|
|||
que ainda não foram notificados para essa regra específica.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Buscar regras ativas do usuário
|
||||
rules = conn.execute(
|
||||
|
|
@ -481,8 +652,8 @@ def get_upcoming_notifications(user_id):
|
|||
"""SELECT e.*, ? as rule_id, ? as rule_label, ? as rule_minutes
|
||||
FROM events e
|
||||
WHERE e.user_id = ?
|
||||
AND e.start_datetime >= ?
|
||||
AND e.start_datetime <= ?
|
||||
AND julianday(e.start_datetime) >= julianday(?)
|
||||
AND julianday(e.start_datetime) <= julianday(?)
|
||||
AND e.completed = 0
|
||||
AND e.id NOT IN (
|
||||
SELECT event_id FROM notification_log WHERE rule_id = ?
|
||||
|
|
@ -509,15 +680,23 @@ def get_upcoming_notifications(user_id):
|
|||
return unique
|
||||
|
||||
|
||||
def mark_event_notified(event_id, rule_id):
|
||||
def mark_event_notified(event_id, rule_id, user_id):
|
||||
"""Registra que uma notificação foi enviada para evento+regra."""
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO notification_log (event_id, rule_id) VALUES (?, ?)",
|
||||
(event_id, rule_id)
|
||||
cur = conn.execute(
|
||||
"""INSERT OR IGNORE INTO notification_log (event_id, rule_id)
|
||||
SELECT e.id, r.id FROM events e JOIN notification_rules r
|
||||
ON r.id = ? AND r.user_id = ? WHERE e.id = ? AND e.user_id = ?""",
|
||||
(rule_id, user_id, event_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0 or conn.execute(
|
||||
"""SELECT 1 FROM notification_log l JOIN events e ON e.id=l.event_id
|
||||
JOIN notification_rules r ON r.id=l.rule_id
|
||||
WHERE l.event_id=? AND l.rule_id=? AND e.user_id=? AND r.user_id=?""",
|
||||
(event_id, rule_id, user_id, user_id)
|
||||
).fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -527,18 +706,18 @@ def mark_event_notified(event_id, rule_id):
|
|||
def get_stats(user_id):
|
||||
"""Retorna estatísticas dos eventos do usuário."""
|
||||
conn = get_db()
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
week_end = now + timedelta(days=7)
|
||||
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ?", (user_id,)
|
||||
).fetchone()[0]
|
||||
upcoming_week = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND start_datetime >= ? AND start_datetime <= ? AND completed = 0",
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND julianday(start_datetime) >= julianday(?) AND julianday(start_datetime) <= julianday(?) AND completed = 0",
|
||||
(user_id, now.isoformat(), week_end.isoformat())
|
||||
).fetchone()[0]
|
||||
overdue = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND start_datetime < ? AND completed = 0",
|
||||
"SELECT COUNT(*) FROM events WHERE user_id = ? AND julianday(start_datetime) < julianday(?) AND completed = 0",
|
||||
(user_id, now.isoformat())
|
||||
).fetchone()[0]
|
||||
completed = conn.execute(
|
||||
|
|
|
|||
275
ics_sync.py
275
ics_sync.py
|
|
@ -1,111 +1,212 @@
|
|||
"""
|
||||
ics_sync.py - Sincronização de feeds ICS para o StudyCalendar.
|
||||
"""
|
||||
"""Sincronização segura de feeds iCalendar."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ipaddress
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import icalendar
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
import database
|
||||
import logging
|
||||
import urllib3
|
||||
from security import ValidationError, validate_public_url
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sp_tz = pytz.timezone('America/Sao_Paulo')
|
||||
|
||||
MAX_REDIRECTS = int(os.environ.get('ICS_MAX_REDIRECTS', '3'))
|
||||
MAX_BYTES = int(os.environ.get('ICS_MAX_BYTES', str(5 * 1024 * 1024)))
|
||||
MAX_EVENTS = int(os.environ.get('ICS_MAX_EVENTS', '5000'))
|
||||
CONNECT_TIMEOUT = float(os.environ.get('ICS_CONNECT_TIMEOUT', '5'))
|
||||
READ_TIMEOUT = float(os.environ.get('ICS_READ_TIMEOUT', '15'))
|
||||
TOTAL_TIMEOUT = float(os.environ.get('ICS_TOTAL_TIMEOUT', '30'))
|
||||
HTTPS_ONLY = os.environ.get('ICS_HTTPS_ONLY', 'true').lower() == 'true'
|
||||
_DNS_LOCK = threading.Lock()
|
||||
|
||||
if not 0 <= MAX_REDIRECTS <= 5:
|
||||
raise RuntimeError('ICS_MAX_REDIRECTS deve estar entre 0 e 5.')
|
||||
if not 1024 <= MAX_BYTES <= 10 * 1024 * 1024:
|
||||
raise RuntimeError('ICS_MAX_BYTES deve estar entre 1 KiB e 10 MiB.')
|
||||
if not 1 <= MAX_EVENTS <= 10000:
|
||||
raise RuntimeError('ICS_MAX_EVENTS deve estar entre 1 e 10000.')
|
||||
if not 0 < CONNECT_TIMEOUT <= 30 or not 0 < READ_TIMEOUT <= 30 or not 1 <= TOTAL_TIMEOUT <= 120:
|
||||
raise RuntimeError('Timeouts ICS fora dos limites permitidos.')
|
||||
|
||||
|
||||
def parse_ics_datetime(dt_prop):
|
||||
"""Converte valor de data/hora iCalendar para ISO string no fuso de São Paulo."""
|
||||
"""Converte data iCalendar para ISO no fuso de São Paulo."""
|
||||
if not dt_prop:
|
||||
return None, False
|
||||
dt = dt_prop.dt
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.utc.localize(dt)
|
||||
dt = dt.astimezone(sp_tz)
|
||||
return dt.isoformat(), False
|
||||
else:
|
||||
# Date only = dia inteiro
|
||||
dt_start = datetime(dt.year, dt.month, dt.day)
|
||||
return sp_tz.localize(dt_start).isoformat(), True
|
||||
value = dt_prop.dt
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = sp_tz.localize(value)
|
||||
return value.astimezone(sp_tz).isoformat(), False
|
||||
value = datetime(value.year, value.month, value.day)
|
||||
return sp_tz.localize(value).isoformat(), True
|
||||
|
||||
|
||||
def _peer_address(response):
|
||||
"""Obtém o peer conectado antes de consumir o corpo, quando exposto pelo urllib3."""
|
||||
raw = response.raw
|
||||
candidates = [
|
||||
getattr(getattr(raw, '_connection', None), 'sock', None),
|
||||
getattr(getattr(raw, 'connection', None), 'sock', None),
|
||||
getattr(getattr(getattr(getattr(raw, '_fp', None), 'fp', None), 'raw', None), '_sock', None),
|
||||
]
|
||||
for sock in candidates:
|
||||
if sock:
|
||||
return str(ipaddress.ip_address(sock.getpeername()[0].split('%', 1)[0]))
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _pinned_dns(hostname, address):
|
||||
"""Fixa a resolução durante a conexão para eliminar a janela de DNS rebinding."""
|
||||
original = socket.getaddrinfo
|
||||
|
||||
def canonical_host(value):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('ascii')
|
||||
return str(value).rstrip('.').encode('idna').decode('ascii').lower()
|
||||
|
||||
normalized_host = canonical_host(hostname)
|
||||
ip = ipaddress.ip_address(address)
|
||||
|
||||
def resolve(host, port, family=0, type=0, proto=0, flags=0):
|
||||
candidate = canonical_host(host)
|
||||
if candidate != normalized_host:
|
||||
return original(host, port, family, type, proto, flags)
|
||||
selected_family = socket.AF_INET6 if ip.version == 6 else socket.AF_INET
|
||||
sockaddr = (address, port, 0, 0) if ip.version == 6 else (address, port)
|
||||
return [(selected_family, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', sockaddr)]
|
||||
|
||||
with _DNS_LOCK:
|
||||
socket.getaddrinfo = resolve
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
socket.getaddrinfo = original
|
||||
|
||||
|
||||
def fetch_ics(url, session_factory=requests.Session):
|
||||
"""Baixa um ICS validando DNS, redirects, peer, TLS e tamanho."""
|
||||
client = session_factory()
|
||||
client.trust_env = False
|
||||
current = url
|
||||
deadline = time.monotonic() + TOTAL_TIMEOUT
|
||||
try:
|
||||
for redirect_count in range(MAX_REDIRECTS + 1):
|
||||
parsed, approved_addresses = validate_public_url(current, HTTPS_ONLY)
|
||||
approved_address = sorted(approved_addresses)[0]
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ValidationError('Tempo total de download excedido.')
|
||||
with _pinned_dns(parsed.hostname, approved_address):
|
||||
response = client.get(
|
||||
current,
|
||||
headers={'User-Agent': 'StudyCalendar/2.0', 'Accept': 'text/calendar,*/*;q=0.1'},
|
||||
timeout=(min(CONNECT_TIMEOUT, remaining), min(READ_TIMEOUT, remaining)),
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
verify=True,
|
||||
)
|
||||
try:
|
||||
peer = _peer_address(response)
|
||||
if not peer:
|
||||
raise ValidationError('Não foi possível validar o endereço conectado.')
|
||||
if peer not in approved_addresses:
|
||||
raise ValidationError('Endereço conectado diverge do DNS validado.')
|
||||
if response.is_redirect or response.is_permanent_redirect:
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
raise ValidationError('Limite de redirecionamentos excedido.')
|
||||
location = response.headers.get('Location')
|
||||
if not location:
|
||||
raise ValidationError('Redirecionamento sem destino.')
|
||||
current = urljoin(current, location)
|
||||
validate_public_url(current, HTTPS_ONLY)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
declared = response.headers.get('Content-Length')
|
||||
if declared and int(declared) > MAX_BYTES:
|
||||
raise ValidationError('Feed excede o tamanho permitido.')
|
||||
body = bytearray()
|
||||
for chunk in response.iter_content(64 * 1024):
|
||||
if time.monotonic() > deadline:
|
||||
raise ValidationError('Tempo total de download excedido.')
|
||||
body.extend(chunk)
|
||||
if len(body) > MAX_BYTES:
|
||||
raise ValidationError('Feed excede o tamanho permitido.')
|
||||
return bytes(body)
|
||||
finally:
|
||||
response.close()
|
||||
finally:
|
||||
client.close()
|
||||
raise ValidationError('Não foi possível obter o feed.')
|
||||
|
||||
|
||||
def _field(component, name, maximum, default=''):
|
||||
value = component.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).replace('\\n', '\n').replace('\\,', ',')[:maximum]
|
||||
|
||||
|
||||
def parse_ics(content):
|
||||
calendar = icalendar.Calendar.from_ical(content)
|
||||
events = []
|
||||
seen_uids = set()
|
||||
for component in calendar.walk('VEVENT'):
|
||||
if len(events) >= MAX_EVENTS:
|
||||
raise ValidationError('Feed excede o número de eventos permitido.')
|
||||
uid = _field(component, 'uid', 512).strip()
|
||||
if not uid or uid in seen_uids:
|
||||
continue
|
||||
start_iso, all_day = parse_ics_datetime(component.get('dtstart'))
|
||||
if not start_iso:
|
||||
continue
|
||||
end_iso, _ = parse_ics_datetime(component.get('dtend'))
|
||||
if end_iso and datetime.fromisoformat(end_iso) <= datetime.fromisoformat(start_iso):
|
||||
end_iso = None
|
||||
seen_uids.add(uid)
|
||||
events.append({
|
||||
'uid': uid,
|
||||
'title': _field(component, 'summary', 200, 'Sem Título') or 'Sem Título',
|
||||
'description': _field(component, 'description', 4000),
|
||||
'location': _field(component, 'location', 300),
|
||||
'start_datetime': start_iso,
|
||||
'end_datetime': end_iso,
|
||||
'all_day': all_day,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def sync_feed(feed_id):
|
||||
"""Sincroniza um feed ICS específico."""
|
||||
"""Sincroniza um feed sem registrar sua URL ou tokens em logs."""
|
||||
feed = database.get_feed(feed_id)
|
||||
if not feed:
|
||||
logger.error(f"Feed {feed_id} não encontrado.")
|
||||
if not feed or not feed.get('user_id'):
|
||||
logger.warning('Feed inexistente ou sem proprietário (id=%s).', feed_id)
|
||||
return False
|
||||
|
||||
user_id = feed.get('user_id')
|
||||
if not user_id:
|
||||
logger.error(f"Feed {feed_id} sem user_id.")
|
||||
return False
|
||||
|
||||
logger.info(f"Sincronizando feed: {feed['name']} ({feed['url']})")
|
||||
try:
|
||||
headers = {'User-Agent': 'StudyCalendar/1.0'}
|
||||
response = requests.get(feed['url'], headers=headers, timeout=30, verify=False)
|
||||
response.raise_for_status()
|
||||
|
||||
cal = icalendar.Calendar.from_ical(response.content)
|
||||
count = 0
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
uid = str(component.get('uid', ''))
|
||||
if not uid:
|
||||
continue
|
||||
|
||||
title = str(component.get('summary', 'Sem Título'))
|
||||
|
||||
description = component.get('description')
|
||||
if description:
|
||||
description = str(description).replace('\\n', '\n').replace('\\,', ',')
|
||||
if len(description) > 2000:
|
||||
description = description[:2000] + '...'
|
||||
else:
|
||||
description = ""
|
||||
|
||||
location = component.get('location')
|
||||
location = str(location) if location else ""
|
||||
|
||||
dtstart = component.get('dtstart')
|
||||
dtend = component.get('dtend')
|
||||
start_iso, all_day = parse_ics_datetime(dtstart)
|
||||
end_iso, _ = parse_ics_datetime(dtend)
|
||||
|
||||
if start_iso:
|
||||
database.upsert_ics_event(
|
||||
feed_id=feed_id,
|
||||
user_id=user_id,
|
||||
uid=uid,
|
||||
title=title,
|
||||
start_datetime=start_iso,
|
||||
end_datetime=end_iso,
|
||||
description=description,
|
||||
location=location,
|
||||
all_day=all_day
|
||||
)
|
||||
count += 1
|
||||
|
||||
database.update_feed_sync_time(feed_id)
|
||||
logger.info(f"Feed {feed['name']}: {count} eventos sincronizados.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao sincronizar feed {feed['name']}: {str(e)}")
|
||||
events = parse_ics(fetch_ics(feed['url']))
|
||||
success = database.replace_ics_events(feed_id, feed['user_id'], events)
|
||||
if success:
|
||||
logger.info('Feed sincronizado (id=%s, eventos=%s).', feed_id, len(events))
|
||||
return success
|
||||
except Exception as exc:
|
||||
database.mark_feed_sync_attempt(feed_id)
|
||||
logger.warning('Falha ao sincronizar feed (id=%s, tipo=%s).', feed_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
|
||||
def sync_all_feeds():
|
||||
"""Sincroniza todos os feeds de todos os usuários."""
|
||||
logger.info("Iniciando sincronização de todos os feeds...")
|
||||
feeds = database.get_all_feeds()
|
||||
for feed in feeds:
|
||||
"""Sincroniza todos os feeds; deve ser chamado apenas pelo processo scheduler."""
|
||||
for feed in database.get_all_feeds():
|
||||
sync_feed(feed['id'])
|
||||
logger.info("Sincronização concluída.")
|
||||
|
|
|
|||
352
requirements.txt
352
requirements.txt
|
|
@ -1,6 +1,346 @@
|
|||
Flask==3.1.1
|
||||
Flask-Cors==5.0.1
|
||||
icalendar==6.1.3
|
||||
requests==2.32.3
|
||||
APScheduler==3.11.0
|
||||
pytz==2025.2
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.14
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --no-index --output-file=requirements.txt requirements.in
|
||||
#
|
||||
apscheduler==3.11.3 \
|
||||
--hash=sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30 \
|
||||
--hash=sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a
|
||||
# via -r requirements.in
|
||||
blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
# via flask
|
||||
certifi==2026.7.22 \
|
||||
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
|
||||
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
|
||||
# via requests
|
||||
charset-normalizer==3.5.1 \
|
||||
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
|
||||
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
|
||||
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
|
||||
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
|
||||
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
|
||||
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
|
||||
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
|
||||
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
|
||||
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
|
||||
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
|
||||
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
|
||||
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
|
||||
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
|
||||
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
|
||||
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
|
||||
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
|
||||
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
|
||||
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
|
||||
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
|
||||
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
|
||||
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
|
||||
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
|
||||
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
|
||||
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
|
||||
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
|
||||
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
|
||||
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
|
||||
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
|
||||
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
|
||||
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
|
||||
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
|
||||
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
|
||||
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
|
||||
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
|
||||
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
|
||||
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
|
||||
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
|
||||
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
|
||||
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
|
||||
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
|
||||
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
|
||||
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
|
||||
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
|
||||
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
|
||||
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
|
||||
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
|
||||
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
|
||||
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
|
||||
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
|
||||
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
|
||||
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
|
||||
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
|
||||
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
|
||||
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
|
||||
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
|
||||
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
|
||||
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
|
||||
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
|
||||
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
|
||||
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
|
||||
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
|
||||
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
|
||||
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
|
||||
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
|
||||
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
|
||||
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
|
||||
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
|
||||
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
|
||||
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
|
||||
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
|
||||
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
|
||||
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
|
||||
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
|
||||
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
|
||||
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
|
||||
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
|
||||
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
|
||||
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
|
||||
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
|
||||
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
|
||||
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
|
||||
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
|
||||
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
|
||||
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
|
||||
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
|
||||
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
|
||||
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
|
||||
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
|
||||
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
|
||||
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
|
||||
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
|
||||
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
|
||||
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
|
||||
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
|
||||
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
|
||||
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
|
||||
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
|
||||
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
|
||||
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
|
||||
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
|
||||
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
|
||||
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
|
||||
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
|
||||
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
|
||||
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
|
||||
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
|
||||
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
|
||||
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
|
||||
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
|
||||
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
|
||||
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
|
||||
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
|
||||
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
|
||||
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
|
||||
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
|
||||
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
|
||||
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
|
||||
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
|
||||
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
|
||||
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
|
||||
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
|
||||
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
|
||||
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
|
||||
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
|
||||
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
|
||||
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
|
||||
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
|
||||
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
|
||||
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
|
||||
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
|
||||
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
|
||||
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
|
||||
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
|
||||
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
|
||||
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
|
||||
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
|
||||
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
|
||||
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
|
||||
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
|
||||
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
|
||||
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
|
||||
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
|
||||
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
|
||||
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
|
||||
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
|
||||
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
|
||||
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
|
||||
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
|
||||
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
|
||||
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
|
||||
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
|
||||
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
|
||||
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
|
||||
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
|
||||
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
|
||||
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
|
||||
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
|
||||
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
|
||||
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
|
||||
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
|
||||
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
|
||||
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
|
||||
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
|
||||
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
|
||||
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
|
||||
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
|
||||
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
|
||||
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
|
||||
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
|
||||
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
|
||||
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
|
||||
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
|
||||
# via requests
|
||||
click==8.5.0 \
|
||||
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
|
||||
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
|
||||
# via flask
|
||||
flask==3.1.3 \
|
||||
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
||||
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
||||
# via -r requirements.in
|
||||
gunicorn==26.2.0 \
|
||||
--hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \
|
||||
--hash=sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3
|
||||
# via -r requirements.in
|
||||
icalendar==7.3.0 \
|
||||
--hash=sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132 \
|
||||
--hash=sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b
|
||||
# via -r requirements.in
|
||||
idna==3.19 \
|
||||
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
|
||||
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||
# via requests
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via flask
|
||||
jinja2==3.1.6 \
|
||||
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
|
||||
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||
# via flask
|
||||
markupsafe==3.0.3 \
|
||||
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
|
||||
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
|
||||
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
|
||||
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
|
||||
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
||||
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
|
||||
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
||||
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
|
||||
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
|
||||
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
|
||||
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
||||
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
|
||||
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
|
||||
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
|
||||
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
|
||||
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
||||
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
|
||||
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
|
||||
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
|
||||
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
|
||||
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
||||
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
|
||||
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
|
||||
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
||||
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
|
||||
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
|
||||
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
|
||||
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
|
||||
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
|
||||
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
|
||||
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
||||
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
|
||||
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
|
||||
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
||||
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
|
||||
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
||||
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
|
||||
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
||||
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
|
||||
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
|
||||
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
||||
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
||||
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
|
||||
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
|
||||
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
|
||||
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
|
||||
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
|
||||
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
|
||||
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
|
||||
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
|
||||
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
|
||||
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
||||
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
|
||||
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
|
||||
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
|
||||
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
|
||||
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
|
||||
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
|
||||
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
|
||||
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
|
||||
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
|
||||
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
||||
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
|
||||
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
|
||||
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
|
||||
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
||||
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
|
||||
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
|
||||
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
|
||||
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
|
||||
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
|
||||
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
|
||||
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
|
||||
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
|
||||
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
|
||||
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
|
||||
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
|
||||
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
||||
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
||||
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
|
||||
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
||||
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
||||
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
||||
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
||||
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
||||
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
|
||||
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
|
||||
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
|
||||
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
||||
# via
|
||||
# flask
|
||||
# jinja2
|
||||
# werkzeug
|
||||
python-dateutil==2.9.0.post0 \
|
||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
# via icalendar
|
||||
pytz==2025.2 \
|
||||
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
|
||||
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
|
||||
# via -r requirements.in
|
||||
requests==2.34.2 \
|
||||
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
|
||||
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
|
||||
# via -r requirements.in
|
||||
six==1.17.0 \
|
||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
||||
# via python-dateutil
|
||||
tzdata==2026.3 \
|
||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
||||
# via icalendar
|
||||
tzlocal==5.4.4 \
|
||||
--hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \
|
||||
--hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15
|
||||
# via apscheduler
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via requests
|
||||
werkzeug==3.1.8 \
|
||||
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
|
||||
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
|
||||
# via flask
|
||||
|
|
|
|||
|
|
@ -1,490 +1,437 @@
|
|||
/* ===== StudyCalendar - CSS ===== */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-tertiary: #252540;
|
||||
--bg-card: #16213e;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0b0;
|
||||
--text-muted: #6a6a7a;
|
||||
--accent: #4A90D9;
|
||||
--accent-hover: #5BA0E9;
|
||||
--accent-light: rgba(74, 144, 217, 0.15);
|
||||
--success: #4CAF50;
|
||||
--warning: #FF9800;
|
||||
--danger: #f44336;
|
||||
--urgent: #e91e63;
|
||||
--border: #2a2a4a;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--sidebar-width: 300px;
|
||||
--header-height: 60px;
|
||||
--cat-estudos: #4A90D9;
|
||||
--cat-trabalho: #FF9800;
|
||||
--cat-pessoal: #4CAF50;
|
||||
--cat-provas: #e91e63;
|
||||
--cat-entregas: #9C27B0;
|
||||
--cat-geral: #607D8B;
|
||||
color-scheme: dark;
|
||||
--bg: #0d0e19;
|
||||
--surface: #17182b;
|
||||
--surface-raised: #20223a;
|
||||
--surface-hover: #292c49;
|
||||
--text: #f4f5fb;
|
||||
--text-soft: #c3c7d9;
|
||||
--text-muted: #9ba2ba;
|
||||
--accent: #6caaf0;
|
||||
--accent-strong: #286fae;
|
||||
--accent-soft: rgb(108 170 240 / 16%);
|
||||
--success: #55c875;
|
||||
--warning: #f5b942;
|
||||
--danger: #ff6b6b;
|
||||
--border: #353852;
|
||||
--overlay: rgb(2 3 10 / 78%);
|
||||
--shadow: 0 20px 60px rgb(0 0 0 / 45%);
|
||||
--radius: 14px;
|
||||
--radius-small: 9px;
|
||||
--header-height: 64px;
|
||||
--sidebar-width: 304px;
|
||||
--cat-estudos: #4a90d9;
|
||||
--cat-trabalho: #d97706;
|
||||
--cat-pessoal: #2f9e44;
|
||||
--cat-provas: #db2777;
|
||||
--cat-entregas: #9333c7;
|
||||
--cat-geral: #64748b;
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { min-height: 100%; background: var(--bg); }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:disabled { cursor: wait; opacity: .58; }
|
||||
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
::selection { background: var(--accent); color: #08101d; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: #41455f; border: 2px solid var(--bg); border-radius: 99px; }
|
||||
|
||||
/* ===== Header ===== */
|
||||
.app-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: var(--header-height); padding: 0 20px;
|
||||
background: var(--bg-secondary); border-bottom: 1px solid var(--border);
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2000;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
.header-left, .header-right { display: flex; align-items: center; gap: 12px; }
|
||||
.app-title { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; }
|
||||
|
||||
/* ===== Botões ===== */
|
||||
.btn-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.btn-icon.btn-sm { width: 32px; height: 32px; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border: none; border-radius: var(--radius-sm);
|
||||
background: var(--accent); color: white; font-family: inherit;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||||
.btn-primary .material-icons-round { font-size: 18px; }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary); color: var(--text-primary); font-family: inherit;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-card); border-color: var(--accent); }
|
||||
.btn-secondary .material-icons-round { font-size: 18px; }
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border: none; border-radius: var(--radius-sm);
|
||||
background: var(--danger); color: white; font-family: inherit;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
.btn-danger .material-icons-round { font-size: 18px; }
|
||||
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
||||
|
||||
.btn-install {
|
||||
background: linear-gradient(135deg, var(--accent), #7c4dff);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(74,144,217,0.3); }
|
||||
50% { box-shadow: 0 0 15px rgba(74,144,217,0.6); }
|
||||
}
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.app-container {
|
||||
display: flex; margin-top: var(--header-height);
|
||||
height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
/* ===== Sidebar ===== */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width); min-width: var(--sidebar-width);
|
||||
background: var(--bg-secondary); border-right: 1px solid var(--border);
|
||||
overflow-y: auto; height: 100%; transition: transform 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay { display: none; }
|
||||
.sidebar-content { padding: 16px; }
|
||||
.sidebar-section { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-section:last-child { border-bottom: none; }
|
||||
.sidebar-title {
|
||||
font-size: 0.8rem; font-weight: 600; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Mini Calendário */
|
||||
.mini-calendar { user-select: none; }
|
||||
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.mini-cal-title { font-size: 0.9rem; font-weight: 600; text-transform: capitalize; }
|
||||
.mini-cal-weekdays {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); text-align: center;
|
||||
font-size: 0.7rem; font-weight: 500; color: var(--text-muted); margin-bottom: 4px;
|
||||
}
|
||||
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.mini-cal-day {
|
||||
display: flex; align-items: center; justify-content: center; position: relative;
|
||||
width: 32px; height: 32px; margin: 0 auto; border-radius: 50%;
|
||||
font-size: 0.8rem; cursor: pointer; transition: all 0.15s; color: var(--text-secondary);
|
||||
}
|
||||
.mini-cal-day:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.mini-cal-day.today { background: var(--accent); color: white; font-weight: 600; }
|
||||
.mini-cal-day.other-month { opacity: 0.3; }
|
||||
|
||||
/* Busca */
|
||||
.search-box {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: var(--bg-tertiary); border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); transition: border-color 0.2s;
|
||||
}
|
||||
.search-box:focus-within { border-color: var(--accent); }
|
||||
.search-icon { color: var(--text-muted); font-size: 20px; }
|
||||
.search-box input {
|
||||
flex: 1; border: none; background: transparent; color: var(--text-primary);
|
||||
font-family: inherit; font-size: 0.875rem; outline: none;
|
||||
}
|
||||
.search-box input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Categorias */
|
||||
.category-filters { display: flex; flex-direction: column; gap: 4px; }
|
||||
.category-btn {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px;
|
||||
border: none; border-radius: var(--radius-sm); background: transparent;
|
||||
color: var(--text-secondary); font-family: inherit; font-size: 0.85rem;
|
||||
cursor: pointer; transition: all 0.2s; text-align: left;
|
||||
}
|
||||
.category-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.category-btn.active { background: var(--accent-light); color: var(--accent); font-weight: 500; }
|
||||
.cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* Lista Próximos */
|
||||
.upcoming-list { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
|
||||
.upcoming-item {
|
||||
display: flex; align-items: flex-start; gap: 8px; padding: 8px;
|
||||
border-radius: var(--radius-sm); background: var(--bg-tertiary);
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.upcoming-item:hover { background: var(--bg-card); }
|
||||
.upcoming-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
|
||||
.upcoming-info { flex: 1; min-width: 0; }
|
||||
.upcoming-title {
|
||||
font-size: 0.8rem; font-weight: 500; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.upcoming-date { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
/* Estatísticas */
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat-card { padding: 12px; background: var(--bg-tertiary); border-radius: var(--radius-sm); text-align: center; }
|
||||
.stat-value { display: block; font-size: 1.5rem; font-weight: 700; color: var(--accent); }
|
||||
.stat-danger .stat-value { color: var(--danger); }
|
||||
.stat-success .stat-value { color: var(--success); }
|
||||
.stat-label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
|
||||
/* ===== Main Content ===== */
|
||||
.main-content { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; }
|
||||
|
||||
.calendar-controls {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; flex-shrink: 0;
|
||||
}
|
||||
.view-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.current-period { font-size: 1.2rem; font-weight: 600; min-width: 200px; text-align: center; text-transform: capitalize; }
|
||||
.btn-today { margin-left: 8px; }
|
||||
.view-tabs {
|
||||
display: flex; background: var(--bg-secondary); border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); overflow: hidden;
|
||||
}
|
||||
.view-tab {
|
||||
padding: 8px 20px; border: none; background: transparent;
|
||||
color: var(--text-secondary); font-family: inherit; font-size: 0.85rem;
|
||||
font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.view-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.view-tab.active { background: var(--accent); color: white; }
|
||||
|
||||
/* ===== Visão Mensal ===== */
|
||||
.calendar-weekdays {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); text-align: center;
|
||||
font-size: 0.75rem; font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.calendar-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); flex: 1;
|
||||
gap: 1px; background: var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.calendar-day {
|
||||
background: var(--bg-primary); padding: 6px; min-height: 100px;
|
||||
cursor: pointer; transition: background 0.15s; display: flex; flex-direction: column;
|
||||
}
|
||||
.calendar-day:hover { background: var(--bg-secondary); }
|
||||
.calendar-day.today { background: var(--accent-light); }
|
||||
.calendar-day.other-month { opacity: 0.4; }
|
||||
.day-number { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.calendar-day.today .day-number {
|
||||
background: var(--accent); color: white; width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; font-weight: 600;
|
||||
}
|
||||
.day-events { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||
.day-event {
|
||||
padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
cursor: pointer; transition: all 0.15s; border-left: 3px solid;
|
||||
background: var(--bg-tertiary); color: var(--text-primary);
|
||||
}
|
||||
.day-event:hover { filter: brightness(1.2); transform: translateX(2px); }
|
||||
.day-event.completed { text-decoration: line-through; opacity: 0.5; }
|
||||
.day-event.cat-estudos { border-color: var(--cat-estudos); }
|
||||
.day-event.cat-trabalho { border-color: var(--cat-trabalho); }
|
||||
.day-event.cat-pessoal { border-color: var(--cat-pessoal); }
|
||||
.day-event.cat-provas { border-color: var(--cat-provas); }
|
||||
.day-event.cat-entregas { border-color: var(--cat-entregas); }
|
||||
.day-event.cat-geral { border-color: var(--cat-geral); }
|
||||
.priority-badge {
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 3px;
|
||||
}
|
||||
.priority-baixa { background: var(--success); }
|
||||
.priority-normal { background: var(--accent); }
|
||||
.priority-alta { background: var(--warning); }
|
||||
.priority-urgente { background: var(--urgent); animation: blink 1s infinite; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.day-more { font-size: 0.65rem; color: var(--accent); text-align: center; padding: 2px; cursor: pointer; font-weight: 500; }
|
||||
|
||||
/* ===== Visão Semanal ===== */
|
||||
.week-grid { display: flex; flex-direction: column; flex: 1; overflow-y: auto; }
|
||||
.week-header {
|
||||
display: grid; grid-template-columns: 60px repeat(7, 1fr);
|
||||
border-bottom: 1px solid var(--border); position: sticky; top: 0;
|
||||
background: var(--bg-primary); z-index: 5;
|
||||
}
|
||||
.week-header-cell { padding: 8px; text-align: center; font-size: 0.75rem; color: var(--text-secondary); }
|
||||
.week-header-cell.today { color: var(--accent); font-weight: 600; }
|
||||
.week-header-cell .day-num { display: block; font-size: 1.1rem; font-weight: 600; }
|
||||
.week-body { display: grid; grid-template-columns: 60px repeat(7, 1fr); flex: 1; }
|
||||
.week-time-label {
|
||||
padding: 4px 8px; font-size: 0.7rem; color: var(--text-muted);
|
||||
text-align: right; height: 48px; border-right: 1px solid var(--border);
|
||||
}
|
||||
.week-day-column { border-right: 1px solid var(--border); position: relative; }
|
||||
.week-hour-slot { height: 48px; border-bottom: 1px solid var(--border); position: relative; }
|
||||
.week-hour-slot:hover { background: var(--accent-light); }
|
||||
.week-event {
|
||||
position: absolute; left: 2px; right: 2px; padding: 2px 4px;
|
||||
border-radius: 4px; font-size: 0.7rem; overflow: hidden;
|
||||
cursor: pointer; z-index: 2; border-left: 3px solid; min-height: 20px;
|
||||
}
|
||||
|
||||
/* ===== Visão Lista ===== */
|
||||
.list-container { flex: 1; overflow-y: auto; }
|
||||
.list-day-group { margin-bottom: 20px; }
|
||||
.list-day-header {
|
||||
font-size: 0.85rem; font-weight: 600; color: var(--text-secondary);
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 8px;
|
||||
text-transform: capitalize; position: sticky; top: 0; background: var(--bg-primary); z-index: 3;
|
||||
}
|
||||
.list-event {
|
||||
display: flex; align-items: flex-start; gap: 12px; padding: 12px;
|
||||
margin-bottom: 6px; border-radius: var(--radius-sm); background: var(--bg-secondary);
|
||||
border-left: 4px solid; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.list-event:hover { background: var(--bg-tertiary); transform: translateX(4px); }
|
||||
.list-event.completed { opacity: 0.5; }
|
||||
.list-event.completed .list-event-title { text-decoration: line-through; }
|
||||
.list-event.cat-estudos { border-color: var(--cat-estudos); }
|
||||
.list-event.cat-trabalho { border-color: var(--cat-trabalho); }
|
||||
.list-event.cat-pessoal { border-color: var(--cat-pessoal); }
|
||||
.list-event.cat-provas { border-color: var(--cat-provas); }
|
||||
.list-event.cat-entregas { border-color: var(--cat-entregas); }
|
||||
.list-event.cat-geral { border-color: var(--cat-geral); }
|
||||
.list-event-time { font-size: 0.8rem; font-weight: 600; color: var(--accent); min-width: 50px; flex-shrink: 0; }
|
||||
.list-event-content { flex: 1; min-width: 0; }
|
||||
.list-event-title { font-size: 0.9rem; font-weight: 500; margin-bottom: 2px; }
|
||||
.list-event-desc { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-event-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 0.7rem; color: var(--text-muted); }
|
||||
.list-event-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.list-check-btn {
|
||||
width: 28px; height: 28px; border: 2px solid var(--border); border-radius: 50%;
|
||||
background: transparent; color: var(--text-muted); display: flex; align-items: center;
|
||||
justify-content: center; cursor: pointer; transition: all 0.2s; flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.list-check-btn:hover { border-color: var(--success); color: var(--success); }
|
||||
.list-check-btn.checked { background: var(--success); border-color: var(--success); color: white; }
|
||||
.list-check-btn .material-icons-round { font-size: 16px; }
|
||||
|
||||
/* ===== FAB ===== */
|
||||
.fab {
|
||||
position: fixed; bottom: 24px; right: 24px; width: 56px; height: 56px;
|
||||
border-radius: 50%; border: none; background: var(--accent); color: white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; box-shadow: 0 4px 12px rgba(74,144,217,0.4);
|
||||
transition: all 0.3s; z-index: 50;
|
||||
}
|
||||
.fab:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(74,144,217,0.6); }
|
||||
.fab .material-icons-round { font-size: 28px; }
|
||||
|
||||
/* ===== Modais ===== */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px); z-index: 200; align-items: center;
|
||||
justify-content: center; padding: 20px;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: var(--bg-secondary); border-radius: var(--radius);
|
||||
border: 1px solid var(--border); width: 100%; max-width: 520px;
|
||||
max-height: 85vh; display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5); animation: modal-in 0.2s ease-out;
|
||||
}
|
||||
.modal-wide { max-width: 640px; }
|
||||
@keyframes modal-in {
|
||||
from { transform: scale(0.95) translateY(10px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 1.1rem; font-weight: 600; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.modal-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.modal-footer-right { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
/* Formulários */
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; font-size: 0.8rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.form-group input[type="text"], .form-group input[type="url"],
|
||||
.form-group input[type="date"], .form-group input[type="time"],
|
||||
.form-group input[type="number"], .form-group textarea, .form-group select {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--bg-tertiary);
|
||||
color: var(--text-primary); font-family: inherit; font-size: 0.9rem;
|
||||
outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--accent); }
|
||||
.form-group input[type="color"] {
|
||||
width: 100%; height: 40px; padding: 4px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--bg-tertiary); cursor: pointer;
|
||||
}
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.checkbox-label { display: flex !important; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.checkbox-label input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); }
|
||||
|
||||
/* Configurações */
|
||||
.settings-section { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.settings-section:last-child { border-bottom: none; }
|
||||
.settings-section h4 { font-size: 1rem; font-weight: 600; margin-bottom: 12px; }
|
||||
.add-feed-form { margin-top: 16px; padding: 16px; background: var(--bg-tertiary); border-radius: var(--radius); }
|
||||
.add-feed-form h5 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; }
|
||||
.feeds-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.feed-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg-tertiary); border-radius: var(--radius-sm); }
|
||||
.feed-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.feed-info { flex: 1; min-width: 0; }
|
||||
.feed-name { font-size: 0.85rem; font-weight: 500; }
|
||||
.feed-meta { font-size: 0.7rem; color: var(--text-muted); }
|
||||
.feed-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* Detalhe de evento */
|
||||
.detail-field { margin-bottom: 12px; }
|
||||
.detail-label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
|
||||
.detail-value { font-size: 0.9rem; line-height: 1.5; }
|
||||
.detail-value.description {
|
||||
white-space: pre-wrap; word-break: break-word; max-height: 200px;
|
||||
overflow-y: auto; padding: 8px; background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm); font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast-container { position: fixed; top: 72px; right: 16px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
||||
border-radius: var(--radius-sm); background: var(--bg-secondary);
|
||||
border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
font-size: 0.85rem; min-width: 280px; max-width: 400px; animation: toast-in 0.3s ease-out;
|
||||
}
|
||||
.toast.toast-out { animation: toast-out 0.3s ease-in forwards; }
|
||||
.toast.success { border-left: 4px solid var(--success); }
|
||||
.toast.error { border-left: 4px solid var(--danger); }
|
||||
.toast.warning { border-left: 4px solid var(--warning); }
|
||||
.toast.info { border-left: 4px solid var(--accent); }
|
||||
.toast .material-icons-round { font-size: 20px; }
|
||||
.toast.success .material-icons-round { color: var(--success); }
|
||||
.toast.error .material-icons-round { color: var(--danger); }
|
||||
.toast.warning .material-icons-round { color: var(--warning); }
|
||||
.toast.info .material-icons-round { color: var(--accent); }
|
||||
@keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||||
|
||||
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.skip-link:focus { transform: none; }
|
||||
.hidden { display: none !important; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spinning { animation: spin 1s linear infinite; }
|
||||
.no-events { text-align: center; padding: 40px; color: var(--text-muted); }
|
||||
.no-events .material-icons-round { font-size: 48px; margin-bottom: 12px; display: block; }
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== Responsivo ===== */
|
||||
@media (max-width: 768px) {
|
||||
.app-title { font-size: 1rem; }
|
||||
.app-shell { min-height: 100dvh; }
|
||||
.app-header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: calc(var(--header-height) + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgb(23 24 43 / 96%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.header-left, .header-right { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.app-title { margin: 0; font-size: 1.15rem; letter-spacing: -.025em; white-space: nowrap; }
|
||||
.user-display { max-width: 220px; overflow: hidden; color: var(--text-soft); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.connection-status { padding: 3px 7px; border: 1px solid #347e4d; border-radius: 99px; color: #83e59e; font-size: .7rem; }
|
||||
.connection-status.offline { border-color: #936d1e; color: var(--warning); }
|
||||
|
||||
.icon-button, .button, .view-tab, .category-button, .text-button, .fab, .mini-cal-day, .upcoming-item, .day-event, .week-event, .week-all-day-event, .week-hour-slot, .list-check-button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.icon-button:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.icon-button.small { width: 36px; height: 36px; min-width: 36px; font-size: 1.15rem; }
|
||||
.danger-text { color: var(--danger); }
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-small);
|
||||
font-weight: 650;
|
||||
}
|
||||
.button.primary { background: var(--accent-strong); color: #fff; }
|
||||
.button.primary:hover { background: #3177b6; }
|
||||
.button.secondary { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); }
|
||||
.button.secondary:hover { border-color: var(--accent); background: var(--surface-hover); }
|
||||
.button.danger { background: #b8343b; color: #fff; }
|
||||
.button.compact { min-height: 38px; padding: 6px 12px; font-size: .82rem; }
|
||||
.button.full { width: 100%; }
|
||||
.text-button { padding: 3px; background: transparent; color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-top: calc(var(--header-height) + env(safe-area-inset-top));
|
||||
}
|
||||
.sidebar {
|
||||
z-index: 90;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.sidebar-content { padding: 18px 16px max(24px, env(safe-area-inset-bottom)); }
|
||||
.sidebar-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-section:last-child { border: 0; }
|
||||
.sidebar-title { margin: 0 0 10px; color: var(--text-soft); font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
|
||||
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.mini-cal-title { margin: 0; font-size: .9rem; text-transform: capitalize; }
|
||||
.mini-cal-weekdays, .mini-cal-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.mini-cal-weekdays { color: var(--text-muted); font-size: .7rem; text-align: center; }
|
||||
.mini-cal-day {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 1px auto;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: .78rem;
|
||||
}
|
||||
.mini-cal-day:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.mini-cal-day.today { background: var(--accent-strong); color: #fff; font-weight: 700; }
|
||||
.mini-cal-day.other-month { color: #70768f; }
|
||||
|
||||
.search-box { display: flex; min-height: 44px; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface-raised); }
|
||||
.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.search-box input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); }
|
||||
.search-box input::placeholder { color: var(--text-muted); }
|
||||
.category-filters { display: grid; gap: 3px; }
|
||||
.category-button { display: flex; min-height: 40px; align-items: center; gap: 9px; padding: 7px 10px; border-radius: var(--radius-small); background: transparent; color: var(--text-soft); text-align: left; }
|
||||
.category-button:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.category-button.active { background: var(--accent-soft); color: #9dcbff; font-weight: 700; }
|
||||
.category-dot, .upcoming-dot { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%; background: var(--cat-geral); }
|
||||
.cat-todas { background: linear-gradient(135deg, var(--cat-estudos) 50%, var(--cat-entregas) 50%); }
|
||||
.cat-geral { background: var(--cat-geral); }
|
||||
.cat-estudos { background: var(--cat-estudos); }
|
||||
.cat-trabalho { background: var(--cat-trabalho); }
|
||||
.cat-pessoal { background: var(--cat-pessoal); }
|
||||
.cat-provas { background: var(--cat-provas); }
|
||||
.cat-entregas { background: var(--cat-entregas); }
|
||||
|
||||
.upcoming-list { display: grid; max-height: 230px; gap: 6px; overflow-y: auto; }
|
||||
.upcoming-item { display: flex; width: 100%; min-height: 48px; align-items: flex-start; gap: 9px; padding: 9px; border-radius: var(--radius-small); background: var(--surface-raised); color: var(--text); text-align: left; }
|
||||
.upcoming-item:hover { background: var(--surface-hover); }
|
||||
.upcoming-dot { margin-top: 6px; }
|
||||
.upcoming-info { display: grid; min-width: 0; }
|
||||
.upcoming-title { overflow: hidden; font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upcoming-date { color: var(--text-muted); font-size: .7rem; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat-card { display: grid; padding: 11px; border-radius: var(--radius-small); background: var(--surface-raised); text-align: center; }
|
||||
.stat-card strong { color: var(--accent); font-size: 1.35rem; }
|
||||
.stat-card span { color: var(--text-muted); font-size: .68rem; text-transform: uppercase; }
|
||||
.stat-card.danger strong { color: var(--danger); }
|
||||
.stat-card.success strong { color: var(--success); }
|
||||
|
||||
.main-content { display: flex; min-width: 0; flex: 1; flex-direction: column; overflow: auto; padding: 18px 20px max(20px, env(safe-area-inset-bottom)); }
|
||||
.calendar-controls { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 12px; }
|
||||
.view-nav { display: flex; align-items: center; gap: 6px; }
|
||||
.current-period { min-width: 230px; margin: 0; font-size: 1.12rem; text-align: center; text-transform: capitalize; }
|
||||
.view-tabs { display: flex; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface); }
|
||||
.view-tab { min-height: 42px; padding: 8px 18px; background: transparent; color: var(--text-soft); font-weight: 650; }
|
||||
.view-tab:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.view-tab.active { background: var(--accent-strong); color: #fff; }
|
||||
.content-status { display: none; margin: 0 0 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-small); background: var(--surface); color: var(--text-soft); }
|
||||
.content-status.visible { display: block; }
|
||||
.content-status.error { border-color: #8c3e45; color: #ffb4b4; }
|
||||
.calendar-view { min-height: 0; flex: 1; }
|
||||
|
||||
.calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); padding: 7px 0; color: var(--text-muted); font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-align: center; text-transform: uppercase; }
|
||||
.calendar-grid { display: grid; min-height: 620px; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: minmax(100px, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
|
||||
.calendar-day { min-width: 0; padding: 6px; background: var(--bg); }
|
||||
.calendar-day:hover { background: #121424; }
|
||||
.calendar-day.today { background: var(--accent-soft); }
|
||||
.calendar-day.other-month { background: #10111d; color: #777d94; }
|
||||
.day-number { display: grid; width: 27px; height: 27px; margin-bottom: 3px; place-items: center; color: var(--text-soft); font-size: .8rem; }
|
||||
.calendar-day.today .day-number { border-radius: 50%; background: var(--accent-strong); color: #fff; font-weight: 700; }
|
||||
.day-events { display: grid; gap: 3px; }
|
||||
.day-event {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 4px 6px;
|
||||
border-left: 4px solid var(--event-color, var(--cat-geral));
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--event-color, var(--cat-geral)) 22%, var(--surface-raised));
|
||||
color: var(--text);
|
||||
font-size: .7rem;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.day-event:hover { filter: brightness(1.18); }
|
||||
.day-event.completed { opacity: .64; text-decoration: line-through; }
|
||||
.day-more { color: #9dcbff; font-size: .68rem; text-align: center; }
|
||||
|
||||
.week-scroll { height: 100%; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.week-grid { min-width: 920px; background: var(--bg); }
|
||||
.week-header, .all-day-row, .week-body { display: grid; grid-template-columns: 64px repeat(7, minmax(120px, 1fr)); }
|
||||
.week-header { position: sticky; top: 0; z-index: 8; min-height: 58px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.week-corner, .week-header-cell, .all-day-label { display: grid; place-content: center; border-right: 1px solid var(--border); color: var(--text-muted); font-size: .7rem; text-align: center; }
|
||||
.week-header-cell { gap: 2px; padding: 7px; text-transform: capitalize; }
|
||||
.week-header-cell.today { color: #9dcbff; }
|
||||
.day-num { font-size: 1rem; }
|
||||
.all-day-row { position: sticky; top: 58px; z-index: 7; min-height: 48px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.all-day-cell { display: grid; align-content: start; gap: 2px; min-width: 0; padding: 4px; border-right: 1px solid var(--border); }
|
||||
.week-all-day-event { overflow: hidden; padding: 3px 5px; border-left: 3px solid var(--event-color); border-radius: 4px; background: color-mix(in srgb, var(--event-color) 25%, var(--surface-raised)); color: var(--text); font-size: .67rem; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.week-all-day-event.completed { opacity: .65; text-decoration: line-through; }
|
||||
.week-time-column { border-right: 1px solid var(--border); }
|
||||
.week-time-label { height: 48px; padding: 3px 7px; color: var(--text-muted); font-size: .68rem; text-align: right; }
|
||||
.week-day-column { position: relative; height: 1152px; border-right: 1px solid var(--border); }
|
||||
.week-hour-slot { display: block; width: 100%; height: 48px; border-bottom: 1px solid var(--border); background: transparent; }
|
||||
.week-hour-slot:hover, .week-hour-slot:focus-visible { background: var(--accent-soft); }
|
||||
.week-event {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: var(--event-top);
|
||||
left: calc((100% / var(--event-columns)) * var(--event-column) + 2px);
|
||||
width: calc(100% / var(--event-columns) - 4px);
|
||||
height: var(--event-height);
|
||||
min-height: 24px;
|
||||
overflow: hidden;
|
||||
padding: 3px 5px;
|
||||
border-left: 4px solid var(--event-color);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--event-color) 35%, #20223a);
|
||||
color: #fff;
|
||||
font-size: .68rem;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
}
|
||||
.week-event:hover { z-index: 4; filter: brightness(1.2); }
|
||||
.week-event.completed { opacity: .6; text-decoration: line-through; }
|
||||
|
||||
.list-container { height: 100%; overflow-y: auto; }
|
||||
.list-day-group { margin-bottom: 20px; }
|
||||
.list-day-header { position: sticky; top: 0; z-index: 3; margin: 0 0 7px; padding: 8px 2px; border-bottom: 1px solid var(--border); background: var(--bg); color: var(--text-soft); font-size: .85rem; text-transform: capitalize; }
|
||||
.list-event { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 7px; padding: 12px; border-left: 4px solid var(--event-color, var(--cat-geral)); border-radius: var(--radius-small); background: var(--surface); }
|
||||
.list-event:hover { background: var(--surface-raised); }
|
||||
.list-event.completed { opacity: .65; }
|
||||
.list-event.completed .list-event-title { text-decoration: line-through; }
|
||||
.list-event-open { align-self: flex-start; margin-top: 5px; border: 0; background: transparent; color: var(--accent); font: inherit; font-size: .72rem; text-decoration: underline; cursor: pointer; }
|
||||
.list-check-button { display: grid; flex: 0 0 auto; width: 36px; height: 36px; margin-top: 1px; place-items: center; border: 2px solid #555b74; border-radius: 50%; background: transparent; color: #fff; }
|
||||
.list-check-button:hover { border-color: var(--success); }
|
||||
.list-check-button.checked { border-color: var(--success); background: #287b40; }
|
||||
.list-event-time { min-width: 66px; padding-top: 8px; color: #9dcbff; font-size: .78rem; font-weight: 700; }
|
||||
.list-event-content { min-width: 0; flex: 1; }
|
||||
.list-event-title { margin: 0 0 2px; font-size: .92rem; }
|
||||
.list-event-description { overflow: hidden; margin: 0; color: var(--text-soft); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-event-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; margin: 5px 0 0; color: var(--text-muted); font-size: .72rem; }
|
||||
.priority::before { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: var(--accent); content: ""; }
|
||||
.priority-baixa::before { background: var(--success); }
|
||||
.priority-alta::before { background: var(--warning); }
|
||||
.priority-urgente::before { background: var(--danger); }
|
||||
|
||||
.fab { position: fixed; right: max(24px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); z-index: 50; display: grid; width: 58px; height: 58px; place-items: center; border-radius: 50%; background: var(--accent-strong); box-shadow: 0 8px 28px rgb(67 142 220 / 42%); color: #fff; font-size: 1.8rem; }
|
||||
.fab:hover { background: #3177b6; transform: translateY(-2px); }
|
||||
|
||||
.auth-overlay, .modal-overlay { position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); background: var(--overlay); }
|
||||
.auth-overlay.active, .modal-overlay.active { display: flex; }
|
||||
.auth-overlay { background: radial-gradient(circle at 50% 15%, #20284b 0, var(--bg) 52%); }
|
||||
.auth-container { width: min(100%, 410px); padding: 34px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.auth-title { margin: 0 0 5px; color: #8fc4ff; font-size: 1.65rem; text-align: center; }
|
||||
.auth-subtitle { margin: 0 0 24px; color: var(--text-muted); font-size: .88rem; text-align: center; }
|
||||
.auth-form h2 { margin: 0 0 17px; font-size: 1.15rem; }
|
||||
.auth-switch { margin: 20px 0 0; color: var(--text-muted); font-size: .84rem; text-align: center; }
|
||||
.modal-overlay { z-index: 500; backdrop-filter: blur(5px); }
|
||||
.modal { display: flex; width: min(100%, 540px); max-height: min(88vh, 88dvh); flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.modal.wide { width: min(100%, 680px); }
|
||||
.modal-header { display: flex; min-height: 62px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 9px 12px 9px 20px; border-bottom: 1px solid var(--border); }
|
||||
.modal-header h2 { margin: 0; font-size: 1.08rem; }
|
||||
.modal-body { min-height: 0; flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.modal-footer { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 20px; border-top: 1px solid var(--border); }
|
||||
.modal-footer.centered { justify-content: center; }
|
||||
.modal-footer-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; margin-bottom: 5px; color: var(--text-soft); font-size: .8rem; font-weight: 650; }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #444862;
|
||||
border-radius: var(--radius-small);
|
||||
outline: 0;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
.form-group textarea { resize: vertical; }
|
||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.form-group input:disabled { color: var(--text-muted); opacity: .7; }
|
||||
.form-group input[type="color"] { padding: 4px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.checkbox-label { display: flex; min-height: 44px; align-items: center; gap: 9px; color: var(--text-soft); cursor: pointer; }
|
||||
.checkbox-label input { width: 20px; height: 20px; accent-color: var(--accent-strong); }
|
||||
.form-error { display: none; margin: 0 0 12px; padding: 9px 11px; border-left: 3px solid var(--danger); border-radius: 4px; background: rgb(255 107 107 / 10%); color: #ffb4b4; font-size: .82rem; }
|
||||
.form-error.visible { display: block; }
|
||||
.settings-section { padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||
.settings-section:last-child { padding-bottom: 0; margin-bottom: 0; border: 0; }
|
||||
.settings-section h3, .add-feed-form h4 { margin: 0 0 10px; font-size: 1rem; }
|
||||
.help-text, .state-message { margin: 6px 0 12px; color: var(--text-muted); font-size: .82rem; }
|
||||
.state-message.error { color: #ffb4b4; }
|
||||
.settings-list { display: grid; gap: 7px; margin: 14px 0; }
|
||||
.settings-item { display: flex; min-height: 54px; align-items: center; gap: 10px; padding: 9px 11px; border-radius: var(--radius-small); background: var(--surface-raised); }
|
||||
.feed-color-dot { flex: 0 0 auto; width: 13px; height: 13px; border-radius: 50%; }
|
||||
.feed-info { display: grid; min-width: 0; flex: 1; }
|
||||
.feed-name { overflow: hidden; font-size: .84rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-meta { color: var(--text-muted); font-size: .7rem; }
|
||||
.feed-actions { display: flex; gap: 2px; }
|
||||
.rule-label { flex: 1; }
|
||||
.inline-form { display: flex; align-items: end; gap: 10px; }
|
||||
.inline-form .form-group { flex: 1; margin: 0; }
|
||||
.add-feed-form { margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); }
|
||||
.add-feed-form .form-group input, .add-feed-form .form-group select { background: var(--surface); }
|
||||
.detail-field { margin-bottom: 16px; }
|
||||
.detail-label { margin: 0 0 3px; color: var(--text-muted); font-size: .7rem; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.detail-value { margin: 0; overflow-wrap: anywhere; font-size: .9rem; }
|
||||
.detail-value.description { max-height: 220px; overflow-y: auto; padding: 10px; border-radius: var(--radius-small); background: var(--surface-raised); white-space: pre-wrap; }
|
||||
|
||||
.toast-container { position: fixed; top: calc(var(--header-height) + env(safe-area-inset-top) + 10px); right: max(14px, env(safe-area-inset-right)); z-index: 1500; display: grid; gap: 8px; width: min(380px, calc(100vw - 28px)); pointer-events: none; }
|
||||
.toast { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius-small); background: var(--surface); box-shadow: var(--shadow); font-size: .84rem; animation: toast-in .2s ease-out; }
|
||||
.toast.success { border-left-color: var(--success); }
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
.toast.warning { border-left-color: var(--warning); }
|
||||
.toast-icon { display: grid; flex: 0 0 auto; width: 22px; height: 22px; place-items: center; border-radius: 50%; background: var(--surface-hover); font-weight: 800; }
|
||||
.toast.toast-out { opacity: 0; transform: translateX(20px); transition: .2s; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinning { display: inline-block; animation: spin .8s linear infinite; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root { --header-height: 60px; }
|
||||
.app-header { padding-right: max(8px, env(safe-area-inset-right)); padding-left: max(8px, env(safe-area-inset-left)); }
|
||||
.header-left, .header-right { gap: 2px; }
|
||||
.app-title { font-size: .98rem; }
|
||||
.user-display, .connection-status, .install-label { display: none; }
|
||||
.sidebar {
|
||||
position: fixed; top: var(--header-height); left: 0; bottom: 0;
|
||||
z-index: 90; transform: translateX(-100%); width: 85vw; max-width: 320px;
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + env(safe-area-inset-top));
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 110;
|
||||
width: min(88vw, 340px);
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
transform: translateX(-105%);
|
||||
transition: transform .22s ease;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: -1; }
|
||||
.sidebar.open .sidebar-overlay { display: block; }
|
||||
.main-content { padding: 12px; }
|
||||
.calendar-controls { flex-direction: column; gap: 8px; align-items: stretch; }
|
||||
.sidebar.open { transform: none; }
|
||||
.sidebar-backdrop { position: fixed; inset: calc(var(--header-height) + env(safe-area-inset-top)) 0 0; z-index: 105; background: var(--overlay); }
|
||||
.sidebar-backdrop.active { display: block; }
|
||||
.main-content { padding: 12px 10px max(78px, env(safe-area-inset-bottom)); }
|
||||
.calendar-controls { align-items: stretch; flex-direction: column; gap: 8px; }
|
||||
.view-nav { justify-content: center; }
|
||||
.view-tabs { justify-content: center; }
|
||||
.current-period { font-size: 1rem; min-width: auto; }
|
||||
.calendar-weekdays span { font-size: 0.6rem; }
|
||||
.calendar-day { min-height: 70px; padding: 3px; }
|
||||
.day-number { font-size: 0.75rem; }
|
||||
.day-event { font-size: 0.6rem; padding: 1px 3px; }
|
||||
.calendar-day.today .day-number { width: 22px; height: 22px; font-size: 0.7rem; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.modal { max-height: 90vh; margin: 10px; }
|
||||
.fab { bottom: 16px; right: 16px; width: 48px; height: 48px; }
|
||||
.fab .material-icons-round { font-size: 24px; }
|
||||
.btn-install span:not(.material-icons-round) { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.calendar-weekdays span { font-size: 0.55rem; }
|
||||
.calendar-day { min-height: 55px; }
|
||||
.current-period { font-size: 0.9rem; }
|
||||
.current-period { min-width: 0; flex: 1; font-size: .95rem; }
|
||||
.view-tabs { width: 100%; }
|
||||
.view-tab { flex: 1; padding-inline: 8px; }
|
||||
.calendar-grid { min-height: 500px; grid-auto-rows: minmax(72px, 1fr); }
|
||||
.calendar-day { padding: 3px; }
|
||||
.day-number { width: 23px; height: 23px; font-size: .72rem; }
|
||||
.day-event { padding: 3px; border-left-width: 3px; font-size: .6rem; }
|
||||
.day-more { font-size: .58rem; }
|
||||
.week-scroll { min-height: 580px; }
|
||||
.fab { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); width: 54px; height: 54px; }
|
||||
.modal-overlay { align-items: flex-end; padding: max(8px, env(safe-area-inset-top)) 0 0; }
|
||||
.modal { max-height: calc(100dvh - max(8px, env(safe-area-inset-top))); border-radius: var(--radius) var(--radius) 0 0; }
|
||||
.modal-footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
|
||||
.form-row { grid-template-columns: 1fr; gap: 0; }
|
||||
.auth-container { padding: 26px 22px; }
|
||||
}
|
||||
|
||||
/* ===== Auth Overlay ===== */
|
||||
.auth-overlay {
|
||||
display: none; position: fixed; inset: 0; background: var(--bg-primary);
|
||||
z-index: 1000; align-items: center; justify-content: center; padding: 20px;
|
||||
@media (max-width: 430px) {
|
||||
.app-title { max-width: 145px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.header-right .icon-button { width: 40px; }
|
||||
.btn-today { margin: 0; }
|
||||
.calendar-weekdays { font-size: .62rem; }
|
||||
.calendar-grid { min-height: 430px; grid-auto-rows: minmax(62px, 1fr); }
|
||||
.inline-form { align-items: stretch; flex-direction: column; }
|
||||
.modal-footer { align-items: stretch; flex-direction: column-reverse; }
|
||||
.modal-footer-actions { width: 100%; margin: 0; }
|
||||
.modal-footer-actions .button { flex: 1; }
|
||||
.modal-footer > .button { width: 100%; }
|
||||
.list-event { gap: 7px; padding: 10px 8px; }
|
||||
.list-event-time { min-width: 53px; font-size: .7rem; }
|
||||
}
|
||||
.auth-overlay.active { display: flex; }
|
||||
.auth-container {
|
||||
background: var(--bg-secondary); padding: 40px; border-radius: var(--radius);
|
||||
border: 1px solid var(--border); box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
width: 100%; max-width: 400px; text-align: center;
|
||||
}
|
||||
.auth-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 8px; color: var(--accent); }
|
||||
.auth-subtitle { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 30px; }
|
||||
.auth-form-container h2 { font-size: 1.3rem; margin-bottom: 20px; text-align: left; }
|
||||
.auth-form-container .form-group { text-align: left; margin-bottom: 16px; }
|
||||
.w-100 { width: 100%; justify-content: center; }
|
||||
.mt-2 { margin-top: 16px; }
|
||||
.auth-switch { margin-top: 24px; font-size: 0.85rem; color: var(--text-muted); }
|
||||
.auth-switch a { color: var(--accent); text-decoration: none; font-weight: 500; }
|
||||
.auth-switch a:hover { text-decoration: underline; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
|
|
|||
2023
static/js/app.js
2023
static/js/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,17 @@
|
|||
"name": "StudyCalendar - Agenda de Estudos",
|
||||
"short_name": "StudyCalendar",
|
||||
"description": "Calendário inteligente para organizar estudos e entregas",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f1a",
|
||||
"theme_color": "#1a1a2e",
|
||||
"lang": "pt-BR",
|
||||
"dir": "ltr",
|
||||
"background_color": "#0d0e19",
|
||||
"theme_color": "#17182b",
|
||||
"orientation": "any",
|
||||
"categories": ["education", "productivity"],
|
||||
"icons": [
|
||||
{"src": "/static/icons/icon.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any maskable"}
|
||||
{"src": "/static/icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
89
static/sw.js
89
static/sw.js
|
|
@ -1,51 +1,58 @@
|
|||
const CACHE_NAME = 'studycalendar-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/app.js',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon.svg',
|
||||
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
|
||||
'https://fonts.googleapis.com/icon?family=Material+Icons'
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'studycalendar-shell-v4';
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/app.js',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon.svg'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => Promise.allSettled(APP_SHELL.map(url => cache.add(new Request(url, { cache: 'reload' })))))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(names => Promise.all(names.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
// Para API, Network First
|
||||
if (event.request.url.includes('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
} else {
|
||||
// Para estáticos, Cache First, Network Fallback
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) return response;
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
}
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
if (response.ok) caches.open(CACHE_NAME).then(cache => cache.put('/', response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('/'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/static/')) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(cached => {
|
||||
const update = fetch(request)
|
||||
.then(response => {
|
||||
if (response.ok) caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || update;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,437 +1,219 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="description" content="Calendário inteligente para organizar estudos e entregas">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#17182b">
|
||||
<meta name="description" content="Calendário para organizar estudos, compromissos e entregas">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="StudyCalendar">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<title>StudyCalendar - Agenda de Estudos</title>
|
||||
<title>StudyCalendar - Agenda de estudos</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Overlay de Autenticação -->
|
||||
<div id="auth-overlay" class="auth-overlay active">
|
||||
<div class="auth-container">
|
||||
<h1 class="auth-title">📚 StudyCalendar</h1>
|
||||
<p class="auth-subtitle">Organize seus estudos e não perca prazos</p>
|
||||
|
||||
<div id="login-form-container" class="auth-form-container">
|
||||
<h2>Entrar</h2>
|
||||
<div class="form-group">
|
||||
<label>Usuário</label>
|
||||
<input type="text" id="login-username" placeholder="Seu nome de usuário">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Senha</label>
|
||||
<input type="password" id="login-password" placeholder="Sua senha">
|
||||
</div>
|
||||
<button class="btn-primary w-100 mt-2" onclick="login()">Entrar</button>
|
||||
<p class="auth-switch">Não tem uma conta? <a href="#" onclick="toggleAuth('register')">Cadastre-se</a></p>
|
||||
<a class="skip-link" href="#main-content">Ir para o calendário</a>
|
||||
|
||||
<div id="app-shell" class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="icon-button sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Abrir menu" aria-controls="sidebar" aria-expanded="false"><span aria-hidden="true">☰</span></button>
|
||||
<h1 class="app-title"><span aria-hidden="true">▦</span> StudyCalendar</h1>
|
||||
<span id="user-display" class="user-display"></span>
|
||||
</div>
|
||||
|
||||
<div id="register-form-container" class="auth-form-container hidden">
|
||||
<h2>Cadastrar</h2>
|
||||
<div class="form-group">
|
||||
<label>Nome Completo</label>
|
||||
<input type="text" id="register-displayname" placeholder="Como quer ser chamado?">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nome de Usuário</label>
|
||||
<input type="text" id="register-username" placeholder="Escolha um usuário">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Senha</label>
|
||||
<input type="password" id="register-password" placeholder="Crie uma senha">
|
||||
</div>
|
||||
<button class="btn-primary w-100 mt-2" onclick="register()">Criar Conta</button>
|
||||
<p class="auth-switch">Já tem uma conta? <a href="#" onclick="toggleAuth('login')">Faça login</a></p>
|
||||
<div class="header-right">
|
||||
<span id="connection-status" class="connection-status" role="status">Online</span>
|
||||
<button class="icon-button" id="sync-all-btn" type="button" aria-label="Sincronizar todos os feeds" title="Sincronizar feeds"><span id="sync-icon" aria-hidden="true">↻</span></button>
|
||||
<button class="icon-button" id="settings-btn" type="button" aria-label="Abrir configurações" title="Configurações"><span aria-hidden="true">⚙</span></button>
|
||||
<button class="icon-button" id="logout-btn" type="button" aria-label="Sair da conta" title="Sair"><span aria-hidden="true">⇥</span></button>
|
||||
<button class="button primary install-button hidden" id="install-btn" type="button"><span aria-hidden="true">⇩</span><span class="install-label">Instalar</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="btn-icon sidebar-toggle" onclick="toggleSidebar()" title="Menu">
|
||||
<span class="material-icons-round">menu</span>
|
||||
</button>
|
||||
<h1 class="app-title">📚 StudyCalendar <span id="user-display" style="font-size: 0.8rem; font-weight: normal; margin-left: 10px; color: var(--text-muted);"></span></h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn-icon" onclick="syncAllFeeds()" title="Sincronizar feeds">
|
||||
<span class="material-icons-round" id="sync-icon">sync</span>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="openSettings()" title="Configurações">
|
||||
<span class="material-icons-round">settings</span>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="logout()" title="Sair">
|
||||
<span class="material-icons-round">logout</span>
|
||||
</button>
|
||||
<button class="btn-primary btn-install" id="install-btn" onclick="installPWA()" style="display:none">
|
||||
<span class="material-icons-round">install_mobile</span>
|
||||
Instalar
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||
<div class="sidebar-content">
|
||||
<!-- Mini Calendário -->
|
||||
<div class="sidebar-section">
|
||||
<div class="mini-calendar">
|
||||
<div class="app-container">
|
||||
<aside class="sidebar" id="sidebar" aria-label="Navegação e filtros">
|
||||
<div class="sidebar-content">
|
||||
<section class="sidebar-section" aria-labelledby="mini-calendar-title">
|
||||
<div class="mini-cal-header">
|
||||
<button class="btn-icon btn-sm" onclick="miniCalPrev()">
|
||||
<span class="material-icons-round">chevron_left</span>
|
||||
</button>
|
||||
<span class="mini-cal-title" id="mini-cal-title"></span>
|
||||
<button class="btn-icon btn-sm" onclick="miniCalNext()">
|
||||
<span class="material-icons-round">chevron_right</span>
|
||||
</button>
|
||||
<button class="icon-button small" id="mini-prev-btn" type="button" aria-label="Mês anterior no minicalendário"><span aria-hidden="true">‹</span></button>
|
||||
<h2 class="mini-cal-title" id="mini-calendar-title"></h2>
|
||||
<button class="icon-button small" id="mini-next-btn" type="button" aria-label="Próximo mês no minicalendário"><span aria-hidden="true">›</span></button>
|
||||
</div>
|
||||
<div class="mini-cal-weekdays">
|
||||
<span>D</span><span>S</span><span>T</span><span>Q</span><span>Q</span><span>S</span><span>S</span>
|
||||
<div class="mini-cal-weekdays" aria-hidden="true"><span>D</span><span>S</span><span>T</span><span>Q</span><span>Q</span><span>S</span><span>S</span></div>
|
||||
<div class="mini-cal-days" id="mini-cal-days" role="grid" aria-label="Minicalendário"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section">
|
||||
<label class="visually-hidden" for="search-input">Buscar em todos os eventos</label>
|
||||
<div class="search-box"><span aria-hidden="true">⌕</span><input type="search" id="search-input" placeholder="Buscar em todos os eventos" autocomplete="off"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section" aria-labelledby="category-title">
|
||||
<h2 class="sidebar-title" id="category-title">Categorias</h2>
|
||||
<div class="category-filters" id="category-filters" role="radiogroup" aria-label="Filtrar por categoria">
|
||||
<button class="category-button active" type="button" data-category="todas" role="radio" aria-checked="true"><span class="category-dot cat-todas" aria-hidden="true"></span>Todas</button>
|
||||
<button class="category-button" type="button" data-category="geral" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-geral" aria-hidden="true"></span>Geral</button>
|
||||
<button class="category-button" type="button" data-category="estudos" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-estudos" aria-hidden="true"></span>Estudos</button>
|
||||
<button class="category-button" type="button" data-category="trabalho" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-trabalho" aria-hidden="true"></span>Trabalho</button>
|
||||
<button class="category-button" type="button" data-category="pessoal" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-pessoal" aria-hidden="true"></span>Pessoal</button>
|
||||
<button class="category-button" type="button" data-category="provas" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-provas" aria-hidden="true"></span>Provas</button>
|
||||
<button class="category-button" type="button" data-category="entregas" role="radio" aria-checked="false" tabindex="-1"><span class="category-dot cat-entregas" aria-hidden="true"></span>Entregas</button>
|
||||
</div>
|
||||
<div class="mini-cal-days" id="mini-cal-days"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section" aria-labelledby="upcoming-title">
|
||||
<h2 class="sidebar-title" id="upcoming-title">Próximos 7 dias</h2>
|
||||
<div class="upcoming-list" id="upcoming-list" aria-busy="true"><p class="state-message">Carregando próximos eventos...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section" aria-labelledby="stats-title">
|
||||
<h2 class="sidebar-title" id="stats-title">Resumo</h2>
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card"><strong id="stat-total">0</strong><span>Total</span></div>
|
||||
<div class="stat-card"><strong id="stat-week">0</strong><span>Esta semana</span></div>
|
||||
<div class="stat-card danger"><strong id="stat-overdue">0</strong><span>Atrasados</span></div>
|
||||
<div class="stat-card success"><strong id="stat-completed">0</strong><span>Concluídos</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
<button class="sidebar-backdrop" id="sidebar-backdrop" type="button" aria-label="Fechar menu" tabindex="-1"></button>
|
||||
|
||||
<main class="main-content" id="main-content" tabindex="-1">
|
||||
<div class="calendar-controls">
|
||||
<div class="view-nav">
|
||||
<button class="icon-button" id="prev-btn" type="button" aria-label="Período anterior"><span aria-hidden="true">‹</span></button>
|
||||
<h2 class="current-period" id="current-period"></h2>
|
||||
<button class="icon-button" id="next-btn" type="button" aria-label="Próximo período"><span aria-hidden="true">›</span></button>
|
||||
<button class="button secondary compact" id="today-btn" type="button">Hoje</button>
|
||||
</div>
|
||||
<div class="view-tabs" role="tablist" aria-label="Visualização do calendário">
|
||||
<button class="view-tab active" id="month-tab" type="button" role="tab" aria-selected="true" aria-controls="month-view" data-view="month">Mês</button>
|
||||
<button class="view-tab" id="week-tab" type="button" role="tab" aria-selected="false" aria-controls="week-view" data-view="week" tabindex="-1">Semana</button>
|
||||
<button class="view-tab" id="list-tab" type="button" role="tab" aria-selected="false" aria-controls="list-view" data-view="list" tabindex="-1">Lista</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="sidebar-section">
|
||||
<div class="search-box">
|
||||
<span class="material-icons-round search-icon">search</span>
|
||||
<input type="text" id="search-input" placeholder="Buscar eventos..." oninput="debounceSearch()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="content-status" class="content-status" role="status" aria-live="polite"></div>
|
||||
<section class="calendar-view" id="month-view" role="tabpanel" aria-labelledby="month-tab">
|
||||
<div class="calendar-weekdays" aria-hidden="true"><span>Dom</span><span>Seg</span><span>Ter</span><span>Qua</span><span>Qui</span><span>Sex</span><span>Sáb</span></div>
|
||||
<div class="calendar-grid" id="calendar-grid" role="grid" aria-label="Calendário mensal"></div>
|
||||
</section>
|
||||
<section class="calendar-view hidden" id="week-view" role="tabpanel" aria-labelledby="week-tab">
|
||||
<div class="week-scroll" tabindex="0" aria-label="Agenda semanal, role horizontalmente para ver todos os dias"><div class="week-grid" id="week-grid"></div></div>
|
||||
</section>
|
||||
<section class="calendar-view hidden" id="list-view" role="tabpanel" aria-labelledby="list-tab">
|
||||
<div class="list-container" id="list-container"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Filtros de Categoria -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">Categorias</h3>
|
||||
<div class="category-filters" id="category-filters">
|
||||
<button class="category-btn active" data-category="todas" onclick="filterCategory('todas')">
|
||||
<span class="cat-dot" style="background:#607D8B"></span> Todas
|
||||
</button>
|
||||
<button class="category-btn" data-category="estudos" onclick="filterCategory('estudos')">
|
||||
<span class="cat-dot" style="background:#4A90D9"></span> Estudos
|
||||
</button>
|
||||
<button class="category-btn" data-category="trabalho" onclick="filterCategory('trabalho')">
|
||||
<span class="cat-dot" style="background:#FF9800"></span> Trabalho
|
||||
</button>
|
||||
<button class="category-btn" data-category="pessoal" onclick="filterCategory('pessoal')">
|
||||
<span class="cat-dot" style="background:#4CAF50"></span> Pessoal
|
||||
</button>
|
||||
<button class="category-btn" data-category="provas" onclick="filterCategory('provas')">
|
||||
<span class="cat-dot" style="background:#e91e63"></span> Provas
|
||||
</button>
|
||||
<button class="category-btn" data-category="entregas" onclick="filterCategory('entregas')">
|
||||
<span class="cat-dot" style="background:#9C27B0"></span> Entregas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Próximas Entregas -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">📅 Próximos 7 dias</h3>
|
||||
<div class="upcoming-list" id="upcoming-list">
|
||||
<p class="text-muted">Carregando...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">📊 Estatísticas</h3>
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="stat-total">0</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="stat-week">0</span>
|
||||
<span class="stat-label">Esta semana</span>
|
||||
</div>
|
||||
<div class="stat-card stat-danger">
|
||||
<span class="stat-value" id="stat-overdue">0</span>
|
||||
<span class="stat-label">Atrasados</span>
|
||||
</div>
|
||||
<div class="stat-card stat-success">
|
||||
<span class="stat-value" id="stat-completed">0</span>
|
||||
<span class="stat-label">Concluídos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Conteúdo Principal -->
|
||||
<main class="main-content">
|
||||
<div class="calendar-controls">
|
||||
<div class="view-nav">
|
||||
<button class="btn-icon" onclick="navigatePrev()">
|
||||
<span class="material-icons-round">chevron_left</span>
|
||||
</button>
|
||||
<h2 class="current-period" id="current-period"></h2>
|
||||
<button class="btn-icon" onclick="navigateNext()">
|
||||
<span class="material-icons-round">chevron_right</span>
|
||||
</button>
|
||||
<button class="btn-secondary btn-sm btn-today" onclick="goToToday()">Hoje</button>
|
||||
</div>
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" data-view="month" onclick="switchView('month')">Mês</button>
|
||||
<button class="view-tab" data-view="week" onclick="switchView('week')">Semana</button>
|
||||
<button class="view-tab" data-view="list" onclick="switchView('list')">Lista</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visão Mensal -->
|
||||
<div class="calendar-view" id="month-view">
|
||||
<div class="calendar-weekdays">
|
||||
<span>Domingo</span><span>Segunda</span><span>Terça</span><span>Quarta</span><span>Quinta</span><span>Sexta</span><span>Sábado</span>
|
||||
</div>
|
||||
<div class="calendar-grid" id="calendar-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Visão Semanal -->
|
||||
<div class="calendar-view hidden" id="week-view">
|
||||
<div class="week-grid" id="week-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Visão Lista -->
|
||||
<div class="calendar-view hidden" id="list-view">
|
||||
<div class="list-container" id="list-container"></div>
|
||||
</div>
|
||||
</main>
|
||||
<button class="fab" id="new-event-btn" type="button" aria-label="Criar novo evento"><span aria-hidden="true">+</span></button>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<button class="fab" onclick="openEventModal()" title="Novo evento">
|
||||
<span class="material-icons-round">add</span>
|
||||
</button>
|
||||
<div id="auth-overlay" class="auth-overlay active" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||
<div class="auth-container">
|
||||
<h1 class="auth-title" id="auth-title"><span aria-hidden="true">▦</span> StudyCalendar</h1>
|
||||
<p class="auth-subtitle">Organize seus estudos e não perca prazos</p>
|
||||
<p class="form-error" id="auth-error" role="alert"></p>
|
||||
|
||||
<!-- Modal de Evento -->
|
||||
<div class="modal-overlay" id="event-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="event-modal-title">Novo Evento</h3>
|
||||
<button class="btn-icon" onclick="closeModal('event-modal')">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="login-form" class="auth-form">
|
||||
<h2>Entrar</h2>
|
||||
<div class="form-group"><label for="login-username">Usuário</label><input type="text" id="login-username" name="username" autocomplete="username" required minlength="2"></div>
|
||||
<div class="form-group"><label for="login-password">Senha</label><input type="password" id="login-password" name="password" autocomplete="current-password" required></div>
|
||||
<button class="button primary full" type="submit">Entrar</button>
|
||||
<p class="auth-switch" id="register-switch">Não tem uma conta? <button class="text-button" type="button" data-auth-view="register">Cadastre-se</button></p>
|
||||
</form>
|
||||
|
||||
<form id="register-form" class="auth-form hidden">
|
||||
<h2>Cadastrar</h2>
|
||||
<div class="form-group"><label for="register-displayname">Nome completo</label><input type="text" id="register-displayname" name="name" autocomplete="name" required minlength="2"></div>
|
||||
<div class="form-group"><label for="register-username">Nome de usuário</label><input type="text" id="register-username" name="username" autocomplete="username" required minlength="3" maxlength="32" pattern="[A-Za-z0-9_.-]+"></div>
|
||||
<div class="form-group"><label for="register-password">Senha</label><input type="password" id="register-password" name="new-password" autocomplete="new-password" required minlength="12" maxlength="128"></div>
|
||||
<button class="button primary full" type="submit">Criar conta</button>
|
||||
<p class="auth-switch">Já tem uma conta? <button class="text-button" type="button" data-auth-view="login">Faça login</button></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="event-modal" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="event-modal-title">
|
||||
<div class="modal-header"><h2 id="event-modal-title">Novo evento</h2><button class="icon-button modal-close" type="button" data-close-modal="event-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
|
||||
<form id="event-form" novalidate>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="event-id">
|
||||
<p class="form-error" id="event-form-error" role="alert"></p>
|
||||
<div class="form-group"><label for="event-title">Título <span aria-hidden="true">*</span></label><input type="text" id="event-title" required maxlength="200" autocomplete="off"></div>
|
||||
<div class="form-group"><label for="event-description">Descrição</label><textarea id="event-description" rows="3" maxlength="4000"></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="event-start-date">Data de início <span aria-hidden="true">*</span></label><input type="date" id="event-start-date" required></div>
|
||||
<div class="form-group"><label for="event-start-time">Hora de início</label><input type="time" id="event-start-time" value="08:00"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="event-end-date">Data de término</label><input type="date" id="event-end-date"></div>
|
||||
<div class="form-group"><label for="event-end-time">Hora de término</label><input type="time" id="event-end-time" value="09:00"></div>
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="event-all-day"><span>Dia inteiro</span></label>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="event-category">Categoria</label><select id="event-category"><option value="geral">Geral</option><option value="estudos">Estudos</option><option value="trabalho">Trabalho</option><option value="pessoal">Pessoal</option><option value="provas">Provas</option><option value="entregas">Entregas</option></select></div>
|
||||
<div class="form-group"><label for="event-priority">Prioridade</label><select id="event-priority"><option value="baixa">Baixa</option><option value="normal" selected>Normal</option><option value="alta">Alta</option><option value="urgente">Urgente</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label for="event-location">Local</label><input type="text" id="event-location" maxlength="300" autocomplete="off"></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="button danger hidden" id="event-delete-btn" type="button">Excluir</button><div class="modal-footer-actions"><button class="button secondary" type="button" data-close-modal="event-modal">Cancelar</button><button class="button primary" type="submit">Salvar</button></div></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="settings-modal" aria-hidden="true">
|
||||
<div class="modal wide" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="modal-header"><h2 id="settings-title">Configurações</h2><button class="icon-button modal-close" type="button" data-close-modal="settings-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="event-id">
|
||||
<div class="form-group">
|
||||
<label for="event-title">Título *</label>
|
||||
<input type="text" id="event-title" placeholder="Nome do evento" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="event-description">Descrição</label>
|
||||
<textarea id="event-description" placeholder="Detalhes do evento" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-start-date">Data Início *</label>
|
||||
<input type="date" id="event-start-date" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="event-start-time">Hora Início</label>
|
||||
<input type="time" id="event-start-time" value="08:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-end-date">Data Fim</label>
|
||||
<input type="date" id="event-end-date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="event-end-time">Hora Fim</label>
|
||||
<input type="time" id="event-end-time" value="09:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="event-all-day" onchange="toggleAllDay()">
|
||||
<span>Dia inteiro</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-category">Categoria</label>
|
||||
<select id="event-category">
|
||||
<option value="geral">Geral</option>
|
||||
<option value="estudos">Estudos</option>
|
||||
<option value="trabalho">Trabalho</option>
|
||||
<option value="pessoal">Pessoal</option>
|
||||
<option value="provas">Provas</option>
|
||||
<option value="entregas">Entregas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="event-priority">Prioridade</label>
|
||||
<select id="event-priority">
|
||||
<option value="baixa">Baixa</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="urgente">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="event-location">Local</label>
|
||||
<input type="text" id="event-location" placeholder="Local do evento">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-danger" id="event-delete-btn" onclick="deleteCurrentEvent()" style="display:none">
|
||||
<span class="material-icons-round">delete</span> Excluir
|
||||
</button>
|
||||
<div class="modal-footer-right">
|
||||
<button class="btn-secondary" onclick="closeModal('event-modal')">Cancelar</button>
|
||||
<button class="btn-primary" onclick="saveEvent()">
|
||||
<span class="material-icons-round">save</span> Salvar
|
||||
</button>
|
||||
</div>
|
||||
<section class="settings-section" aria-labelledby="notifications-title">
|
||||
<h3 id="notifications-title">Notificações</h3>
|
||||
<p class="help-text" id="notification-permission-status">Permissão do navegador ainda não solicitada.</p>
|
||||
<button class="button secondary" id="enable-notifications-btn" type="button">Ativar notificações neste dispositivo</button>
|
||||
<div id="notif-rules-list" class="settings-list"></div>
|
||||
<form id="notification-rule-form" class="inline-form">
|
||||
<div class="form-group"><label for="new-rule-minutes">Notificar antes</label><select id="new-rule-minutes"><option value="5">5 minutos</option><option value="15">15 minutos</option><option value="30">30 minutos</option><option value="60">1 hora</option><option value="120">2 horas</option><option value="1440">1 dia</option><option value="2880">2 dias</option><option value="10080">1 semana</option></select></div>
|
||||
<button class="button primary" type="submit">Adicionar regra</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="settings-section" aria-labelledby="feeds-title">
|
||||
<h3 id="feeds-title">Feeds de calendário (ICS)</h3>
|
||||
<div class="settings-list" id="feeds-list"><p class="state-message">Carregando feeds...</p></div>
|
||||
<form id="feed-form" class="add-feed-form" novalidate>
|
||||
<h4>Adicionar feed</h4><p class="form-error" id="feed-form-error" role="alert"></p>
|
||||
<div class="form-group"><label for="feed-name">Nome</label><input type="text" id="feed-name" required maxlength="100" autocomplete="off"></div>
|
||||
<div class="form-group"><label for="feed-url">URL HTTPS do feed ICS</label><input type="url" id="feed-url" required inputmode="url" placeholder="https://exemplo.com/calendario.ics"></div>
|
||||
<div class="form-row"><div class="form-group"><label for="feed-color">Cor</label><input type="color" id="feed-color" value="#4a90d9"></div><div class="form-group"><label for="feed-interval">Intervalo (minutos)</label><input type="number" id="feed-interval" value="30" min="5" max="1440" required></div></div>
|
||||
<button class="button primary" type="submit">Adicionar feed</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Configurações -->
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h3>⚙️ Configurações</h3>
|
||||
<button class="btn-icon" onclick="closeModal('settings-modal')">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="settings-section">
|
||||
<h4>🔔 Notificações</h4>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="notif-enabled" checked>
|
||||
<span>Ativar notificações</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notif-minutes">Notificar antes (minutos)</label>
|
||||
<select id="notif-minutes">
|
||||
<option value="5">5 minutos</option>
|
||||
<option value="10">10 minutos</option>
|
||||
<option value="15">15 minutos</option>
|
||||
<option value="30" selected>30 minutos</option>
|
||||
<option value="60">1 hora</option>
|
||||
<option value="120">2 horas</option>
|
||||
<option value="1440">1 dia</option>
|
||||
<option value="2880">2 dias</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary btn-sm" onclick="saveNotificationSettings()">
|
||||
<span class="material-icons-round">save</span> Salvar notificações
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>📡 Feeds de Calendário (ICS)</h4>
|
||||
<div class="feeds-list" id="feeds-list">
|
||||
<p class="text-muted">Carregando feeds...</p>
|
||||
</div>
|
||||
<div class="add-feed-form">
|
||||
<h5>Adicionar novo feed</h5>
|
||||
<div class="form-group">
|
||||
<label for="feed-name">Nome</label>
|
||||
<input type="text" id="feed-name" placeholder="Ex: Unicep Virtual">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="feed-url">URL do feed ICS</label>
|
||||
<input type="url" id="feed-url" placeholder="https://exemplo.com/calendar.ics">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="feed-color">Cor</label>
|
||||
<input type="color" id="feed-color" value="#4A90D9">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="feed-interval">Intervalo de sync (min)</label>
|
||||
<input type="number" id="feed-interval" value="30" min="5" max="1440">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="addFeed()">
|
||||
<span class="material-icons-round">add</span> Adicionar Feed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Detalhe de Evento -->
|
||||
<div class="modal-overlay" id="event-detail-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="detail-title">Detalhes do Evento</h3>
|
||||
<button class="btn-icon" onclick="closeModal('event-detail-modal')">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-overlay" id="event-detail-modal" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||
<div class="modal-header"><h2 id="detail-title">Detalhes do evento</h2><button class="icon-button modal-close" type="button" data-close-modal="event-detail-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
|
||||
<div class="modal-body" id="detail-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="detail-toggle-btn" onclick="toggleDetailComplete()">
|
||||
<span class="material-icons-round">check_circle</span> Concluir
|
||||
</button>
|
||||
<div class="modal-footer-right">
|
||||
<button class="btn-secondary" id="detail-edit-btn" onclick="editDetailEvent()">
|
||||
<span class="material-icons-round">edit</span> Editar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="button secondary" id="detail-toggle-btn" type="button">Concluir</button><div class="modal-footer-actions"><button class="button secondary" id="detail-edit-btn" type="button">Editar</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Eventos do Dia -->
|
||||
<div class="modal-overlay" id="day-events-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="day-events-title">Eventos do Dia</h3>
|
||||
<button class="btn-icon" onclick="closeModal('day-events-modal')">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="day-events-list" class="list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<!-- Eventos serão injetados aqui -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer-right" style="width: 100%; justify-content: center;">
|
||||
<button class="btn-primary" onclick="openNewEventForDay()">
|
||||
<span class="material-icons-round">add</span> Adicionar Evento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="day-events-modal" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="day-events-title">
|
||||
<div class="modal-header"><h2 id="day-events-title">Eventos do dia</h2><button class="icon-button modal-close" type="button" data-close-modal="day-events-modal" aria-label="Fechar"><span aria-hidden="true">×</span></button></div>
|
||||
<div class="modal-body"><div id="day-events-list" class="list-container"></div></div>
|
||||
<div class="modal-footer centered"><button class="button primary" id="new-day-event-btn" type="button">Adicionar evento</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/sw.js')
|
||||
.then(reg => console.log('SW registrado:', reg.scope))
|
||||
.catch(err => console.log('Erro ao registrar SW:', err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in a new issue