zkt26/sk1/app.py

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">&#128203;</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">&#43; Add Task</button>
</div>
</form>
</div>
<!-- Kanban board -->
<div class="board">
{% for col,label,css,icon in [
('todo','To Do','col-todo','&#9711;'),
('in_progress','In Progress','col-progress','&#9654;'),
('done','Done','col-done','&#10003;')] %}
<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">&#9654; Advance</button>
</form>
{% endif %}
<form method="post" action="/delete/{{t.id}}">
<button class="btn btn-delete">&#10005;</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">&#9643;</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)