From f3bffa40cda93aa548b12228535ae48c22c4913f Mon Sep 17 00:00:00 2001 From: Yuyao Huang Date: Fri, 8 May 2026 12:41:19 +0800 Subject: [PATCH] Initial commit: GoalsBreakDown web app - Flask backend with TinyDB database - Multi-user auth with bcrypt password hashing - Goal CRUD with activation/deactivation and per-user limits - Task CRUD with status tracking (todo/doing/pending/done) - Focus rule: one doing task per goal - Time picker-style scroll view with drag-and-drop reordering - Admin panel for user management - uv environment management --- .gitignore | 28 +++ README.md | 101 ++++++++++ app.py | 397 ++++++++++++++++++++++++++++++++++++++++ auth.py | 45 +++++ config.py | 13 ++ database.py | 137 ++++++++++++++ pyproject.toml | 10 + static/css/goals.css | 130 +++++++++++++ static/css/style.css | 253 +++++++++++++++++++++++++ static/css/tasks.css | 266 +++++++++++++++++++++++++++ static/js/api.js | 45 +++++ static/js/auth.js | 92 ++++++++++ static/js/goals.js | 152 +++++++++++++++ static/js/tasks.js | 301 ++++++++++++++++++++++++++++++ templates/admin.html | 165 +++++++++++++++++ templates/base.html | 28 +++ templates/goals.html | 56 ++++++ templates/login.html | 29 +++ templates/register.html | 33 ++++ templates/tasks.html | 93 ++++++++++ 20 files changed, 2374 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 auth.py create mode 100644 config.py create mode 100644 database.py create mode 100644 pyproject.toml create mode 100644 static/css/goals.css create mode 100644 static/css/style.css create mode 100644 static/css/tasks.css create mode 100644 static/js/api.js create mode 100644 static/js/auth.js create mode 100644 static/js/goals.js create mode 100644 static/js/tasks.js create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/goals.html create mode 100644 templates/login.html create mode 100644 templates/register.html create mode 100644 templates/tasks.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b265fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Database +data/ + +# Virtual environment +.venv/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# uv +uv.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc12a0 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# GoalsBreakDown + +A web-based task management application focused on goal-oriented task tracking with a scroll-view interface. + +## Features + +- Multi-user support with registration and authentication +- Goal management with activation/deactivation +- Task management with status tracking (todo/doing/pending/done) +- Focus rule: Only one "doing" task per goal +- Scroll-view task selector with drag-and-drop reordering +- Admin panel for user management +- Per-user goal limits + +## Quick Start + +### Prerequisites + +- Python 3.10+ +- [uv](https://github.com/astral-sh/uv) package manager + +### Installation + +```bash +# Clone the repository +git clone +cd GoalsBreakDown + +# Install dependencies with uv +uv sync + +# Run the application +uv run python app.py +``` + +The application will start at **http://127.0.0.1:5000** + +### Default Admin Account + +- **Username:** `admin` +- **Password:** `admin123` + +**Important:** Change the default admin password after first login. + +## Configuration + +Edit `config.py` to customize settings: + +```python +# Database path +DB_PATH = "data/db.json" + +# Default admin credentials (change these!) +DEFAULT_ADMIN_USERNAME = "admin" +DEFAULT_ADMIN_PASSWORD = "admin123" + +# Default max goals for new users +DEFAULT_MAX_GOALS = 5 + +# Flask settings +SECRET_KEY = "your-secret-key-here" # Change in production +DEBUG = True +HOST = "0.0.0.0" +PORT = 5000 +``` + +### Production Deployment + +1. Change `SECRET_KEY` to a random secure string +2. Set `DEBUG = False` +3. Change default admin credentials +4. Use a production WSGI server (e.g., gunicorn): + +```bash +uv add gunicorn +uv run gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +## Project Structure + +``` +GoalsBreakDown/ +├── app.py # Flask application +├── config.py # Configuration +├── database.py # TinyDB operations +├── auth.py # Authentication helpers +├── templates/ # HTML templates +├── static/ +│ ├── css/ # Stylesheets +│ └── js/ # JavaScript files +└── data/ # Database (auto-created, not tracked in git) +``` + +## Tech Stack + +- **Backend:** Python + Flask +- **Database:** TinyDB +- **Frontend:** Vanilla JS + HTML/CSS +- **Drag-and-Drop:** SortableJS +- **Authentication:** bcrypt + Flask sessions +- **Environment:** uv diff --git a/app.py b/app.py new file mode 100644 index 0000000..8f839bd --- /dev/null +++ b/app.py @@ -0,0 +1,397 @@ +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from datetime import datetime +import database +import auth +import config + +app = Flask(__name__) +app.secret_key = config.SECRET_KEY + +database.init_db() + + +@app.route("/") +def index(): + if "user_id" in session: + return redirect(url_for("goals_page")) + return redirect(url_for("login_page")) + + +@app.route("/login") +def login_page(): + return render_template("login.html") + + +@app.route("/register") +def register_page(): + return render_template("register.html") + + +@app.route("/goals") +@auth.login_required +def goals_page(): + return render_template("goals.html") + + +@app.route("/tasks") +@auth.login_required +def tasks_page(): + return render_template("tasks.html") + + +@app.route("/admin") +@auth.login_required +def admin_page(): + user = auth.get_current_user() + if not user or user.get("role") != "admin": + return redirect(url_for("goals_page")) + return render_template("admin.html") + + +@app.route("/api/auth/register", methods=["POST"]) +def api_register(): + data = request.get_json() + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + return jsonify({"success": False, "message": "Username and password are required"}), 400 + + if len(password) < 6: + return jsonify({"success": False, "message": "Password must be at least 6 characters"}), 400 + + if database.get_user_by_username(username): + return jsonify({"success": False, "message": "Username already exists"}), 400 + + password_hash = auth.hash_password(password) + user = database.create_user(username, password_hash) + + session["user_id"] = user.doc_id + session["username"] = username + + return jsonify({ + "success": True, + "message": "Registration successful", + "user_id": user.doc_id + }), 201 + + +@app.route("/api/auth/login", methods=["POST"]) +def api_login(): + data = request.get_json() + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + return jsonify({"success": False, "message": "Username and password are required"}), 400 + + user = database.get_user_by_username(username) + if not user or not auth.check_password(password, user["password_hash"]): + return jsonify({"success": False, "message": "Invalid credentials"}), 401 + + session["user_id"] = user.doc_id + session["username"] = username + + return jsonify({ + "success": True, + "message": "Login successful", + "user": { + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + } + }) + + +@app.route("/api/auth/logout", methods=["POST"]) +@auth.login_required +def api_logout(): + session.clear() + return jsonify({"success": True, "message": "Logged out"}) + + +@app.route("/api/auth/me", methods=["GET"]) +@auth.login_required +def api_me(): + user = auth.get_current_user() + if not user: + return jsonify({"success": False, "message": "User not found"}), 404 + return jsonify({ + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + }) + + +@app.route("/api/goals", methods=["GET"]) +@auth.login_required +def api_get_goals(): + user_id = session["user_id"] + goals = database.get_goals_by_user(user_id) + result = [] + for goal in goals: + goal_dict = dict(goal) + goal_dict["id"] = goal.doc_id + result.append(goal_dict) + return jsonify(result) + + +@app.route("/api/goals", methods=["POST"]) +@auth.login_required +def api_create_goal(): + data = request.get_json() + title = data.get("title", "").strip() + + if not title: + return jsonify({"success": False, "message": "Title is required"}), 400 + + user_id = session["user_id"] + user = database.get_user_by_id(user_id) + + if database.count_goals_by_user(user_id) >= user["max_goals"]: + return jsonify({ + "success": False, + "message": f"Goal limit reached ({user['max_goals']})" + }), 400 + + goal = database.create_goal(user_id, title) + return jsonify({"success": True, "goal_id": goal.doc_id}), 201 + + +@app.route("/api/goals/", methods=["PUT"]) +@auth.login_required +def api_update_goal(goal_id): + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + data = request.get_json() + updates = {} + + if "title" in data: + title = data["title"].strip() + if not title: + return jsonify({"success": False, "message": "Title cannot be empty"}), 400 + updates["title"] = title + + if "activated" in data: + updates["activated"] = bool(data["activated"]) + + database.update_goal(goal_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/goals/", methods=["DELETE"]) +@auth.login_required +def api_delete_goal(goal_id): + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + database.delete_goal(goal_id) + return jsonify({"success": True}) + + +@app.route("/api/goals//toggle", methods=["PATCH"]) +@auth.login_required +def api_toggle_goal(goal_id): + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + new_status = not goal["activated"] + database.update_goal(goal_id, activated=new_status) + return jsonify({"success": True, "activated": new_status}) + + +@app.route("/api/tasks", methods=["GET"]) +@auth.login_required +def api_get_tasks(): + goal_id = request.args.get("goal_id", type=int) + if not goal_id: + return jsonify([]) + + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + tasks = database.get_tasks_sorted(goal_id) + result = [] + for task in tasks: + task_dict = dict(task) + task_dict["id"] = task.doc_id + result.append(task_dict) + return jsonify(result) + + +@app.route("/api/tasks", methods=["POST"]) +@auth.login_required +def api_create_task(): + data = request.get_json() + goal_id = data.get("goal_id") + title = data.get("title", "").strip() + desc = data.get("desc", "").strip() + + if not goal_id or not title: + return jsonify({"success": False, "message": "Goal ID and title are required"}), 400 + + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + if not goal["activated"]: + return jsonify({"success": False, "message": "Cannot add task to deactivated goal"}), 400 + + task = database.create_task(goal_id, title, desc) + return jsonify({"success": True, "task_id": task.doc_id}), 201 + + +@app.route("/api/tasks/", methods=["PUT"]) +@auth.login_required +def api_update_task(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + updates = {} + + if "title" in data: + title = data["title"].strip() + if not title: + return jsonify({"success": False, "message": "Title cannot be empty"}), 400 + updates["title"] = title + + if "desc" in data: + updates["desc"] = data["desc"] + + database.update_task(task_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/tasks/", methods=["DELETE"]) +@auth.login_required +def api_delete_task(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + database.delete_task(task_id) + return jsonify({"success": True}) + + +@app.route("/api/tasks//status", methods=["PATCH"]) +@auth.login_required +def api_update_task_status(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + new_status = data.get("status") + + if new_status not in ["todo", "doing", "pending", "done"]: + return jsonify({"success": False, "message": "Invalid status"}), 400 + + updates = {"status": new_status} + + if new_status == "doing": + doing_task = None + all_tasks = database.get_tasks_by_goal(task["goal_id"]) + for t in all_tasks: + if t.get("status") == "doing" and t.doc_id != task.doc_id: + doing_task = t + break + if doing_task: + database.update_task(doing_task.doc_id, status="todo") + updates["start_time"] = datetime.now().isoformat() + else: + updates["start_time"] = None + + if new_status == "done": + updates["finished_time"] = datetime.now().isoformat() + else: + updates["finished_time"] = None + + database.update_task(task_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/tasks//order", methods=["PATCH"]) +@auth.login_required +def api_update_task_order(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + new_order = data.get("order") + + if new_order is None: + return jsonify({"success": False, "message": "Order value required"}), 400 + + database.update_task(task_id, order=float(new_order)) + return jsonify({"success": True}) + + +@app.route("/api/admin/users", methods=["GET"]) +@auth.admin_required +def api_get_users(): + users = database.get_all_users() + result = [] + for user in users: + result.append({ + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + }) + return jsonify(result) + + +@app.route("/api/admin/users/", methods=["PUT"]) +@auth.admin_required +def api_update_user(user_id): + user = database.get_user_by_id(user_id) + if not user: + return jsonify({"success": False, "message": "User not found"}), 404 + + data = request.get_json() + updates = {} + + if "max_goals" in data: + max_goals = data["max_goals"] + if not isinstance(max_goals, int) or max_goals < 1: + return jsonify({"success": False, "message": "Invalid max_goals value"}), 400 + updates["max_goals"] = max_goals + + if "role" in data: + role = data["role"] + if role not in ["user", "admin"]: + return jsonify({"success": False, "message": "Invalid role"}), 400 + updates["role"] = role + + database.update_user(user_id, **updates) + return jsonify({"success": True}) + + +if __name__ == "__main__": + app.run(host=config.HOST, port=config.PORT, debug=config.DEBUG) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..af26dd9 --- /dev/null +++ b/auth.py @@ -0,0 +1,45 @@ +from functools import wraps +from flask import session, redirect, url_for, jsonify +import bcrypt +import database + + +def hash_password(password): + return bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + + +def check_password(password, password_hash): + return bcrypt.checkpw( + password.encode("utf-8"), + password_hash.encode("utf-8") + ) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if "user_id" not in session: + return jsonify({"success": False, "message": "Not authenticated"}), 401 + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if "user_id" not in session: + return jsonify({"success": False, "message": "Not authenticated"}), 401 + user = database.get_user_by_id(session["user_id"]) + if not user or user.get("role") != "admin": + return jsonify({"success": False, "message": "Admin access required"}), 403 + return f(*args, **kwargs) + return decorated_function + + +def get_current_user(): + if "user_id" not in session: + return None + return database.get_user_by_id(session["user_id"]) diff --git a/config.py b/config.py new file mode 100644 index 0000000..e8f1ecb --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "data", "db.json") + +DEFAULT_ADMIN_USERNAME = "admin" +DEFAULT_ADMIN_PASSWORD = "admin123" + +DEFAULT_MAX_GOALS = 5 + +SECRET_KEY = "goals-breakdown-secret-key-change-in-production" +DEBUG = True +HOST = "0.0.0.0" +PORT = 5000 diff --git a/database.py b/database.py new file mode 100644 index 0000000..2d41133 --- /dev/null +++ b/database.py @@ -0,0 +1,137 @@ +import os +from tinydb import TinyDB, Query +from tinydb.operations import increment +import config + +os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True) + +db = TinyDB(config.DB_PATH) + +users_table = db.table("users") +goals_table = db.table("goals") +tasks_table = db.table("tasks") + +User = Query() +Goal = Query() +Task = Query() + + +def init_db(): + if users_table.count(User.role == "admin") == 0: + import bcrypt + password_hash = bcrypt.hashpw( + config.DEFAULT_ADMIN_PASSWORD.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + users_table.insert({ + "username": config.DEFAULT_ADMIN_USERNAME, + "password_hash": password_hash, + "role": "admin", + "max_goals": 100 + }) + + +def get_user_by_username(username): + result = users_table.search(User.username == username) + return result[0] if result else None + + +def get_user_by_id(user_id): + return users_table.get(doc_id=user_id) + + +def create_user(username, password_hash, role="user", max_goals=None): + if max_goals is None: + max_goals = config.DEFAULT_MAX_GOALS + doc_id = users_table.insert({ + "username": username, + "password_hash": password_hash, + "role": role, + "max_goals": max_goals + }) + return users_table.get(doc_id=doc_id) + + +def update_user(user_id, **kwargs): + users_table.update(kwargs, doc_ids=[user_id]) + + +def get_all_users(): + return users_table.all() + + +def get_goals_by_user(user_id): + return goals_table.search(Goal.user_id == user_id) + + +def get_goal_by_id(goal_id): + return goals_table.get(doc_id=goal_id) + + +def create_goal(user_id, title): + doc_id = goals_table.insert({ + "user_id": user_id, + "title": title, + "activated": True + }) + return goals_table.get(doc_id=doc_id) + + +def update_goal(goal_id, **kwargs): + goals_table.update(kwargs, doc_ids=[goal_id]) + + +def delete_goal(goal_id): + tasks = tasks_table.search(Task.goal_id == goal_id) + for task in tasks: + tasks_table.remove(doc_ids=[task.doc_id]) + goals_table.remove(doc_ids=[goal_id]) + + +def count_goals_by_user(user_id): + return len(goals_table.search(Goal.user_id == user_id)) + + +def get_tasks_by_goal(goal_id): + return tasks_table.search(Task.goal_id == goal_id) + + +def get_task_by_id(task_id): + return tasks_table.get(doc_id=task_id) + + +def create_task(goal_id, title, desc="", status="todo", order=None): + if order is None: + existing_tasks = tasks_table.search(Task.goal_id == goal_id) + unfinished = [t for t in existing_tasks if t.get("status") != "done"] + order = max([t.get("order", 0) for t in unfinished], default=0) + 1.0 + + doc_id = tasks_table.insert({ + "goal_id": goal_id, + "title": title, + "desc": desc, + "status": status, + "start_time": None, + "finished_time": None, + "order": order + }) + return tasks_table.get(doc_id=doc_id) + + +def update_task(task_id, **kwargs): + tasks_table.update(kwargs, doc_ids=[task_id]) + + +def delete_task(task_id): + tasks_table.remove(doc_ids=[task_id]) + + +def get_tasks_sorted(goal_id): + all_tasks = tasks_table.search(Task.goal_id == goal_id) + unfinished = [t for t in all_tasks if t.get("status") != "done"] + finished = [t for t in all_tasks if t.get("status") == "done"] + + unfinished.sort(key=lambda t: t.get("order", 0)) + finished.sort(key=lambda t: t.get("finished_time", ""), reverse=True) + + return unfinished + finished diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92831f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "goalsbreakdown" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.13" +dependencies = [ + "bcrypt>=5.0.0", + "flask>=3.1.3", + "tinydb>=4.8.2", +] diff --git a/static/css/goals.css b/static/css/goals.css new file mode 100644 index 0000000..ec47f77 --- /dev/null +++ b/static/css/goals.css @@ -0,0 +1,130 @@ +.goals-page { + padding: 1rem; +} + +.goals-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.goals-header h1 { + color: #2c3e50; +} + +.goals-list { + display: grid; + gap: 1rem; +} + +.goal-card { + background: white; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + display: flex; + justify-content: space-between; + align-items: center; + transition: transform 0.2s, box-shadow 0.2s; +} + +.goal-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.goal-info { + flex: 1; +} + +.goal-title { + font-size: 1.2rem; + font-weight: 600; + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.goal-status { + font-size: 0.9rem; + color: #7f8c8d; +} + +.goal-status.active { + color: #27ae60; +} + +.goal-status.inactive { + color: #e74c3c; +} + +.goal-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.toggle-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.toggle-btn.activate { + background-color: #27ae60; + color: white; +} + +.toggle-btn.activate:hover { + background-color: #229954; +} + +.toggle-btn.deactivate { + background-color: #f39c12; + color: white; +} + +.toggle-btn.deactivate:hover { + background-color: #e67e22; +} + +.edit-btn { + padding: 0.5rem 1rem; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.edit-btn:hover { + background-color: #2980b9; +} + +.delete-btn { + padding: 0.5rem 1rem; + background-color: #e74c3c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.delete-btn:hover { + background-color: #c0392b; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: #7f8c8d; +} + +.empty-state h3 { + margin-bottom: 1rem; +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..aaa6210 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,253 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.navbar { + background-color: #2c3e50; + color: white; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.nav-brand { + font-size: 1.5rem; + font-weight: bold; +} + +.nav-links { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-link { + color: white; + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: 4px; + transition: background-color 0.2s; +} + +.nav-link:hover { + background-color: #34495e; +} + +.nav-user { + color: #bdc3c7; + font-size: 0.9rem; +} + +.nav-btn { + background-color: #e74c3c; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.nav-btn:hover { + background-color: #c0392b; +} + +.container { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +.auth-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.auth-container { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + width: 100%; + max-width: 400px; +} + +.auth-container h1 { + text-align: center; + margin-bottom: 1.5rem; + color: #2c3e50; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; +} + +.btn-primary { + width: 100%; + padding: 0.75rem; + background-color: #667eea; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary:hover { + background-color: #5568d3; +} + +.btn-secondary { + padding: 0.5rem 1rem; + background-color: #95a5a6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-secondary:hover { + background-color: #7f8c8d; +} + +.btn-danger { + padding: 0.5rem 1rem; + background-color: #e74c3c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-success { + padding: 0.5rem 1rem; + background-color: #27ae60; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-success:hover { + background-color: #229954; +} + +.error-message { + color: #e74c3c; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.auth-link { + text-align: center; + margin-top: 1rem; + color: #666; +} + +.auth-link a { + color: #667eea; + text-decoration: none; +} + +.auth-link a:hover { + text-decoration: underline; +} + +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.modal-header h2 { + color: #2c3e50; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; +} + +.modal-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} diff --git a/static/css/tasks.css b/static/css/tasks.css new file mode 100644 index 0000000..f01f9ed --- /dev/null +++ b/static/css/tasks.css @@ -0,0 +1,266 @@ +.tasks-page { + padding: 1rem; +} + +.tasks-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.tasks-header h1 { + color: #2c3e50; +} + +.goal-selector-container { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.goal-selector-container label { + font-weight: 500; + color: #555; +} + +.goal-selector-container select { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + min-width: 200px; +} + +.tasks-container { + display: flex; + gap: 2rem; + margin-bottom: 2rem; +} + +.scroll-view { + flex: 1; + height: 300px; + overflow-y: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.tasks-list { + padding: 120px 1rem; +} + +.task-item { + padding: 0.75rem 1rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + border: 2px solid transparent; + background: #f8f9fa; + min-height: 60px; + margin-bottom: 0.5rem; + opacity: 0.5; + transform: scale(0.95); +} + +.task-item:last-child { + margin-bottom: 0; +} + +.task-item.in-focus { + opacity: 1; + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.task-item.in-focus.doing { + background: #fff3cd; + border-color: #ffc107; +} + +.task-item.in-focus.todo { + background: #f8f9fa; + border-color: #667eea; +} + +.task-item.in-focus.pending { + background: #e7f3ff; + border-color: #2196F3; +} + +.task-item.in-focus.done { + background: #d4edda; + border-color: #28a745; +} + +.task-item:hover { + background: #e9ecef; +} + +.task-item.doing { + background: #fff3cd; + border-color: #ffc107; + transform: scale(1.02); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.task-item.todo { + background: #f8f9fa; +} + +.task-item.pending { + background: #e7f3ff; + border-color: #2196F3; +} + +.task-item.done { + background: #d4edda; + border-color: #28a745; + opacity: 0.7; +} + +.task-title { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.task-status-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.task-status-badge.todo { + background: #6c757d; + color: white; +} + +.task-status-badge.doing { + background: #ffc107; + color: #333; +} + +.task-status-badge.pending { + background: #2196F3; + color: white; +} + +.task-status-badge.done { + background: #28a745; + color: white; +} + +.task-item.sortable-ghost { + opacity: 0.4; +} + +.task-item.sortable-chosen { + box-shadow: 0 8px 16px rgba(0,0,0,0.2); + opacity: 1; +} + +.finished-section { + flex: 0 0 300px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 1rem; + max-height: 600px; + overflow-y: auto; +} + +.finished-section h2 { + color: #2c3e50; + margin-bottom: 1rem; + font-size: 1.2rem; +} + +.finished-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.finished-item { + padding: 0.75rem; + background: #f8f9fa; + border-radius: 4px; + border-left: 3px solid #28a745; +} + +.finished-item-title { + font-weight: 500; + margin-bottom: 0.25rem; +} + +.finished-item-time { + font-size: 0.8rem; + color: #6c757d; +} + +.side-panel { + position: fixed; + top: 0; + right: -400px; + width: 400px; + height: 100%; + background: white; + box-shadow: -2px 0 8px rgba(0,0,0,0.1); + transition: right 0.3s ease; + z-index: 1000; + display: flex; + flex-direction: column; +} + +.side-panel.active { + right: 0; +} + +.side-panel-header { + padding: 1rem; + border-bottom: 1px solid #ddd; + display: flex; + justify-content: space-between; + align-items: center; +} + +.side-panel-header h2 { + color: #2c3e50; + font-size: 1.2rem; +} + +.side-panel-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; +} + +.side-panel-content { + padding: 1rem; + flex: 1; + overflow-y: auto; +} + +.side-panel-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +#create-task-btn { + margin-top: 1rem; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: #7f8c8d; +} diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..a33a112 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,45 @@ +const API_BASE = ""; + +async function apiRequest(endpoint, options = {}) { + const url = API_BASE + endpoint; + const config = { + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + ...options, + }; + + if (options.body && typeof options.body === "object") { + config.body = JSON.stringify(options.body); + } + + const response = await fetch(url, config); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Request failed"); + } + + return data; +} + +async function get(endpoint) { + return apiRequest(endpoint, { method: "GET" }); +} + +async function post(endpoint, body) { + return apiRequest(endpoint, { method: "POST", body }); +} + +async function put(endpoint, body) { + return apiRequest(endpoint, { method: "PUT", body }); +} + +async function del(endpoint) { + return apiRequest(endpoint, { method: "DELETE" }); +} + +async function patch(endpoint, body) { + return apiRequest(endpoint, { method: "PATCH", body }); +} diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..1e4f0b8 --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,92 @@ +async function checkAuth() { + try { + const user = await get("/api/auth/me"); + const userInfo = document.getElementById("user-info"); + if (userInfo) { + userInfo.textContent = user.username; + } + const adminLink = document.getElementById("admin-link"); + if (adminLink && user.role === "admin") { + adminLink.style.display = "inline"; + } + return user; + } catch (error) { + window.location.href = "/login"; + return null; + } +} + +async function handleLogin(event) { + event.preventDefault(); + const form = event.target; + const errorMessage = document.getElementById("error-message"); + errorMessage.textContent = ""; + + const username = form.username.value.trim(); + const password = form.password.value; + + try { + await post("/api/auth/login", { username, password }); + window.location.href = "/goals"; + } catch (error) { + errorMessage.textContent = error.message; + } +} + +async function handleRegister(event) { + event.preventDefault(); + const form = event.target; + const errorMessage = document.getElementById("error-message"); + errorMessage.textContent = ""; + + const username = form.username.value.trim(); + const password = form.password.value; + const confirmPassword = form["confirm-password"].value; + + if (password !== confirmPassword) { + errorMessage.textContent = "Passwords do not match"; + return; + } + + if (password.length < 6) { + errorMessage.textContent = "Password must be at least 6 characters"; + return; + } + + try { + await post("/api/auth/register", { username, password }); + window.location.href = "/goals"; + } catch (error) { + errorMessage.textContent = error.message; + } +} + +async function handleLogout() { + try { + await post("/api/auth/logout"); + window.location.href = "/login"; + } catch (error) { + console.error("Logout failed:", error); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const loginForm = document.getElementById("login-form"); + if (loginForm) { + loginForm.addEventListener("submit", handleLogin); + } + + const registerForm = document.getElementById("register-form"); + if (registerForm) { + registerForm.addEventListener("submit", handleRegister); + } + + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", handleLogout); + } + + if (!document.body.classList.contains("auth-page")) { + checkAuth(); + } +}); diff --git a/static/js/goals.js b/static/js/goals.js new file mode 100644 index 0000000..96edbe4 --- /dev/null +++ b/static/js/goals.js @@ -0,0 +1,152 @@ +let goals = []; +let deleteGoalId = null; + +async function loadGoals() { + try { + goals = await get("/api/goals"); + renderGoals(); + } catch (error) { + console.error("Failed to load goals:", error); + } +} + +function renderGoals() { + const container = document.getElementById("goals-list"); + + if (goals.length === 0) { + container.innerHTML = ` +
+

