329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""TaskFlow — Flask task management application."""
|
|
import os
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
from flask import Flask, request, redirect, url_for, render_template_string, jsonify
|
|
|
|
app = Flask(__name__)
|
|
|
|
def get_db():
|
|
return psycopg2.connect(
|
|
host=os.environ.get("POSTGRES_HOST"),
|
|
port=int(os.environ.get("POSTGRES_PORT", 5432)),
|
|
user=os.environ["POSTGRES_USER"],
|
|
password=os.environ["POSTGRES_PASSWORD"],
|
|
dbname=os.environ["POSTGRES_DB"],
|
|
sslmode="require"
|
|
)
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
try:
|
|
conn = get_db(); conn.close()
|
|
return jsonify({"status": "ok"}), 200
|
|
except Exception as e:
|
|
return jsonify({"status": "error", "detail": str(e)}), 500
|
|
|
|
TEMPLATE = """
|
|
<!doctype html><html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>TaskFlow</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--bg:#0f1117;
|
|
--surface:#1a1d27;
|
|
--surface2:#21253a;
|
|
--surface3:#272b3e;
|
|
--border:#2e3350;
|
|
--border2:#3a3f5c;
|
|
--text:#e8eaf6;
|
|
--muted:#7b82a8;
|
|
--accent:#5b6af0;
|
|
--accent2:#7b8af8;
|
|
--todo:#7b82a8;
|
|
--prog:#5b6af0;
|
|
--done:#34d399;
|
|
--danger:#f87171;
|
|
--yellow:#fbbf24;
|
|
}
|
|
html{scroll-behavior:smooth}
|
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
|
|
/* Header */
|
|
header{
|
|
background:var(--surface);
|
|
border-bottom:1px solid var(--border);
|
|
padding:1rem 2rem;
|
|
display:flex;align-items:center;gap:1rem;
|
|
position:sticky;top:0;z-index:100;
|
|
}
|
|
.logo{display:flex;align-items:center;gap:.6rem}
|
|
.logo-icon{
|
|
width:32px;height:32px;border-radius:8px;
|
|
background:linear-gradient(135deg,var(--accent),var(--accent2));
|
|
display:flex;align-items:center;justify-content:center;font-size:1rem;
|
|
}
|
|
.logo h1{font-size:1.1rem;font-weight:600;letter-spacing:-.01em}
|
|
.header-right{margin-left:auto;display:flex;align-items:center;gap:1rem}
|
|
.stat-pill{
|
|
font-size:.72rem;font-weight:500;
|
|
padding:.3rem .75rem;border-radius:999px;
|
|
background:var(--surface2);border:1px solid var(--border);
|
|
color:var(--muted);
|
|
}
|
|
.stat-pill span{color:var(--text);font-weight:600}
|
|
|
|
/* Main */
|
|
main{padding:2rem;max-width:1280px;margin:0 auto}
|
|
|
|
/* Add task section */
|
|
.add-section{
|
|
background:var(--surface);
|
|
border:1px solid var(--border);
|
|
border-radius:16px;
|
|
padding:1.5rem;
|
|
margin-bottom:2rem;
|
|
}
|
|
.add-section-title{
|
|
font-size:.8rem;font-weight:600;text-transform:uppercase;
|
|
letter-spacing:.08em;color:var(--muted);
|
|
margin-bottom:1.25rem;display:flex;align-items:center;gap:.5rem;
|
|
}
|
|
.add-section-title::before{
|
|
content:'';display:block;width:3px;height:.85rem;
|
|
background:var(--accent);border-radius:2px;
|
|
}
|
|
.add-form{display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-start}
|
|
.add-form .field{flex:1;min-width:180px}
|
|
.add-form .field.desc{flex:2;min-width:240px}
|
|
.add-form label{
|
|
display:block;font-size:.7rem;font-weight:500;
|
|
color:var(--muted);margin-bottom:.35rem;
|
|
}
|
|
.add-form input,.add-form textarea{
|
|
width:100%;padding:.6rem .875rem;
|
|
background:var(--surface2);
|
|
border:1px solid var(--border);
|
|
border-radius:10px;
|
|
font-size:.85rem;font-family:inherit;
|
|
color:var(--text);outline:none;
|
|
transition:border-color .15s,box-shadow .15s;
|
|
}
|
|
.add-form input:focus,.add-form textarea:focus{
|
|
border-color:var(--accent);
|
|
box-shadow:0 0 0 3px rgba(91,106,240,.18);
|
|
}
|
|
.add-form input::placeholder,.add-form textarea::placeholder{color:var(--muted);opacity:.6}
|
|
.add-form textarea{resize:none;height:42px}
|
|
.btn-add{
|
|
align-self:flex-end;
|
|
padding:.6rem 1.4rem;
|
|
background:var(--accent);
|
|
color:#fff;border:none;border-radius:10px;
|
|
font-size:.85rem;font-weight:600;font-family:inherit;
|
|
cursor:pointer;white-space:nowrap;
|
|
transition:background .15s,transform .1s,box-shadow .15s;
|
|
box-shadow:0 2px 12px rgba(91,106,240,.35);
|
|
}
|
|
.btn-add:hover{background:var(--accent2);box-shadow:0 4px 20px rgba(91,106,240,.45)}
|
|
.btn-add:active{transform:scale(.97)}
|
|
|
|
/* Board */
|
|
.board{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25rem}
|
|
@media(max-width:720px){.board{grid-template-columns:1fr}}
|
|
|
|
.column{
|
|
background:var(--surface);
|
|
border:1px solid var(--border);
|
|
border-radius:16px;
|
|
overflow:hidden;
|
|
display:flex;flex-direction:column;
|
|
}
|
|
.col-header{
|
|
padding:.875rem 1rem;
|
|
border-bottom:1px solid var(--border);
|
|
display:flex;align-items:center;justify-content:space-between;
|
|
}
|
|
.col-title{display:flex;align-items:center;gap:.5rem;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.07em}
|
|
.col-dot{width:8px;height:8px;border-radius:50%}
|
|
.col-todo .col-dot{background:var(--todo)}
|
|
.col-todo .col-title{color:var(--todo)}
|
|
.col-progress .col-dot{background:var(--prog)}
|
|
.col-progress .col-title{color:var(--accent2)}
|
|
.col-done .col-dot{background:var(--done)}
|
|
.col-done .col-title{color:var(--done)}
|
|
.col-count{
|
|
font-size:.68rem;font-weight:600;
|
|
padding:.15rem .5rem;border-radius:999px;
|
|
background:var(--surface2);color:var(--muted);
|
|
border:1px solid var(--border);
|
|
}
|
|
.tasks{padding:.75rem;display:flex;flex-direction:column;gap:.5rem;flex:1}
|
|
|
|
/* Task card */
|
|
.task{
|
|
background:var(--surface2);
|
|
border:1px solid var(--border);
|
|
border-radius:10px;
|
|
padding:.875rem;
|
|
transition:border-color .15s,transform .15s;
|
|
}
|
|
.task:hover{border-color:var(--border2);transform:translateY(-1px)}
|
|
.col-done .task{border-left:2px solid var(--done);opacity:.75}
|
|
.task-title{font-size:.83rem;font-weight:500;line-height:1.4;margin-bottom:.3rem;color:var(--text)}
|
|
.task-desc{font-size:.75rem;color:var(--muted);line-height:1.5;margin-bottom:.625rem}
|
|
.task-footer{display:flex;gap:.375rem;align-items:center}
|
|
.btn{
|
|
display:inline-flex;align-items:center;gap:.25rem;
|
|
padding:.25rem .625rem;border-radius:7px;
|
|
font-size:.72rem;font-weight:500;font-family:inherit;
|
|
border:1px solid;cursor:pointer;
|
|
transition:opacity .15s,background .15s;
|
|
}
|
|
.btn-advance{
|
|
background:rgba(91,106,240,.15);
|
|
border-color:rgba(91,106,240,.4);
|
|
color:var(--accent2);
|
|
}
|
|
.btn-advance:hover{background:rgba(91,106,240,.25)}
|
|
.btn-delete{
|
|
background:transparent;
|
|
border-color:rgba(248,113,113,.25);
|
|
color:var(--danger);
|
|
}
|
|
.btn-delete:hover{background:rgba(248,113,113,.1)}
|
|
.task-date{font-size:.68rem;color:var(--muted);margin-left:auto;opacity:.6}
|
|
|
|
/* Empty */
|
|
.empty{text-align:center;padding:2rem .5rem;color:var(--muted);font-size:.78rem}
|
|
.empty-icon{font-size:1.75rem;margin-bottom:.5rem;opacity:.25}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="logo">
|
|
<div class="logo-icon">📋</div>
|
|
<h1>TaskFlow</h1>
|
|
</div>
|
|
<div class="header-right">
|
|
<span class="stat-pill">To Do <span>{{tasks['todo']|length}}</span></span>
|
|
<span class="stat-pill">In Progress <span>{{tasks['in_progress']|length}}</span></span>
|
|
<span class="stat-pill">Done <span>{{tasks['done']|length}}</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- Add task form at top -->
|
|
<div class="add-section">
|
|
<div class="add-section-title">New Task</div>
|
|
<form method="post" action="/create">
|
|
<div class="add-form">
|
|
<div class="field">
|
|
<label>Title</label>
|
|
<input type="text" name="title" required placeholder="What needs to be done?">
|
|
</div>
|
|
<div class="field desc">
|
|
<label>Description <span style="opacity:.5;font-weight:400">(optional)</span></label>
|
|
<textarea name="description" placeholder="Add more details..."></textarea>
|
|
</div>
|
|
<button type="submit" class="btn-add">+ Add Task</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Kanban board -->
|
|
<div class="board">
|
|
{% for col,label,css,icon in [
|
|
('todo','To Do','col-todo','◯'),
|
|
('in_progress','In Progress','col-progress','▶'),
|
|
('done','Done','col-done','✓')] %}
|
|
<div class="column {{css}}">
|
|
<div class="col-header">
|
|
<div class="col-title">
|
|
<span class="col-dot"></span>{{icon}} {{label}}
|
|
</div>
|
|
<span class="col-count">{{tasks[col]|length}}</span>
|
|
</div>
|
|
<div class="tasks">
|
|
{% if tasks[col] %}
|
|
{% for t in tasks[col] %}
|
|
<div class="task">
|
|
<div class="task-title">{{t.title}}</div>
|
|
{% if t.description %}<div class="task-desc">{{t.description}}</div>{% endif %}
|
|
<div class="task-footer">
|
|
{% if col != 'done' %}
|
|
<form method="post" action="/advance/{{t.id}}">
|
|
<button class="btn btn-advance">▶ Advance</button>
|
|
</form>
|
|
{% endif %}
|
|
<form method="post" action="/delete/{{t.id}}">
|
|
<button class="btn btn-delete">✕</button>
|
|
</form>
|
|
<span class="task-date">{{t.created_at.strftime('%b %d') if t.created_at else ''}}</span>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="empty">
|
|
<div class="empty-icon">▫</div>
|
|
Nothing here yet
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</main>
|
|
</body></html>
|
|
"""
|
|
NEXT_STATUS = {"todo": "in_progress", "in_progress": "done"}
|
|
|
|
@app.route("/")
|
|
def index():
|
|
conn = get_db()
|
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
cur.execute("SELECT * FROM tasks ORDER BY created_at")
|
|
rows = cur.fetchall()
|
|
cur.close(); conn.close()
|
|
tasks = {"todo": [], "in_progress": [], "done": []}
|
|
for row in rows:
|
|
tasks[row["status"]].append(row)
|
|
return render_template_string(TEMPLATE, tasks=tasks)
|
|
|
|
@app.route("/create", methods=["POST"])
|
|
def create():
|
|
title = request.form.get("title", "").strip()
|
|
desc = request.form.get("description", "").strip()
|
|
if title:
|
|
conn = get_db(); cur = conn.cursor()
|
|
cur.execute(
|
|
"INSERT INTO tasks (title, description, status) VALUES (%s, %s, 'todo')",
|
|
(title, desc or None))
|
|
conn.commit(); cur.close(); conn.close()
|
|
return redirect(url_for("index"))
|
|
|
|
@app.route("/advance/<int:task_id>", methods=["POST"])
|
|
def advance(task_id):
|
|
conn = get_db(); cur = conn.cursor()
|
|
cur.execute("SELECT status FROM tasks WHERE id = %s", (task_id,))
|
|
row = cur.fetchone()
|
|
if row and row[0] in NEXT_STATUS:
|
|
cur.execute("UPDATE tasks SET status = %s WHERE id = %s",
|
|
(NEXT_STATUS[row[0]], task_id))
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return redirect(url_for("index"))
|
|
|
|
@app.route("/delete/<int:task_id>", methods=["POST"])
|
|
def delete(task_id):
|
|
conn = get_db(); cur = conn.cursor()
|
|
cur.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
|
|
conn.commit(); cur.close(); conn.close()
|
|
return redirect(url_for("index"))
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=5000, debug=False) |