chore: initial repo, copy source from assignment 1
This commit is contained in:
commit
0a58373d86
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
110
backend/app.py
Normal file
110
backend/app.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
"host": os.environ.get("DB_HOST", "db"),
|
||||||
|
"database": os.environ.get("DB_NAME", "taskapp"),
|
||||||
|
"user": os.environ.get("DB_USER", "taskapp"),
|
||||||
|
"password": os.environ.get("DB_PASSWORD", "taskapp123"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Wait for PostgreSQL and create the tasks table if it doesn't exist."""
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print("Database initialized.")
|
||||||
|
return
|
||||||
|
except psycopg2.OperationalError:
|
||||||
|
print(f"Waiting for database... (attempt {attempt + 1}/30)")
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError("Could not connect to the database after 30 attempts.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks", methods=["GET"])
|
||||||
|
def get_tasks():
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM tasks ORDER BY created_at DESC")
|
||||||
|
tasks = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks", methods=["POST"])
|
||||||
|
def create_task():
|
||||||
|
data = request.get_json()
|
||||||
|
title = data.get("title", "").strip()
|
||||||
|
if not title:
|
||||||
|
return jsonify({"error": "Title is required"}), 400
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO tasks (title) VALUES (%s) RETURNING *",
|
||||||
|
(title,),
|
||||||
|
)
|
||||||
|
task = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(task), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks/<int:task_id>", methods=["PUT"])
|
||||||
|
def update_task(task_id):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE tasks SET completed = NOT completed WHERE id = %s RETURNING *",
|
||||||
|
(task_id,),
|
||||||
|
)
|
||||||
|
task = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if task is None:
|
||||||
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
return jsonify(task)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks/<int:task_id>", methods=["DELETE"])
|
||||||
|
def delete_task(task_id):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM tasks WHERE id = %s RETURNING id", (task_id,))
|
||||||
|
deleted = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if deleted is None:
|
||||||
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
return jsonify({"result": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
app.run(host="0.0.0.0", port=5000)
|
||||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask==3.1.1
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
gunicorn==23.0.0
|
||||||
72
frontend/app.js
Normal file
72
frontend/app.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const taskList = document.getElementById("task-list");
|
||||||
|
const taskForm = document.getElementById("task-form");
|
||||||
|
const taskInput = document.getElementById("task-input");
|
||||||
|
const emptyMsg = document.getElementById("empty-msg");
|
||||||
|
|
||||||
|
async function fetchTasks() {
|
||||||
|
const res = await fetch("/api/tasks");
|
||||||
|
const tasks = await res.json();
|
||||||
|
renderTasks(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks(tasks) {
|
||||||
|
taskList.innerHTML = "";
|
||||||
|
emptyMsg.style.display = tasks.length === 0 ? "block" : "none";
|
||||||
|
|
||||||
|
tasks.forEach(function (task) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "task-item" + (task.completed ? " completed" : "");
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.className = "task-checkbox";
|
||||||
|
checkbox.checked = task.completed;
|
||||||
|
checkbox.addEventListener("change", function () {
|
||||||
|
toggleTask(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = document.createElement("span");
|
||||||
|
title.className = "task-title";
|
||||||
|
title.textContent = task.title;
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "task-delete";
|
||||||
|
deleteBtn.textContent = "\u00D7";
|
||||||
|
deleteBtn.title = "Delete task";
|
||||||
|
deleteBtn.addEventListener("click", function () {
|
||||||
|
deleteTask(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(checkbox);
|
||||||
|
li.appendChild(title);
|
||||||
|
li.appendChild(deleteBtn);
|
||||||
|
taskList.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
taskForm.addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = taskInput.value.trim();
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: title }),
|
||||||
|
});
|
||||||
|
|
||||||
|
taskInput.value = "";
|
||||||
|
fetchTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleTask(id) {
|
||||||
|
await fetch("/api/tasks/" + id, { method: "PUT" });
|
||||||
|
fetchTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(id) {
|
||||||
|
await fetch("/api/tasks/" + id, { method: "DELETE" });
|
||||||
|
fetchTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTasks();
|
||||||
21
frontend/index.html
Normal file
21
frontend/index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Task Manager</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Task Manager</h1>
|
||||||
|
<form id="task-form">
|
||||||
|
<input type="text" id="task-input" placeholder="Enter a new task..." required>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
<ul id="task-list"></ul>
|
||||||
|
<p id="empty-msg" class="empty">No tasks yet. Add one above!</p>
|
||||||
|
</div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
120
frontend/style.css
Normal file
120
frontend/style.css
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-input:focus {
|
||||||
|
border-color: #4a6cf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-form button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #4a6cf7;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-form button:hover {
|
||||||
|
background: #3a5ce5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.completed .task-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #4a6cf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user