Push to Gitea ๐
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Flado Configuration
|
||||||
|
# SECURITY: Set a strong secret key in production!
|
||||||
|
# Generate one with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
FLADO_SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Database Configuration (optional)
|
||||||
|
# Default: sqlite:///instance/flado.sqlite
|
||||||
|
# FLADO_DATABASE_URI=sqlite:///instance/flado.sqlite
|
||||||
|
|
||||||
|
# Theme Configuration (optional)
|
||||||
|
# Options: light, dark, auto
|
||||||
|
# FLADO_THEME=auto
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
ARG FLADO_DATABASE_URI=sqlite:////app/instance/flado.sqlite
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_APP=flado.app:create_app \
|
||||||
|
FLADO_DATABASE_URI=${FLADO_DATABASE_URI}
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
|
||||||
|
# Install curl for healthcheck
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/instance && chown -R app:app /app/instance
|
||||||
|
|
||||||
|
VOLUME ["/app/instance"]
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error http://localhost:5000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "mkdir -p /app/instance && flask db upgrade && gunicorn --bind 0.0.0.0:5000 --workers 2 --timeout 120 --access-logfile - --error-logfile - wsgi:app"]
|
||||||
28
LICENSE
Normal file
28
LICENSE
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2025, Sandro Cazzaniga
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
1
flado/__init__.py
Normal file
1
flado/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Flado package
|
||||||
112
flado/app.py
Normal file
112
flado/app.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Flask application factory."""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, Response
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .blueprints import tasks_blueprint
|
||||||
|
from .models import db
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Determine if we're in production
|
||||||
|
_is_production = os.getenv(
|
||||||
|
'FLASK_DEBUG', '').lower() not in ('1', 'true', 'yes')
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(app: Flask) -> None:
|
||||||
|
"""Configure logging for the application."""
|
||||||
|
if not app.debug and not app.testing:
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
else:
|
||||||
|
app.logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_name: Optional[str] = None) -> Flask:
|
||||||
|
"""
|
||||||
|
Application factory pattern for Flask.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_name: Optional configuration name (development, production, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flask application instance
|
||||||
|
"""
|
||||||
|
# Get the base directory (project root)
|
||||||
|
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
template_dir = os.path.join(base_dir, 'flado', 'templates')
|
||||||
|
static_dir = os.path.join(base_dir, 'static')
|
||||||
|
|
||||||
|
app = Flask(__name__, template_folder=template_dir,
|
||||||
|
static_folder=static_dir)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
secret_key = os.getenv('FLADO_SECRET_KEY')
|
||||||
|
if _is_production and not secret_key:
|
||||||
|
raise ValueError(
|
||||||
|
"FLADO_SECRET_KEY environment variable must be set in production"
|
||||||
|
)
|
||||||
|
app.config['SECRET_KEY'] = secret_key or 'dev-secret-key-change-in-production'
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
database_uri = os.getenv('FLADO_DATABASE_URI')
|
||||||
|
if not database_uri:
|
||||||
|
instance_dir = os.path.join(base_dir, 'instance')
|
||||||
|
os.makedirs(instance_dir, exist_ok=True)
|
||||||
|
database_path = os.path.join(instance_dir, 'flado.sqlite')
|
||||||
|
database_uri = f'sqlite:///{database_path}'
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = database_uri
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
# Theme configuration
|
||||||
|
app.config['FLADO_THEME'] = os.getenv('FLADO_THEME', 'auto')
|
||||||
|
|
||||||
|
# Session configuration for CSRF protection
|
||||||
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
|
if _is_production:
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
setup_logging(app)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
migrate = Migrate(app, db)
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
# Exempt health check endpoint from CSRF (used by monitoring)
|
||||||
|
csrf.exempt('tasks.health_check')
|
||||||
|
|
||||||
|
# Register error handlers
|
||||||
|
@app.errorhandler(400)
|
||||||
|
def bad_request(error) -> Tuple[Response, int]:
|
||||||
|
"""Handle 400 errors with JSON response."""
|
||||||
|
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error) -> Tuple[Response, int]:
|
||||||
|
"""Handle 404 errors with JSON response."""
|
||||||
|
return jsonify({'error': 'Not found', 'message': str(error)}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(CSRFError)
|
||||||
|
def handle_csrf_error(e) -> Tuple[Response, int]:
|
||||||
|
"""Handle CSRF errors with JSON response."""
|
||||||
|
app.logger.warning(f'CSRF error: {e}')
|
||||||
|
return jsonify({'error': 'CSRF token missing or invalid'}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error) -> Tuple[Response, int]:
|
||||||
|
"""Handle 500 errors with JSON response."""
|
||||||
|
app.logger.error(f'Server error: {error}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(tasks_blueprint)
|
||||||
|
|
||||||
|
return app
|
||||||
242
flado/blueprints.py
Normal file
242
flado/blueprints.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""Blueprint for task-related routes."""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Dict, Any, Tuple
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, jsonify, Response
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .models import Task, db
|
||||||
|
from .services import (
|
||||||
|
get_tasks, create_task, update_task, delete_task,
|
||||||
|
reorder_tasks, get_all_tags
|
||||||
|
)
|
||||||
|
from .validators import (
|
||||||
|
validate_title, validate_description, validate_date_format,
|
||||||
|
validate_tag_names, validate_task_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
tasks_blueprint = Blueprint('tasks', __name__, url_prefix='/')
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/health')
|
||||||
|
def health_check() -> Response:
|
||||||
|
"""
|
||||||
|
Health check endpoint for monitoring.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with health status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Test database connection
|
||||||
|
db.session.execute(text('SELECT 1'))
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'database': 'connected'
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Health check failed: {e}')
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unhealthy',
|
||||||
|
'database': 'disconnected',
|
||||||
|
'error': str(e)
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/')
|
||||||
|
def index() -> str:
|
||||||
|
"""Render the main task list page."""
|
||||||
|
filter_type = request.args.get('filter', 'all')
|
||||||
|
search_query = request.args.get('search', '')
|
||||||
|
|
||||||
|
# Validate filter type
|
||||||
|
valid_filters = ['all', 'today', 'upcoming', 'active', 'completed']
|
||||||
|
if filter_type not in valid_filters:
|
||||||
|
filter_type = 'all'
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = get_tasks(
|
||||||
|
filter_type, search_query if search_query else None).all()
|
||||||
|
tags = get_all_tags()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
tasks=tasks,
|
||||||
|
tags=tags,
|
||||||
|
filter_type=filter_type,
|
||||||
|
search_query=search_query,
|
||||||
|
today=date.today()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error rendering index: {e}')
|
||||||
|
# Return empty state on error
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
tasks=[],
|
||||||
|
tags=[],
|
||||||
|
filter_type=filter_type,
|
||||||
|
search_query=search_query,
|
||||||
|
today=date.today()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/api/tasks', methods=['POST'])
|
||||||
|
def api_create_task() -> Tuple[Response, int]:
|
||||||
|
"""API endpoint to create a new task."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||||
|
|
||||||
|
# Validate title
|
||||||
|
title = data.get('title')
|
||||||
|
is_valid, error = validate_title(title)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
title = title.strip()
|
||||||
|
|
||||||
|
# Validate description
|
||||||
|
description = data.get('description')
|
||||||
|
is_valid, error = validate_description(description)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
description = description.strip() if description else None
|
||||||
|
|
||||||
|
# Validate and parse due date
|
||||||
|
due_date_str = data.get('due_date')
|
||||||
|
due_date = None
|
||||||
|
if due_date_str:
|
||||||
|
is_valid, error = validate_date_format(due_date_str)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
try:
|
||||||
|
due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid date value'}), 400
|
||||||
|
|
||||||
|
# Validate tags
|
||||||
|
tag_names = data.get('tags', [])
|
||||||
|
is_valid, error = validate_tag_names(tag_names)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
|
||||||
|
task = create_task(title, description, due_date, tag_names)
|
||||||
|
logger.info(f'Created task {task.id}: {task.title}')
|
||||||
|
return jsonify(task.to_dict()), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error creating task: {e}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['GET'])
|
||||||
|
def api_get_task(task_id: int) -> Tuple[Response, int]:
|
||||||
|
"""API endpoint to get a single task."""
|
||||||
|
try:
|
||||||
|
task = Task.query.get_or_404(task_id)
|
||||||
|
return jsonify(task.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error fetching task {task_id}: {e}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def api_update_task(task_id: int) -> Tuple[Response, int]:
|
||||||
|
"""API endpoint to update a task."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||||
|
|
||||||
|
update_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Validate and update title
|
||||||
|
if 'title' in data:
|
||||||
|
is_valid, error = validate_title(data['title'])
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
update_data['title'] = data['title'].strip()
|
||||||
|
|
||||||
|
# Validate and update description
|
||||||
|
if 'description' in data:
|
||||||
|
is_valid, error = validate_description(data['description'])
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
description = data['description']
|
||||||
|
update_data['description'] = description.strip(
|
||||||
|
) if description else None
|
||||||
|
|
||||||
|
# Validate and update completed status
|
||||||
|
if 'completed' in data:
|
||||||
|
if not isinstance(data['completed'], bool):
|
||||||
|
return jsonify({'error': 'completed must be a boolean'}), 400
|
||||||
|
update_data['completed'] = data['completed']
|
||||||
|
|
||||||
|
# Validate and update due date
|
||||||
|
if 'due_date' in data:
|
||||||
|
due_date_str = data['due_date']
|
||||||
|
if due_date_str:
|
||||||
|
is_valid, error = validate_date_format(due_date_str)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
try:
|
||||||
|
update_data['due_date'] = datetime.strptime(
|
||||||
|
due_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid date value'}), 400
|
||||||
|
else:
|
||||||
|
update_data['due_date'] = None
|
||||||
|
|
||||||
|
# Validate and update tags
|
||||||
|
if 'tags' in data:
|
||||||
|
tag_names = data['tags']
|
||||||
|
is_valid, error = validate_tag_names(tag_names)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
update_data['tag_names'] = tag_names
|
||||||
|
|
||||||
|
task = update_task(task_id, **update_data)
|
||||||
|
logger.info(f'Updated task {task_id}')
|
||||||
|
return jsonify(task.to_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error updating task {task_id}: {e}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['DELETE'])
|
||||||
|
def api_delete_task(task_id: int) -> Tuple[Response, int]:
|
||||||
|
"""API endpoint to delete a task."""
|
||||||
|
try:
|
||||||
|
delete_task(task_id)
|
||||||
|
logger.info(f'Deleted task {task_id}')
|
||||||
|
return jsonify({'success': True}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error deleting task {task_id}: {e}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_blueprint.route('/api/tasks/reorder', methods=['POST'])
|
||||||
|
def api_reorder_tasks() -> Tuple[Response, int]:
|
||||||
|
"""API endpoint to reorder tasks."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||||
|
|
||||||
|
task_ids = data.get('task_ids', [])
|
||||||
|
|
||||||
|
# Validate task IDs
|
||||||
|
is_valid, error = validate_task_ids(task_ids)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
|
||||||
|
reorder_tasks(task_ids)
|
||||||
|
logger.info(f'Reordered {len(task_ids)} tasks')
|
||||||
|
return jsonify({'success': True}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error reordering tasks: {e}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
71
flado/models.py
Normal file
71
flado/models.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""SQLAlchemy models for Flado."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
"""Get current UTC datetime."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Task(db.Model):
|
||||||
|
"""Task model representing a todo item."""
|
||||||
|
__tablename__ = 'tasks'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
completed = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, default=utc_now, nullable=False)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
position = db.Column(db.Integer, default=0, nullable=False)
|
||||||
|
tags = db.relationship('Tag', secondary='task_tags',
|
||||||
|
back_populates='tasks', lazy='select')
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert task to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title,
|
||||||
|
'description': self.description,
|
||||||
|
'completed': self.completed,
|
||||||
|
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||||
|
'created_at': self.created_at.isoformat(),
|
||||||
|
'updated_at': self.updated_at.isoformat(),
|
||||||
|
'position': self.position,
|
||||||
|
'tags': [tag.name for tag in self.tags]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<Task {self.id}: {self.title}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(db.Model):
|
||||||
|
"""Tag model for categorizing tasks."""
|
||||||
|
__tablename__ = 'tags'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
color = db.Column(db.String(7), default='#3b82f6',
|
||||||
|
nullable=False) # Hex color
|
||||||
|
tasks = db.relationship('Task', secondary='task_tags',
|
||||||
|
back_populates='tags', lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<Tag {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship
|
||||||
|
task_tags = db.Table('task_tags',
|
||||||
|
db.Column('task_id', db.Integer, db.ForeignKey(
|
||||||
|
'tasks.id'), primary_key=True),
|
||||||
|
db.Column('tag_id', db.Integer, db.ForeignKey(
|
||||||
|
'tags.id'), primary_key=True)
|
||||||
|
)
|
||||||
238
flado/services.py
Normal file
238
flado/services.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Business logic helpers for Flado."""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date, timezone
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy import or_, func
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
|
||||||
|
from .models import db, Task, Tag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tasks(filter_type: str = 'all', search_query: Optional[str] = None) -> Query:
|
||||||
|
"""
|
||||||
|
Retrieve tasks based on filter type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_type: 'all', 'today', 'upcoming', 'completed', 'active'
|
||||||
|
search_query: Optional search string to filter by title/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query object of tasks
|
||||||
|
"""
|
||||||
|
query = Task.query
|
||||||
|
|
||||||
|
if filter_type == 'today':
|
||||||
|
query = query.filter(Task.due_date == date.today())
|
||||||
|
elif filter_type == 'upcoming':
|
||||||
|
query = query.filter(Task.due_date > date.today())
|
||||||
|
elif filter_type == 'completed':
|
||||||
|
query = query.filter(Task.completed.is_(True))
|
||||||
|
elif filter_type == 'active':
|
||||||
|
query = query.filter(Task.completed.is_(False))
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
search_term = f'%{search_query}%'
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Task.title.ilike(search_term),
|
||||||
|
Task.description.ilike(search_term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.order_by(Task.position.asc(), Task.created_at.desc())
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
title: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
due_date: Optional[date] = None,
|
||||||
|
tag_names: Optional[List[str]] = None
|
||||||
|
) -> Task:
|
||||||
|
"""
|
||||||
|
Create a new task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Task title (required, already validated)
|
||||||
|
description: Optional task description (already validated)
|
||||||
|
due_date: Optional due date (date object, already validated)
|
||||||
|
tag_names: Optional list of tag names to associate (already validated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Task object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If title is empty or invalid
|
||||||
|
"""
|
||||||
|
if not title or not title.strip():
|
||||||
|
raise ValueError("Task title is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get max position for ordering
|
||||||
|
max_position = db.session.query(func.max(Task.position)).scalar() or 0
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
title=title.strip(),
|
||||||
|
description=description.strip() if description else None,
|
||||||
|
due_date=due_date,
|
||||||
|
position=max_position + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag_names:
|
||||||
|
tags = get_or_create_tags(tag_names)
|
||||||
|
task.tags = tags
|
||||||
|
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
return task
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'Error creating task: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(task_id: int, **kwargs) -> Task:
|
||||||
|
"""
|
||||||
|
Update an existing task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID of task to update
|
||||||
|
**kwargs: Fields to update (title, description, completed, due_date, tag_names)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Task object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
404: If task not found
|
||||||
|
"""
|
||||||
|
task = Task.query.get_or_404(task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'title' in kwargs and kwargs['title']:
|
||||||
|
task.title = kwargs['title'].strip()
|
||||||
|
if 'description' in kwargs:
|
||||||
|
task.description = kwargs['description'].strip(
|
||||||
|
) if kwargs['description'] else None
|
||||||
|
if 'completed' in kwargs:
|
||||||
|
task.completed = kwargs['completed']
|
||||||
|
if 'due_date' in kwargs:
|
||||||
|
task.due_date = kwargs['due_date']
|
||||||
|
if 'tag_names' in kwargs:
|
||||||
|
tag_names = kwargs['tag_names']
|
||||||
|
if tag_names:
|
||||||
|
tags = get_or_create_tags(tag_names)
|
||||||
|
task.tags = tags
|
||||||
|
else:
|
||||||
|
task.tags = []
|
||||||
|
|
||||||
|
task.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
return task
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'Error updating task {task_id}: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(task_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID of task to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
404: If task not found
|
||||||
|
"""
|
||||||
|
task = Task.query.get_or_404(task_id)
|
||||||
|
try:
|
||||||
|
db.session.delete(task)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'Error deleting task {task_id}: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_tasks(task_ids: List[int]) -> bool:
|
||||||
|
"""
|
||||||
|
Reorder tasks by updating their positions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_ids: List of task IDs in desired order (already validated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if reordered successfully
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Invalid task IDs are silently skipped (task not found).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Fetch all tasks in current order so unaffected tasks keep stable positions
|
||||||
|
all_tasks = Task.query.order_by(
|
||||||
|
Task.position.asc(), Task.created_at.desc()
|
||||||
|
).all()
|
||||||
|
if not all_tasks:
|
||||||
|
return True
|
||||||
|
|
||||||
|
id_to_task = {task.id: task for task in all_tasks}
|
||||||
|
|
||||||
|
# Build new ordered list: first the provided IDs (if they exist), then the rest
|
||||||
|
reordered_tasks = []
|
||||||
|
seen_ids = set()
|
||||||
|
for task_id in task_ids:
|
||||||
|
task = id_to_task.get(task_id)
|
||||||
|
if task and task_id not in seen_ids:
|
||||||
|
reordered_tasks.append(task)
|
||||||
|
seen_ids.add(task_id)
|
||||||
|
|
||||||
|
for task in all_tasks:
|
||||||
|
if task.id not in seen_ids:
|
||||||
|
reordered_tasks.append(task)
|
||||||
|
|
||||||
|
for index, task in enumerate(reordered_tasks, start=1):
|
||||||
|
task.position = index
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'Error reordering tasks: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_tags(tag_names: List[str]) -> List[Tag]:
|
||||||
|
"""
|
||||||
|
Get existing tags or create new ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag_names: List of tag names (already validated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Tag objects
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Does not commit the session. Caller is responsible for committing.
|
||||||
|
"""
|
||||||
|
tags = []
|
||||||
|
for tag_name in tag_names:
|
||||||
|
tag_name = tag_name.strip()
|
||||||
|
if not tag_name:
|
||||||
|
continue
|
||||||
|
tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=tag_name)
|
||||||
|
db.session.add(tag)
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_tags() -> List[Tag]:
|
||||||
|
"""Get all tags."""
|
||||||
|
return Tag.query.order_by(Tag.name.asc()).all()
|
||||||
42
flado/templates/base.html
Normal file
42
flado/templates/base.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
|
<title>{% block title %}Flado{% endblock %}</title>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="{{ url_for('static', filename='favicon.ico') }}"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body data-theme="{{ config.get('FLADO_THEME', 'auto') }}">
|
||||||
|
<div class="app-container">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1 class="app-title">Flado</h1>
|
||||||
|
<p class="app-subtitle">Simple task management</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">{% block content %}{% endblock %}</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>
|
||||||
|
© 2025 Flado by
|
||||||
|
<a href="https://sandro.cazzaniga.fr" target="_blank"
|
||||||
|
>Sandro CAZZANIGA</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p>Built with Python, Flask and โค๏ธ</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
180
flado/templates/index.html
Normal file
180
flado/templates/index.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tasks-container" data-filter="{{ filter_type }}" data-search="{{ search_query }}">
|
||||||
|
<div class="controls-bar">
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-input"
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
value="{{ search_query }}"
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<button class="search-clear" id="search-clear" style="display: none;">ร</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<a href="{{ url_for('tasks.index', filter='all') }}"
|
||||||
|
class="filter-tab {{ 'active' if filter_type == 'all' else '' }}">All</a>
|
||||||
|
<a href="{{ url_for('tasks.index', filter='today') }}"
|
||||||
|
class="filter-tab {{ 'active' if filter_type == 'today' else '' }}">Today</a>
|
||||||
|
<a href="{{ url_for('tasks.index', filter='upcoming') }}"
|
||||||
|
class="filter-tab {{ 'active' if filter_type == 'upcoming' else '' }}">Upcoming</a>
|
||||||
|
<a href="{{ url_for('tasks.index', filter='active') }}"
|
||||||
|
class="filter-tab {{ 'active' if filter_type == 'active' else '' }}">Active</a>
|
||||||
|
<a href="{{ url_for('tasks.index', filter='completed') }}"
|
||||||
|
class="filter-tab {{ 'active' if filter_type == 'completed' else '' }}">Completed</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-add-form">
|
||||||
|
<form id="task-form" class="task-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="task-title-input"
|
||||||
|
placeholder="Add a new task..."
|
||||||
|
class="task-input"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button type="submit" class="add-button" title="Add task (Enter)">+</button>
|
||||||
|
</form>
|
||||||
|
<div class="task-details-expand" id="task-details-toggle" style="display: none;">
|
||||||
|
<button class="expand-button">
|
||||||
|
<span class="expand-icon">+</span>
|
||||||
|
Add details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="task-details-form" id="task-details-form" style="display: none;">
|
||||||
|
<textarea
|
||||||
|
id="task-description-input"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
class="task-description-input"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="task-due-date-input"
|
||||||
|
class="task-due-date-input"
|
||||||
|
>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-button" id="cancel-details">Cancel</button>
|
||||||
|
<button type="submit" class="save-button" form="task-form">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-list" id="task-list">
|
||||||
|
{% if tasks %}
|
||||||
|
{% for task in tasks %}
|
||||||
|
<div class="task-item {{ 'completed' if task.completed else '' }}"
|
||||||
|
data-task-id="{{ task.id }}"
|
||||||
|
draggable="true">
|
||||||
|
<div class="task-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="task-check"
|
||||||
|
data-task-id="{{ task.id }}"
|
||||||
|
{{ 'checked' if task.completed else '' }}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="task-content">
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-title-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="task-title"
|
||||||
|
value="{{ task.title }}"
|
||||||
|
data-task-id="{{ task.id }}"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% if task.due_date %}
|
||||||
|
<div class="task-due-date">
|
||||||
|
<span class="due-date-badge {{ 'overdue' if task.due_date < today and not task.completed else '' }}">
|
||||||
|
{{ task.due_date.strftime('%b %d') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if task.description %}
|
||||||
|
<div class="task-description">{{ task.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if task.tags %}
|
||||||
|
<div class="task-tags">
|
||||||
|
{% for tag in task.tags %}
|
||||||
|
{% set bg_color = tag.color + "20" %}
|
||||||
|
<span class="task-tag" style="background-color: {{ bg_color }}; color: {{ tag.color }};">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="task-edit" data-task-id="{{ task.id }}" title="Edit">โ</button>
|
||||||
|
<button class="task-delete" data-task-id="{{ task.id }}" title="Delete">ร</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-message">No tasks found. Add one above to get started!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="edit-modal" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Edit Task</h2>
|
||||||
|
<button class="modal-close" id="modal-close">ร</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="edit-task-form">
|
||||||
|
<input type="hidden" id="edit-task-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-task-title">Title</label>
|
||||||
|
<input type="text" id="edit-task-title" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-task-description">Description</label>
|
||||||
|
<textarea id="edit-task-description" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-task-due-date">Due Date</label>
|
||||||
|
<input type="date" id="edit-task-due-date">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-button" id="cancel-edit">Cancel</button>
|
||||||
|
<button type="submit" class="save-button">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="delete-modal" style="display: none;">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<div class="delete-modal-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body delete-modal-body">
|
||||||
|
<h2 class="delete-modal-title">Delete Task</h2>
|
||||||
|
<p class="delete-modal-message">Are you sure you want to delete this task? This action cannot be undone.</p>
|
||||||
|
<div class="form-actions delete-modal-actions">
|
||||||
|
<button type="button" class="cancel-button" id="delete-cancel">Cancel</button>
|
||||||
|
<button type="button" class="delete-confirm-button" id="delete-confirm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
172
flado/validators.py
Normal file
172
flado/validators.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Input validation utilities for Flado."""
|
||||||
|
import re
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def validate_title(title: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate task title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Title to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return False, "Title is required"
|
||||||
|
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
return False, "Title cannot be empty"
|
||||||
|
|
||||||
|
if len(title) > 200:
|
||||||
|
return False, "Title must be 200 characters or less"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_description(description: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate task description.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Description to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if description is None:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
description = description.strip()
|
||||||
|
if len(description) > 10000: # Reasonable limit for text field
|
||||||
|
return False, "Description must be 10000 characters or less"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_date_format(date_str: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate date string format (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: Date string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not date_str:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not isinstance(date_str, str):
|
||||||
|
return False, "Date must be a string"
|
||||||
|
|
||||||
|
# Check format YYYY-MM-DD
|
||||||
|
pattern = r'^\d{4}-\d{2}-\d{2}$'
|
||||||
|
if not re.match(pattern, date_str):
|
||||||
|
return False, "Date must be in YYYY-MM-DD format"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tag_name(tag_name: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate tag name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag_name: Tag name to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not tag_name:
|
||||||
|
return False, "Tag name is required"
|
||||||
|
|
||||||
|
tag_name = tag_name.strip()
|
||||||
|
if not tag_name:
|
||||||
|
return False, "Tag name cannot be empty"
|
||||||
|
|
||||||
|
if len(tag_name) > 50:
|
||||||
|
return False, "Tag name must be 50 characters or less"
|
||||||
|
|
||||||
|
# Allow alphanumeric, spaces, hyphens, underscores
|
||||||
|
if not re.match(r'^[\w\s-]+$', tag_name):
|
||||||
|
return False, "Tag name contains invalid characters"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tag_names(tag_names: Optional[List[str]]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate list of tag names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag_names: List of tag names to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if tag_names is None:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not isinstance(tag_names, list):
|
||||||
|
return False, "Tags must be a list"
|
||||||
|
|
||||||
|
for tag_name in tag_names:
|
||||||
|
is_valid, error = validate_tag_name(tag_name)
|
||||||
|
if not is_valid:
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_hex_color(color: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate hex color format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color: Hex color string (e.g., #RRGGBB)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not color:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not isinstance(color, str):
|
||||||
|
return False, "Color must be a string"
|
||||||
|
|
||||||
|
pattern = r'^#[0-9A-Fa-f]{6}$'
|
||||||
|
if not re.match(pattern, color):
|
||||||
|
return False, "Color must be in #RRGGBB hex format"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_task_ids(task_ids: Optional[List[int]]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate list of task IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_ids: List of task IDs to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not task_ids:
|
||||||
|
return False, "task_ids is required"
|
||||||
|
|
||||||
|
if not isinstance(task_ids, list):
|
||||||
|
return False, "task_ids must be a list"
|
||||||
|
|
||||||
|
if len(task_ids) == 0:
|
||||||
|
return False, "task_ids cannot be empty"
|
||||||
|
|
||||||
|
for task_id in task_ids:
|
||||||
|
if not isinstance(task_id, int):
|
||||||
|
return False, "All task IDs must be integers"
|
||||||
|
if task_id <= 0:
|
||||||
|
return False, "All task IDs must be positive integers"
|
||||||
|
|
||||||
|
return True, None
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
54
migrations/versions/3c60b8937d43_auto_migration.py
Normal file
54
migrations/versions/3c60b8937d43_auto_migration.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""auto migration
|
||||||
|
|
||||||
|
Revision ID: 3c60b8937d43
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-11-05 11:13:53.125077
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3c60b8937d43'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tags',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('tasks',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('completed', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('task_tags',
|
||||||
|
sa.Column('task_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('task_id', 'tag_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('task_tags')
|
||||||
|
op.drop_table('tasks')
|
||||||
|
op.drop_table('tags')
|
||||||
|
# ### end Alembic commands ###
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Flask==3.1.2
|
||||||
|
SQLAlchemy>=2.0.36
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.0.5
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
Werkzeug==3.1.0
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
Flask-WTF>=1.2.0
|
||||||
|
|
||||||
634
static/css/style.css
Normal file
634
static/css/style.css
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/* Flado - Modern Todo App Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--text-primary: #1f2937;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f9fafb;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--radius: 8px;
|
||||||
|
--transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--text-primary: #f9fafb;
|
||||||
|
--text-secondary: #d1d5db;
|
||||||
|
--bg-primary: #111827;
|
||||||
|
--bg-secondary: #1f2937;
|
||||||
|
--border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls Bar */
|
||||||
|
.controls-bar {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab:hover {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Add Form */
|
||||||
|
.quick-add-form {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-details-expand {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:hover .expand-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:active .expand-icon {
|
||||||
|
transform: rotate(90deg) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-details-form {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description-input,
|
||||||
|
.task-due-date-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description-input:focus,
|
||||||
|
.task-due-date-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List */
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.completed {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.completed .task-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-check {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title:focus {
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title[readonly] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-tag {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date-badge.overdue {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover .task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-edit,
|
||||||
|
.task-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-edit:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete:hover {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button,
|
||||||
|
.cancel-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete Confirmation Modal */
|
||||||
|
.delete-modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 2rem auto 1rem;
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-body {
|
||||||
|
padding: 0 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-message {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-actions {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-button:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-container {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title-wrapper {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
569
static/js/main.js
Normal file
569
static/js/main.js
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
// Flado - Main JavaScript
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initializeApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializeApp() {
|
||||||
|
setupTaskForm();
|
||||||
|
setupTaskInteractions();
|
||||||
|
setupSearch();
|
||||||
|
setupDragAndDrop();
|
||||||
|
setupKeyboardShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection Helpers
|
||||||
|
function getCSRFToken() {
|
||||||
|
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return metaTag ? metaTag.getAttribute('content') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest(url, options = {}) {
|
||||||
|
const token = getCSRFToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': token,
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTaskForm() {
|
||||||
|
const form = document.getElementById("task-form");
|
||||||
|
const titleInput = document.getElementById("task-title-input");
|
||||||
|
const descriptionInput = document.getElementById("task-description-input");
|
||||||
|
const dueDateInput = document.getElementById("task-due-date-input");
|
||||||
|
const detailsToggle = document.getElementById("task-details-toggle");
|
||||||
|
const detailsForm = document.getElementById("task-details-form");
|
||||||
|
const cancelDetails = document.getElementById("cancel-details");
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
if (titleInput && sessionStorage.getItem("focusTaskInput") === "true") {
|
||||||
|
titleInput.focus();
|
||||||
|
sessionStorage.removeItem("focusTaskInput");
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const title = titleInput.value.trim();
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
const description = descriptionInput.value.trim() || null;
|
||||||
|
const dueDate = dueDateInput.value || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
due_date: dueDate,
|
||||||
|
tags: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const task = await response.json();
|
||||||
|
sessionStorage.setItem("focusTaskInput", "true");
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert("Error: " + (error.error || "Failed to create task"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating task:", error);
|
||||||
|
alert("Failed to create task. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
titleInput.value = "";
|
||||||
|
descriptionInput.value = "";
|
||||||
|
dueDateInput.value = "";
|
||||||
|
detailsForm.style.display = "none";
|
||||||
|
detailsToggle.style.display = "none";
|
||||||
|
if (titleInput) {
|
||||||
|
titleInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
titleInput.addEventListener("input", function () {
|
||||||
|
if (this.value.trim() && detailsForm.style.display === "none") {
|
||||||
|
detailsToggle.style.display = "block";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailsToggle) {
|
||||||
|
detailsToggle.addEventListener("click", function () {
|
||||||
|
detailsForm.style.display =
|
||||||
|
detailsForm.style.display === "none" ? "block" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelDetails) {
|
||||||
|
cancelDetails.addEventListener("click", function () {
|
||||||
|
descriptionInput.value = "";
|
||||||
|
dueDateInput.value = "";
|
||||||
|
detailsForm.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTaskInteractions() {
|
||||||
|
document.addEventListener("change", async function (e) {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-check")) {
|
||||||
|
const taskId = parseInt(e.target.dataset.taskId);
|
||||||
|
const completed = e.target.checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ completed }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const task = await response.json();
|
||||||
|
const taskItem = e.target.closest(".task-item");
|
||||||
|
if (completed) {
|
||||||
|
taskItem.classList.add("completed");
|
||||||
|
} else {
|
||||||
|
taskItem.classList.remove("completed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.target.checked = !completed;
|
||||||
|
alert("Failed to update task");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating task:", error);
|
||||||
|
e.target.checked = !completed;
|
||||||
|
alert("Failed to update task. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("dblclick", function (e) {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-title")) {
|
||||||
|
e.target.removeAttribute("readonly");
|
||||||
|
e.target.focus();
|
||||||
|
e.target.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"blur",
|
||||||
|
async function (e) {
|
||||||
|
if (
|
||||||
|
e.target &&
|
||||||
|
e.target.classList &&
|
||||||
|
e.target.classList.contains("task-title") &&
|
||||||
|
!e.target.hasAttribute("readonly")
|
||||||
|
) {
|
||||||
|
const taskId = parseInt(e.target.dataset.taskId);
|
||||||
|
const title = e.target.value.trim();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
e.target.value = e.target.defaultValue;
|
||||||
|
e.target.setAttribute("readonly", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
e.target.setAttribute("readonly", "");
|
||||||
|
} else {
|
||||||
|
alert("Failed to update task");
|
||||||
|
e.target.value = e.target.defaultValue;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating task:", error);
|
||||||
|
alert("Failed to update task. Please try again.");
|
||||||
|
e.target.value = e.target.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.setAttribute("readonly", "");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-edit")) {
|
||||||
|
const taskId = parseInt(e.target.dataset.taskId);
|
||||||
|
openEditModal(taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-delete")) {
|
||||||
|
const taskId = parseInt(e.target.dataset.taskId);
|
||||||
|
openDeleteModal(taskId, e.target.closest(".task-item"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditModal(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/tasks/${taskId}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
alert("Failed to load task");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await response.json();
|
||||||
|
const modal = document.getElementById("edit-modal");
|
||||||
|
const form = document.getElementById("edit-task-form");
|
||||||
|
|
||||||
|
document.getElementById("edit-task-id").value = task.id;
|
||||||
|
document.getElementById("edit-task-title").value = task.title;
|
||||||
|
document.getElementById("edit-task-description").value =
|
||||||
|
task.description || "";
|
||||||
|
document.getElementById("edit-task-due-date").value = task.due_date || "";
|
||||||
|
|
||||||
|
modal.style.display = "flex";
|
||||||
|
|
||||||
|
form.onsubmit = async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const title = document.getElementById("edit-task-title").value.trim();
|
||||||
|
if (!title) {
|
||||||
|
alert("Title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description =
|
||||||
|
document.getElementById("edit-task-description").value.trim() || null;
|
||||||
|
const dueDate =
|
||||||
|
document.getElementById("edit-task-due-date").value || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateResponse = await apiRequest(`/api/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
due_date: dueDate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateResponse.ok) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await updateResponse.json();
|
||||||
|
alert("Error: " + (error.error || "Failed to update task"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating task:", error);
|
||||||
|
alert("Failed to update task. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("cancel-edit").onclick = function () {
|
||||||
|
modal.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("modal-close").onclick = function () {
|
||||||
|
modal.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.onclick = function (e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening edit modal:", error);
|
||||||
|
alert("Failed to load task");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(taskId, taskElement) {
|
||||||
|
const modal = document.getElementById("delete-modal");
|
||||||
|
const cancelButton = document.getElementById("delete-cancel");
|
||||||
|
const confirmButton = document.getElementById("delete-confirm");
|
||||||
|
let isConfirming = false;
|
||||||
|
|
||||||
|
modal.style.display = "flex";
|
||||||
|
|
||||||
|
const closeModal = function () {
|
||||||
|
modal.style.display = "none";
|
||||||
|
modal.onclick = null;
|
||||||
|
cancelButton.onclick = null;
|
||||||
|
confirmButton.onclick = null;
|
||||||
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
|
isConfirming = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.onclick = closeModal;
|
||||||
|
|
||||||
|
modal.onclick = function (e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmButton.onclick = async function () {
|
||||||
|
if (isConfirming) return;
|
||||||
|
isConfirming = true;
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/tasks/${taskId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
taskElement.remove();
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
const taskList = document.getElementById("task-list");
|
||||||
|
if (taskList && taskList.children.length === 0) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("Failed to delete task");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting task:", error);
|
||||||
|
alert("Failed to delete task. Please try again.");
|
||||||
|
} finally {
|
||||||
|
isConfirming = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = function (e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmButton.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
confirmButton.focus();
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
const searchInput = document.getElementById("search-input");
|
||||||
|
const searchClear = document.getElementById("search-clear");
|
||||||
|
const container = document.querySelector(".tasks-container");
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
if (searchInput.value && searchClear) {
|
||||||
|
searchClear.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
const performSearch = async (query) => {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (query) {
|
||||||
|
url.searchParams.set("search", query);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete("search");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch search results");
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
const newTaskList = doc.querySelector("#task-list");
|
||||||
|
const newContainer = doc.querySelector(".tasks-container");
|
||||||
|
const taskList = document.getElementById("task-list");
|
||||||
|
|
||||||
|
if (taskList && newTaskList) {
|
||||||
|
taskList.innerHTML = newTaskList.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container && newContainer) {
|
||||||
|
container.dataset.filter = newContainer.dataset.filter || "all";
|
||||||
|
container.dataset.search = newContainer.dataset.search || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error performing search:", error);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", function () {
|
||||||
|
const query = this.value.trim();
|
||||||
|
|
||||||
|
if (searchClear) {
|
||||||
|
searchClear.style.display = query ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => performSearch(query), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const query = this.value.trim();
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchClear) {
|
||||||
|
searchClear.addEventListener("click", function () {
|
||||||
|
searchInput.value = "";
|
||||||
|
searchClear.style.display = "none";
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
performSearch("");
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDragAndDrop() {
|
||||||
|
const container = document.querySelector(".tasks-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const taskList = document.getElementById("task-list");
|
||||||
|
if (!taskList) return;
|
||||||
|
if (taskList.dataset.dragSetup === "true") return;
|
||||||
|
|
||||||
|
const isReorderEnabled = () => {
|
||||||
|
const filterType = container.dataset.filter || "all";
|
||||||
|
const hasSearchQuery = (container.dataset.search || "").trim().length > 0;
|
||||||
|
return filterType === "all" && !hasSearchQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
let draggedElement = null;
|
||||||
|
|
||||||
|
taskList.addEventListener("dragstart", function (e) {
|
||||||
|
if (!isReorderEnabled()) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-item")) {
|
||||||
|
draggedElement = e.target;
|
||||||
|
e.target.classList.add("dragging");
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskList.addEventListener("dragend", function (e) {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains("task-item")) {
|
||||||
|
e.target.classList.remove("dragging");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskList.addEventListener("dragover", function (e) {
|
||||||
|
if (!isReorderEnabled()) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
|
||||||
|
const afterElement = getDragAfterElement(taskList, e.clientY);
|
||||||
|
const dragging = document.querySelector(".dragging");
|
||||||
|
|
||||||
|
if (afterElement == null) {
|
||||||
|
taskList.appendChild(dragging);
|
||||||
|
} else {
|
||||||
|
taskList.insertBefore(dragging, afterElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskList.addEventListener("drop", async function (e) {
|
||||||
|
if (!isReorderEnabled()) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const taskItems = Array.from(taskList.querySelectorAll(".task-item"));
|
||||||
|
const taskIds = taskItems.map((item) => parseInt(item.dataset.taskId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("/api/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ task_ids: taskIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert("Failed to reorder tasks");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reordering tasks:", error);
|
||||||
|
alert("Failed to reorder tasks. Please try again.");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskList.dataset.dragSetup = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDragAfterElement(container, y) {
|
||||||
|
const draggableElements = [
|
||||||
|
...container.querySelectorAll(".task-item:not(.dragging)"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return draggableElements.reduce(
|
||||||
|
(closest, child) => {
|
||||||
|
const box = child.getBoundingClientRect();
|
||||||
|
const offset = y - box.top - box.height / 2;
|
||||||
|
|
||||||
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
return { offset: offset, element: child };
|
||||||
|
} else {
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ offset: Number.NEGATIVE_INFINITY },
|
||||||
|
).element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupKeyboardShortcuts() {
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchInput = document.getElementById("search-input");
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "n") {
|
||||||
|
e.preventDefault();
|
||||||
|
const taskInput = document.getElementById("task-title-input");
|
||||||
|
if (taskInput) {
|
||||||
|
taskInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user