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