No goals yet

+

Create your first goal to get started!

+
+ `; + return; + } + + container.innerHTML = goals.map(goal => ` +
+
+
${escapeHtml(goal.title)}
+
+ ${goal.activated ? 'Active' : 'Inactive'} +
+
+
+ + + +
+
+ `).join(""); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function openModal(goal = null) { + const modal = document.getElementById("goal-modal"); + const modalTitle = document.getElementById("modal-title"); + const goalId = document.getElementById("goal-id"); + const goalTitle = document.getElementById("goal-title"); + const error = document.getElementById("goal-error"); + + error.textContent = ""; + + if (goal) { + modalTitle.textContent = "Edit Goal"; + goalId.value = goal.doc_id; + goalTitle.value = goal.title; + } else { + modalTitle.textContent = "Create Goal"; + goalId.value = ""; + goalTitle.value = ""; + } + + modal.classList.add("active"); +} + +function closeModal() { + document.getElementById("goal-modal").classList.remove("active"); +} + +async function handleGoalSubmit(event) { + event.preventDefault(); + const error = document.getElementById("goal-error"); + error.textContent = ""; + + const goalId = document.getElementById("goal-id").value; + const title = document.getElementById("goal-title").value.trim(); + + if (!title) { + error.textContent = "Title is required"; + return; + } + + try { + if (goalId) { + await put(`/api/goals/${goalId}`, { title }); + } else { + await post("/api/goals", { title }); + } + closeModal(); + await loadGoals(); + } catch (err) { + error.textContent = err.message; + } +} + +async function toggleGoal(goalId) { + try { + await patch(`/api/goals/${goalId}/toggle`, {}); + await loadGoals(); + } catch (error) { + console.error("Failed to toggle goal:", error); + } +} + +function editGoal(goalId) { + const goal = goals.find(g => g.id === goalId); + if (goal) { + openModal(goal); + } +} + +function confirmDelete(goalId) { + deleteGoalId = goalId; + document.getElementById("delete-confirm-modal").classList.add("active"); +} + +function closeDeleteModal() { + document.getElementById("delete-confirm-modal").classList.remove("active"); + deleteGoalId = null; +} + +async function handleDelete() { + if (!deleteGoalId) return; + + try { + await del(`/api/goals/${deleteGoalId}`); + closeDeleteModal(); + await loadGoals(); + } catch (error) { + console.error("Failed to delete goal:", error); + } +} + +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("create-goal-btn").addEventListener("click", () => openModal()); + document.getElementById("modal-close").addEventListener("click", closeModal); + document.getElementById("modal-cancel").addEventListener("click", closeModal); + document.getElementById("goal-form").addEventListener("submit", handleGoalSubmit); + document.getElementById("delete-modal-close").addEventListener("click", closeDeleteModal); + document.getElementById("cancel-delete").addEventListener("click", closeDeleteModal); + document.getElementById("confirm-delete").addEventListener("click", handleDelete); + + loadGoals(); +}); diff --git a/static/js/tasks.js b/static/js/tasks.js new file mode 100644 index 0000000..0f1f29c --- /dev/null +++ b/static/js/tasks.js @@ -0,0 +1,301 @@ +let goals = []; +let tasks = []; +let finishedTasks = []; +let selectedGoalId = null; +let selectedTaskId = null; +let sortableInstance = null; + +async function loadGoals() { + try { + goals = await get("/api/goals"); + const selector = document.getElementById("goal-selector"); + const activatedGoals = goals.filter(g => g.activated); + + selector.innerHTML = activatedGoals.map(goal => + `` + ).join(""); + + if (activatedGoals.length > 0) { + selectedGoalId = activatedGoals[0].id; + selector.value = selectedGoalId; + await loadTasks(); + } + } catch (error) { + console.error("Failed to load goals:", error); + } +} + +async function loadTasks() { + if (!selectedGoalId) return; + + try { + const allTasks = await get(`/api/tasks?goal_id=${selectedGoalId}`); + tasks = allTasks.filter(t => t.status !== "done"); + finishedTasks = allTasks.filter(t => t.status === "done"); + + renderTasks(); + renderFinishedTasks(); + initSortable(); + initScrollFocus(); + + const doingTask = tasks.find(t => t.status === "doing"); + if (doingTask) { + scrollToTask(doingTask.id); + } + } catch (error) { + console.error("Failed to load tasks:", error); + } +} + +function renderTasks() { + const container = document.getElementById("tasks-list"); + + if (tasks.length === 0) { + container.innerHTML = ` +
+

