Push to Gitea ๐Ÿš€

This commit is contained in:
2025-11-06 07:54:23 +01:00
parent a3bb1b3dd6
commit e39470e126
22 changed files with 2633 additions and 0 deletions

12
.env.example Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Flado package

112
flado/app.py Normal file
View 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
View 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
View 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
View 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
View 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>
&copy; 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
View 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
View 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
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

569
static/js/main.js Normal file
View 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();
}
}
});
}

9
wsgi.py Normal file
View File

@@ -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()