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
This commit is contained in:
commit
f3bffa40cd
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -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
|
||||
101
README.md
Normal file
101
README.md
Normal file
@ -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 <repository-url>
|
||||
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
|
||||
397
app.py
Normal file
397
app.py
Normal file
@ -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/<int:goal_id>", 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/<int:goal_id>", 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/<int:goal_id>/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/<int:task_id>", 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/<int:task_id>", 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/<int:task_id>/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/<int:task_id>/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/<int:user_id>", 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)
|
||||
45
auth.py
Normal file
45
auth.py
Normal file
@ -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"])
|
||||
13
config.py
Normal file
13
config.py
Normal file
@ -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
|
||||
137
database.py
Normal file
137
database.py
Normal file
@ -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
|
||||
10
pyproject.toml
Normal file
10
pyproject.toml
Normal file
@ -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",
|
||||
]
|
||||
130
static/css/goals.css
Normal file
130
static/css/goals.css
Normal file
@ -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;
|
||||
}
|
||||
253
static/css/style.css
Normal file
253
static/css/style.css
Normal file
@ -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;
|
||||
}
|
||||
266
static/css/tasks.css
Normal file
266
static/css/tasks.css
Normal file
@ -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;
|
||||
}
|
||||
45
static/js/api.js
Normal file
45
static/js/api.js
Normal file
@ -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 });
|
||||
}
|
||||
92
static/js/auth.js
Normal file
92
static/js/auth.js
Normal file
@ -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();
|
||||
}
|
||||
});
|
||||
152
static/js/goals.js
Normal file
152
static/js/goals.js
Normal file
@ -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 = `
|
||||
<div class="empty-state">
|
||||
<h3>No goals yet</h3>
|
||||
<p>Create your first goal to get started!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = goals.map(goal => `
|
||||
<div class="goal-card" data-goal-id="${goal.id}">
|
||||
<div class="goal-info">
|
||||
<div class="goal-title">${escapeHtml(goal.title)}</div>
|
||||
<div class="goal-status ${goal.activated ? 'active' : 'inactive'}">
|
||||
${goal.activated ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-actions">
|
||||
<button class="toggle-btn ${goal.activated ? 'deactivate' : 'activate'}"
|
||||
onclick="toggleGoal(${goal.id})">
|
||||
${goal.activated ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button class="edit-btn" onclick="editGoal(${goal.id})">Edit</button>
|
||||
<button class="delete-btn" onclick="confirmDelete(${goal.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).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();
|
||||
});
|
||||
301
static/js/tasks.js
Normal file
301
static/js/tasks.js
Normal file
@ -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 =>
|
||||
`<option value="${goal.id}">${escapeHtml(goal.title)}</option>`
|
||||
).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 = `
|
||||
<div class="empty-state">
|
||||
<h3>No tasks yet</h3>
|
||||
<p>Create your first task for this goal!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tasks.map(task => `
|
||||
<div class="task-item ${task.status}" data-task-id="${task.id}" onclick="selectTask(${task.id})">
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
<span class="task-status-badge ${task.status}">${task.status}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderFinishedTasks() {
|
||||
const container = document.getElementById("finished-list");
|
||||
|
||||
if (finishedTasks.length === 0) {
|
||||
container.innerHTML = '<p style="color: #7f8c8d;">No completed tasks yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = finishedTasks.map(task => `
|
||||
<div class="finished-item" onclick="selectTask(${task.id})">
|
||||
<div class="finished-item-title">${escapeHtml(task.title)}</div>
|
||||
<div class="finished-item-time">Completed: ${formatTime(task.finished_time)}</div>
|
||||
</div>
|
||||
`).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();
|
||||
});
|
||||
165
templates/admin.html
Normal file
165
templates/admin.html
Normal file
@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin - GoalsBreakDown{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.users-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.users-table input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.users-table select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-page">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Max Goals</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let users = [];
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users = await get("/api/admin/users");
|
||||
renderUsers();
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = document.getElementById("users-tbody");
|
||||
tbody.innerHTML = users.map(user => `
|
||||
<tr>
|
||||
<td>${user.user_id}</td>
|
||||
<td>${escapeHtml(user.username)}</td>
|
||||
<td>
|
||||
<select onchange="updateUserRole(${user.user_id}, this.value)">
|
||||
<option value="user" ${user.role === 'user' ? 'selected' : ''}>User</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" value="${user.max_goals}"
|
||||
id="max-goals-${user.user_id}">
|
||||
</td>
|
||||
<td>
|
||||
<button class="save-btn" onclick="saveUser(${user.user_id})">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function saveUser(userId) {
|
||||
const maxGoals = parseInt(document.getElementById(`max-goals-${userId}`).value);
|
||||
|
||||
try {
|
||||
await put(`/api/admin/users/${userId}`, { max_goals });
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserRole(userId, role) {
|
||||
try {
|
||||
await put(`/api/admin/users/${userId}`, { role });
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
28
templates/base.html
Normal file
28
templates/base.html
Normal file
@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}GoalsBreakDown{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">GoalsBreakDown</div>
|
||||
<div class="nav-links">
|
||||
<a href="/goals" class="nav-link">Goals</a>
|
||||
<a href="/tasks" class="nav-link">Tasks</a>
|
||||
<a href="/admin" class="nav-link" id="admin-link" style="display: none;">Admin</a>
|
||||
<span id="user-info" class="nav-user"></span>
|
||||
<button id="logout-btn" class="nav-btn">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='js/api.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
56
templates/goals.html
Normal file
56
templates/goals.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Goals - GoalsBreakDown{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/goals.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="goals-page">
|
||||
<div class="goals-header">
|
||||
<h1>My Goals</h1>
|
||||
<button id="create-goal-btn" class="btn-success">+ Create Goal</button>
|
||||
</div>
|
||||
<div id="goals-list" class="goals-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="goal-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Create Goal</h2>
|
||||
<button class="modal-close" id="modal-close">×</button>
|
||||
</div>
|
||||
<form id="goal-form">
|
||||
<input type="hidden" id="goal-id">
|
||||
<div class="form-group">
|
||||
<label for="goal-title">Goal Title</label>
|
||||
<input type="text" id="goal-title" required>
|
||||
</div>
|
||||
<div id="goal-error" class="error-message"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" id="modal-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="delete-confirm-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Confirm Delete</h2>
|
||||
<button class="modal-close" id="delete-modal-close">×</button>
|
||||
</div>
|
||||
<p>Are you sure you want to delete this goal? All associated tasks will also be deleted.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-danger" id="confirm-delete">Delete</button>
|
||||
<button class="btn-secondary" id="cancel-delete">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/goals.js') }}"></script>
|
||||
{% endblock %}
|
||||
29
templates/login.html
Normal file
29
templates/login.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - GoalsBreakDown</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Login</h1>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div id="error-message" class="error-message"></div>
|
||||
<button type="submit" class="btn-primary">Login</button>
|
||||
</form>
|
||||
<p class="auth-link">Don't have an account? <a href="/register">Register</a></p>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/api.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
templates/register.html
Normal file
33
templates/register.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register - GoalsBreakDown</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Register</h1>
|
||||
<form id="register-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input type="password" id="confirm-password" name="confirm-password" required minlength="6">
|
||||
</div>
|
||||
<div id="error-message" class="error-message"></div>
|
||||
<button type="submit" class="btn-primary">Register</button>
|
||||
</form>
|
||||
<p class="auth-link">Already have an account? <a href="/login">Login</a></p>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/api.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
93
templates/tasks.html
Normal file
93
templates/tasks.html
Normal file
@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tasks - GoalsBreakDown{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tasks.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tasks-page">
|
||||
<div class="tasks-header">
|
||||
<h1>Tasks</h1>
|
||||
<div class="goal-selector-container">
|
||||
<label for="goal-selector">Select Goal:</label>
|
||||
<select id="goal-selector"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tasks-container">
|
||||
<div id="scroll-view" class="scroll-view">
|
||||
<div id="tasks-list" class="tasks-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="finished-section" class="finished-section">
|
||||
<h2>Completed Tasks</h2>
|
||||
<div id="finished-list" class="finished-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="create-task-btn" class="btn-success">+ Create Task</button>
|
||||
</div>
|
||||
|
||||
<div id="task-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="task-modal-title">Create Task</h2>
|
||||
<button class="modal-close" id="task-modal-close">×</button>
|
||||
</div>
|
||||
<form id="task-form">
|
||||
<input type="hidden" id="task-id">
|
||||
<div class="form-group">
|
||||
<label for="task-title">Task Title</label>
|
||||
<input type="text" id="task-title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-desc">Description</label>
|
||||
<textarea id="task-desc" rows="3"></textarea>
|
||||
</div>
|
||||
<div id="task-error" class="error-message"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" id="task-modal-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="side-panel" class="side-panel">
|
||||
<div class="side-panel-header">
|
||||
<h2>Edit Task</h2>
|
||||
<button class="side-panel-close" id="side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="side-panel-content">
|
||||
<div class="form-group">
|
||||
<label for="edit-task-title">Title</label>
|
||||
<input type="text" id="edit-task-title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-task-desc">Description</label>
|
||||
<textarea id="edit-task-desc" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-task-status">Status</label>
|
||||
<select id="edit-task-status">
|
||||
<option value="todo">To Do</option>
|
||||
<option value="doing">Doing</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="side-panel-error" class="error-message"></div>
|
||||
<div class="side-panel-actions">
|
||||
<button class="btn-success" id="save-task-btn">Save</button>
|
||||
<button class="btn-danger" id="delete-task-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/tasks.js') }}"></script>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user