No tasks yet

+

Create your first task for this goal!

+
+ `; + return; + } + + container.innerHTML = tasks.map(task => ` +
+
${escapeHtml(task.title)}
+ ${task.status} +
+ `).join(""); +} + +function renderFinishedTasks() { + const container = document.getElementById("finished-list"); + + if (finishedTasks.length === 0) { + container.innerHTML = '

No completed tasks yet

'; + return; + } + + container.innerHTML = finishedTasks.map(task => ` +
+
${escapeHtml(task.title)}
+
Completed: ${formatTime(task.finished_time)}
+
+ `).join(""); +} + +function initSortable() { + const container = document.getElementById("tasks-list"); + + if (sortableInstance) { + sortableInstance.destroy(); + } + + sortableInstance = Sortable.create(container, { + animation: 150, + ghostClass: "sortable-ghost", + chosenClass: "sortable-chosen", + filter: ".task-item.done", + onEnd: async function(evt) { + const taskId = evt.item.dataset.taskId; + const newIndex = evt.newIndex; + + const prevTask = tasks[newIndex - 1]; + const nextTask = tasks[newIndex + 1]; + + let prevOrder = prevTask ? prevTask.order : 0; + let nextOrder = nextTask ? nextTask.order : prevOrder + 2; + + const newOrder = (prevOrder + nextOrder) / 2; + + try { + await patch(`/api/tasks/${taskId}/order`, { order: newOrder }); + await loadTasks(); + } catch (error) { + console.error("Failed to update order:", error); + await loadTasks(); + } + } + }); +} + +function scrollToTask(taskId) { + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (taskElement) { + const scrollView = document.getElementById("scroll-view"); + const taskTop = taskElement.offsetTop; + const scrollViewHeight = scrollView.clientHeight; + const taskHeight = taskElement.offsetHeight; + scrollView.scrollTop = taskTop - (scrollViewHeight / 2) + (taskHeight / 2); + } +} + +function initScrollFocus() { + const scrollView = document.getElementById("scroll-view"); + + scrollView.removeEventListener("scroll", handleScrollFocus); + scrollView.addEventListener("scroll", handleScrollFocus); + + handleScrollFocus(); +} + +function handleScrollFocus() { + const scrollView = document.getElementById("scroll-view"); + const taskItems = document.querySelectorAll(".task-item"); + + const scrollViewRect = scrollView.getBoundingClientRect(); + const focusCenter = scrollViewRect.top + scrollViewRect.height / 2; + + let closestItem = null; + let closestDistance = Infinity; + + taskItems.forEach(item => { + const itemRect = item.getBoundingClientRect(); + const itemCenter = itemRect.top + itemRect.height / 2; + const distance = Math.abs(itemCenter - focusCenter); + + item.classList.remove("in-focus"); + + if (distance < closestDistance) { + closestDistance = distance; + closestItem = item; + } + }); + + if (closestItem) { + closestItem.classList.add("in-focus"); + } +} + +function selectTask(taskId) { + selectedTaskId = taskId; + const task = [...tasks, ...finishedTasks].find(t => t.id === taskId); + + if (!task) return; + + document.getElementById("edit-task-title").value = task.title; + document.getElementById("edit-task-desc").value = task.desc || ""; + document.getElementById("edit-task-status").value = task.status; + document.getElementById("side-panel-error").textContent = ""; + + document.getElementById("side-panel").classList.add("active"); +} + +function closeSidePanel() { + document.getElementById("side-panel").classList.remove("active"); + selectedTaskId = null; +} + +async function saveTask() { + if (!selectedTaskId) return; + + const error = document.getElementById("side-panel-error"); + error.textContent = ""; + + const title = document.getElementById("edit-task-title").value.trim(); + const desc = document.getElementById("edit-task-desc").value; + const status = document.getElementById("edit-task-status").value; + + if (!title) { + error.textContent = "Title is required"; + return; + } + + try { + await put(`/api/tasks/${selectedTaskId}`, { title, desc }); + + const currentTask = [...tasks, ...finishedTasks].find(t => t.id === selectedTaskId); + if (status !== currentTask?.status) { + await patch(`/api/tasks/${selectedTaskId}/status`, { status }); + } + + await loadTasks(); + } catch (err) { + error.textContent = err.message; + } +} + +async function deleteTask() { + if (!selectedTaskId) return; + + if (!confirm("Are you sure you want to delete this task?")) return; + + try { + await del(`/api/tasks/${selectedTaskId}`); + closeSidePanel(); + await loadTasks(); + } catch (error) { + console.error("Failed to delete task:", error); + } +} + +function openTaskModal() { + document.getElementById("task-id").value = ""; + document.getElementById("task-title").value = ""; + document.getElementById("task-desc").value = ""; + document.getElementById("task-error").textContent = ""; + document.getElementById("task-modal-title").textContent = "Create Task"; + document.getElementById("task-modal").classList.add("active"); +} + +function closeTaskModal() { + document.getElementById("task-modal").classList.remove("active"); +} + +async function handleTaskSubmit(event) { + event.preventDefault(); + const error = document.getElementById("task-error"); + error.textContent = ""; + + const title = document.getElementById("task-title").value.trim(); + const desc = document.getElementById("task-desc").value; + + if (!title) { + error.textContent = "Title is required"; + return; + } + + const goalId = parseInt(document.getElementById("goal-selector").value); + if (!goalId) { + error.textContent = "Please select a goal first"; + return; + } + + try { + await post("/api/tasks", { goal_id: goalId, title, desc }); + closeTaskModal(); + await loadTasks(); + } catch (err) { + error.textContent = err.message; + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatTime(isoString) { + if (!isoString) return ""; + const date = new Date(isoString); + return date.toLocaleString(); +} + +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("goal-selector").addEventListener("change", (e) => { + selectedGoalId = parseInt(e.target.value); + loadTasks(); + }); + + document.getElementById("create-task-btn").addEventListener("click", openTaskModal); + document.getElementById("task-modal-close").addEventListener("click", closeTaskModal); + document.getElementById("task-modal-cancel").addEventListener("click", closeTaskModal); + document.getElementById("task-form").addEventListener("submit", handleTaskSubmit); + + document.getElementById("side-panel-close").addEventListener("click", closeSidePanel); + document.getElementById("save-task-btn").addEventListener("click", saveTask); + document.getElementById("delete-task-btn").addEventListener("click", deleteTask); + + loadGoals(); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..128f06c --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} + +{% block title %}Admin - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Admin Panel

+
+
+ + + + + + + + + + + +
User IDUsernameRoleMax GoalsActions
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f8d4985 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,28 @@ + + + + + + {% block title %}GoalsBreakDown{% endblock %} + + {% block extra_css %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + diff --git a/templates/goals.html b/templates/goals.html new file mode 100644 index 0000000..49e3eb1 --- /dev/null +++ b/templates/goals.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}Goals - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

My Goals

+ +
+
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ab9d874 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,29 @@ + + + + + + Login - GoalsBreakDown + + + +
+

Login

+
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..4304751 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,33 @@ + + + + + + Register - GoalsBreakDown + + + +
+

Register

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + diff --git a/templates/tasks.html b/templates/tasks.html new file mode 100644 index 0000000..cce4746 --- /dev/null +++ b/templates/tasks.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}Tasks - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Tasks

+
+ + +
+
+ +
+
+
+
+ +
+

Completed Tasks

+
+
+
+ + +
+ + + +
+
+

Edit Task

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %}