diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f535ac1 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0896a65 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c3daa13 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d4df248 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/flado/__init__.py b/flado/__init__.py new file mode 100644 index 0000000..8078ceb --- /dev/null +++ b/flado/__init__.py @@ -0,0 +1 @@ +# Flado package diff --git a/flado/app.py b/flado/app.py new file mode 100644 index 0000000..ba4b777 --- /dev/null +++ b/flado/app.py @@ -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 diff --git a/flado/blueprints.py b/flado/blueprints.py new file mode 100644 index 0000000..12f09e6 --- /dev/null +++ b/flado/blueprints.py @@ -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/', 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/', 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/', 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 diff --git a/flado/models.py b/flado/models.py new file mode 100644 index 0000000..91f0763 --- /dev/null +++ b/flado/models.py @@ -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'' + + +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'' + + +# 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) + ) diff --git a/flado/services.py b/flado/services.py new file mode 100644 index 0000000..2c4ab65 --- /dev/null +++ b/flado/services.py @@ -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() diff --git a/flado/templates/base.html b/flado/templates/base.html new file mode 100644 index 0000000..2a7b01d --- /dev/null +++ b/flado/templates/base.html @@ -0,0 +1,42 @@ + + + + + + + {% block title %}Flado{% endblock %} + + + {% block extra_head %}{% endblock %} + + +
+
+

Flado

+

Simple task management

+
+ +
{% block content %}{% endblock %}
+ +
+

+ © 2025 Flado by + Sandro CAZZANIGA +

+

Built with Python, Flask and ❤️

+
+
+ + + {% block extra_scripts %}{% endblock %} + + diff --git a/flado/templates/index.html b/flado/templates/index.html new file mode 100644 index 0000000..dce7e5c --- /dev/null +++ b/flado/templates/index.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +
+ +
+
+ + +
+ + +
+ +
+ {% if tasks %} + {% for task in tasks %} +
+
+ +
+
+
+
+ +
+ {% if task.due_date %} +
+ + {{ task.due_date.strftime('%b %d') }} + +
+ {% endif %} +
+ {% if task.description %} +
{{ task.description }}
+ {% endif %} + {% if task.tags %} +
+ {% for tag in task.tags %} + {% set bg_color = tag.color + "20" %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} +
+
+ + +
+
+ {% endfor %} + {% else %} +
+

No tasks found. Add one above to get started!

+
+ {% endif %} +
+
+ + + + +{% endblock %} diff --git a/flado/validators.py b/flado/validators.py new file mode 100644 index 0000000..be14428 --- /dev/null +++ b/flado/validators.py @@ -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 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/3c60b8937d43_auto_migration.py b/migrations/versions/3c60b8937d43_auto_migration.py new file mode 100644 index 0000000..1cc6ce9 --- /dev/null +++ b/migrations/versions/3c60b8937d43_auto_migration.py @@ -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 ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00499e7 --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..aa6cab0 --- /dev/null +++ b/static/css/style.css @@ -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%; + } +} + diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..b71420b Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..a22a7e6 --- /dev/null +++ b/static/js/main.js @@ -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(); + } + } + }); +} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..1889d43 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI entry point for production deployment.""" +from flask import Flask +from flado.app import create_app + +app: Flask = create_app() + +if __name__ == "__main__": + app.run() +