feat(notes): add notes feature with CRUD operations and UI
- Implement notes database schema and API endpoints - Add notes page with filtering, search, and markdown support - Persist selected goal and task preferences for better UX - Include responsive design and mobile-friendly layout
This commit is contained in:
parent
594bf65715
commit
3c325bdb0f
95
app.py
95
app.py
@ -48,6 +48,12 @@ def admin_page():
|
|||||||
return render_template("admin.html")
|
return render_template("admin.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/notes")
|
||||||
|
@auth.login_required
|
||||||
|
def notes_page():
|
||||||
|
return render_template("notes.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/auth/register", methods=["POST"])
|
@app.route("/api/auth/register", methods=["POST"])
|
||||||
def api_register():
|
def api_register():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@ -99,7 +105,8 @@ def api_login():
|
|||||||
"user_id": user["id"],
|
"user_id": user["id"],
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"max_goals": user["max_goals"]
|
"max_goals": user["max_goals"],
|
||||||
|
"selected_goal_id": user.get("selected_goal_id")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -121,10 +128,20 @@ def api_me():
|
|||||||
"user_id": user["id"],
|
"user_id": user["id"],
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"max_goals": user["max_goals"]
|
"max_goals": user["max_goals"],
|
||||||
|
"selected_goal_id": user.get("selected_goal_id")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/user/selected-goal", methods=["PATCH"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_set_selected_goal():
|
||||||
|
data = request.get_json()
|
||||||
|
goal_id = data.get("goal_id")
|
||||||
|
database.update_user(session["user_id"], selected_goal_id=goal_id)
|
||||||
|
return jsonify({"success": True, "selected_goal_id": goal_id})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/goals", methods=["GET"])
|
@app.route("/api/goals", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def api_get_goals():
|
def api_get_goals():
|
||||||
@ -206,6 +223,19 @@ def api_toggle_goal(goal_id):
|
|||||||
return jsonify({"success": True, "activated": new_status})
|
return jsonify({"success": True, "activated": new_status})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/goals/<int:goal_id>/selected-task", methods=["PATCH"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_set_selected_task(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()
|
||||||
|
task_id = data.get("task_id")
|
||||||
|
database.update_goal(goal_id, selected_task_id=task_id)
|
||||||
|
return jsonify({"success": True, "selected_task_id": task_id})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/tasks", methods=["GET"])
|
@app.route("/api/tasks", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def api_get_tasks():
|
def api_get_tasks():
|
||||||
@ -352,6 +382,67 @@ def api_update_task_order(task_id):
|
|||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/notes", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_get_notes():
|
||||||
|
goal_id = request.args.get("goal_id", type=int)
|
||||||
|
search = request.args.get("search", "").strip() or None
|
||||||
|
notes = database.get_notes(session["user_id"], goal_id, search)
|
||||||
|
return jsonify(notes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/notes", methods=["POST"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_create_note():
|
||||||
|
data = request.get_json()
|
||||||
|
title = data.get("title", "").strip()
|
||||||
|
if not title:
|
||||||
|
return jsonify({"success": False, "message": "Title is required"}), 400
|
||||||
|
|
||||||
|
goal_id = data.get("goal_id")
|
||||||
|
task_id = data.get("task_id")
|
||||||
|
content = data.get("content", "")
|
||||||
|
|
||||||
|
note = database.create_note(session["user_id"], goal_id, task_id, title, content)
|
||||||
|
return jsonify(note), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/notes/<int:note_id>", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_get_note(note_id):
|
||||||
|
note = database.get_note_by_id(note_id)
|
||||||
|
if not note or note["user_id"] != session["user_id"]:
|
||||||
|
return jsonify({"success": False, "message": "Note not found"}), 404
|
||||||
|
return jsonify(note)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/notes/<int:note_id>", methods=["PUT"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_update_note(note_id):
|
||||||
|
note = database.get_note_by_id(note_id)
|
||||||
|
if not note or note["user_id"] != session["user_id"]:
|
||||||
|
return jsonify({"success": False, "message": "Note not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
title = data.get("title", "").strip()
|
||||||
|
if not title:
|
||||||
|
return jsonify({"success": False, "message": "Title is required"}), 400
|
||||||
|
|
||||||
|
updated = database.update_note(note_id, title, data.get("content", ""))
|
||||||
|
return jsonify(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/notes/<int:note_id>", methods=["DELETE"])
|
||||||
|
@auth.login_required
|
||||||
|
def api_delete_note(note_id):
|
||||||
|
note = database.get_note_by_id(note_id)
|
||||||
|
if not note or note["user_id"] != session["user_id"]:
|
||||||
|
return jsonify({"success": False, "message": "Note not found"}), 404
|
||||||
|
|
||||||
|
database.delete_note(note_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/admin/users", methods=["GET"])
|
@app.route("/api/admin/users", methods=["GET"])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
def api_get_users():
|
def api_get_users():
|
||||||
|
|||||||
82
database.py
82
database.py
@ -216,3 +216,85 @@ def get_tasks_sorted(goal_id):
|
|||||||
return finished + unfinished
|
return finished + unfinished
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_notes(user_id, goal_id=None, search=None):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conditions = ["n.user_id = ?"]
|
||||||
|
params = [user_id]
|
||||||
|
|
||||||
|
if goal_id:
|
||||||
|
conditions.append("n.goal_id = ?")
|
||||||
|
params.append(goal_id)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
conditions.append("(n.title LIKE ? OR n.content LIKE ?)")
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like])
|
||||||
|
|
||||||
|
sql = f"""SELECT n.*, g.title as goal_title, t.title as task_title
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN goals g ON n.goal_id = g.id
|
||||||
|
LEFT JOIN tasks t ON n.task_id = t.id
|
||||||
|
WHERE {' AND '.join(conditions)}
|
||||||
|
ORDER BY n.updated_at DESC"""
|
||||||
|
cur = conn.execute(sql, params)
|
||||||
|
return [row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_note_by_id(note_id):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""SELECT n.*, g.title as goal_title, t.title as task_title
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN goals g ON n.goal_id = g.id
|
||||||
|
LEFT JOIN tasks t ON n.task_id = t.id
|
||||||
|
WHERE n.id = ?""",
|
||||||
|
(note_id,)
|
||||||
|
)
|
||||||
|
return row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_note(user_id, goal_id, task_id, title, content):
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO notes (user_id, goal_id, task_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, goal_id, task_id, title, content, now, now)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_note_by_id(cur.lastrowid)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_note(note_id, title, content):
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE notes SET title = ?, content = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(title, content, now, note_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_note_by_id(note_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_note(note_id):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
35
schema.py
35
schema.py
@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
max_goals INTEGER NOT NULL DEFAULT 5
|
max_goals INTEGER NOT NULL DEFAULT 5,
|
||||||
|
selected_goal_id INTEGER
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -19,7 +20,9 @@ CREATE TABLE IF NOT EXISTS goals (
|
|||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
activated INTEGER NOT NULL DEFAULT 1,
|
activated INTEGER NOT NULL DEFAULT 1,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
selected_task_id INTEGER,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (selected_task_id) REFERENCES tasks(id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -37,6 +40,22 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CREATE_NOTES = """
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
goal_id INTEGER,
|
||||||
|
task_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (goal_id) REFERENCES goals(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_connection():
|
def get_connection():
|
||||||
os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True)
|
||||||
@ -52,6 +71,18 @@ def init_db():
|
|||||||
conn.execute(CREATE_USERS)
|
conn.execute(CREATE_USERS)
|
||||||
conn.execute(CREATE_GOALS)
|
conn.execute(CREATE_GOALS)
|
||||||
conn.execute(CREATE_TASKS)
|
conn.execute(CREATE_TASKS)
|
||||||
|
conn.execute(CREATE_NOTES)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE goals ADD COLUMN selected_task_id INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN selected_goal_id INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|||||||
257
static/css/notes.css
Normal file
257
static/css/notes.css
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
.notes-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-header h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card:hover {
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-snippet {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-link {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #eef;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-modal-content {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panes {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1,
|
||||||
|
.markdown-preview h2,
|
||||||
|
.markdown-preview h3 {
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1 { font-size: 1.3rem; }
|
||||||
|
.markdown-preview h2 { font-size: 1.15rem; }
|
||||||
|
.markdown-preview h3 { font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.markdown-preview p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview code {
|
||||||
|
background: #eee;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre {
|
||||||
|
background: #282c34;
|
||||||
|
color: #abb2bf;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul,
|
||||||
|
.markdown-preview ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th,
|
||||||
|
.markdown-preview td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-panes {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
static/js/notes.js
Normal file
242
static/js/notes.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
let notes = [];
|
||||||
|
let goals = [];
|
||||||
|
let editingNoteId = null;
|
||||||
|
let filterTimer = null;
|
||||||
|
let currentUser = null;
|
||||||
|
let savedGoalId = null;
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
const goalId = document.getElementById("filter-goal").value;
|
||||||
|
const search = document.getElementById("filter-search").value.trim();
|
||||||
|
|
||||||
|
let params = new URLSearchParams();
|
||||||
|
if (goalId) params.set("goal_id", goalId);
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
|
||||||
|
try {
|
||||||
|
notes = await get(`/api/notes?${params.toString()}`);
|
||||||
|
renderNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load notes:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGoals() {
|
||||||
|
try {
|
||||||
|
goals = await get("/api/goals");
|
||||||
|
currentUser = await get("/api/auth/me");
|
||||||
|
savedGoalId = currentUser.selected_goal_id;
|
||||||
|
populateGoalSelectors();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load goals:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateGoalSelectors() {
|
||||||
|
const filterSelect = document.getElementById("filter-goal");
|
||||||
|
const modalSelect = document.getElementById("note-goal");
|
||||||
|
|
||||||
|
const activated = goals.filter(g => g.activated);
|
||||||
|
const options = activated.map(g =>
|
||||||
|
`<option value="${g.id}">${escapeHtml(g.title)}</option>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
const savedGoalExists = savedGoalId && activated.some(g => g.id === savedGoalId);
|
||||||
|
|
||||||
|
filterSelect.innerHTML = `<option value="">All Goals</option>` + options;
|
||||||
|
if (savedGoalExists) {
|
||||||
|
filterSelect.value = savedGoalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalSelect.innerHTML = `<option value="">None</option>` + options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes() {
|
||||||
|
const container = document.getElementById("notes-list");
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No notes yet</h3>
|
||||||
|
<p>Create your first note to get started!</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = notes.map(note => {
|
||||||
|
const snippet = (note.content || "").replace(/[#*`\[\]()>|~_-]/g, "").substring(0, 120);
|
||||||
|
const time = formatTime(note.updated_at);
|
||||||
|
let link = "";
|
||||||
|
if (note.task_title) {
|
||||||
|
link = `<span class="note-card-link">${escapeHtml(note.goal_title)} / ${escapeHtml(note.task_title)}</span>`;
|
||||||
|
} else if (note.goal_title) {
|
||||||
|
link = `<span class="note-card-link">${escapeHtml(note.goal_title)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="note-card" onclick="openNote(${note.id})">
|
||||||
|
<div class="note-card-title">${escapeHtml(note.title)}</div>
|
||||||
|
<div class="note-card-meta">
|
||||||
|
<span>${time}</span>
|
||||||
|
${link}
|
||||||
|
</div>
|
||||||
|
<div class="note-card-snippet">${escapeHtml(snippet)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNote(noteId) {
|
||||||
|
editingNoteId = noteId;
|
||||||
|
const note = notes.find(n => n.id === noteId);
|
||||||
|
if (!note) return;
|
||||||
|
|
||||||
|
document.getElementById("note-modal-title").textContent = "Edit Note";
|
||||||
|
document.getElementById("note-id").value = note.id;
|
||||||
|
document.getElementById("note-title").value = note.title;
|
||||||
|
document.getElementById("note-content").value = note.content || "";
|
||||||
|
document.getElementById("note-goal").value = note.goal_id || "";
|
||||||
|
document.getElementById("note-error").textContent = "";
|
||||||
|
|
||||||
|
await populateTasks(note.goal_id);
|
||||||
|
document.getElementById("note-task").value = note.task_id || "";
|
||||||
|
|
||||||
|
document.getElementById("delete-note-btn").style.display = "inline-block";
|
||||||
|
updatePreview();
|
||||||
|
document.getElementById("note-modal").classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewNote() {
|
||||||
|
editingNoteId = null;
|
||||||
|
document.getElementById("note-modal-title").textContent = "New Note";
|
||||||
|
document.getElementById("note-id").value = "";
|
||||||
|
document.getElementById("note-title").value = "";
|
||||||
|
document.getElementById("note-content").value = "";
|
||||||
|
document.getElementById("note-goal").value = savedGoalId || "";
|
||||||
|
document.getElementById("note-error").textContent = "";
|
||||||
|
document.getElementById("delete-note-btn").style.display = "none";
|
||||||
|
updatePreview();
|
||||||
|
document.getElementById("note-modal").classList.add("active");
|
||||||
|
|
||||||
|
if (savedGoalId) {
|
||||||
|
populateTasks(savedGoalId);
|
||||||
|
} else {
|
||||||
|
document.getElementById("note-task").innerHTML = '<option value="">None</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNoteModal() {
|
||||||
|
document.getElementById("note-modal").classList.remove("active");
|
||||||
|
editingNoteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTasks(goalId) {
|
||||||
|
const select = document.getElementById("note-task");
|
||||||
|
select.innerHTML = '<option value="">None</option>';
|
||||||
|
if (!goalId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = await get(`/api/tasks?goal_id=${goalId}`);
|
||||||
|
select.innerHTML += tasks.map(t =>
|
||||||
|
`<option value="${t.id}">${escapeHtml(t.title)}</option>`
|
||||||
|
).join("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tasks:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const content = document.getElementById("note-content").value;
|
||||||
|
const preview = document.getElementById("note-preview");
|
||||||
|
try {
|
||||||
|
preview.innerHTML = marked.parse(content || "");
|
||||||
|
} catch (e) {
|
||||||
|
preview.innerHTML = escapeHtml(content || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoteSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const error = document.getElementById("note-error");
|
||||||
|
error.textContent = "";
|
||||||
|
|
||||||
|
const title = document.getElementById("note-title").value.trim();
|
||||||
|
if (!title) {
|
||||||
|
error.textContent = "Title is required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goalId = parseInt(document.getElementById("note-goal").value) || null;
|
||||||
|
const taskId = parseInt(document.getElementById("note-task").value) || null;
|
||||||
|
const content = document.getElementById("note-content").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingNoteId) {
|
||||||
|
await put(`/api/notes/${editingNoteId}`, { title, content });
|
||||||
|
} else {
|
||||||
|
await post("/api/notes", { goal_id: goalId, task_id: taskId, title, content });
|
||||||
|
}
|
||||||
|
closeNoteModal();
|
||||||
|
await loadNotes();
|
||||||
|
} catch (err) {
|
||||||
|
error.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote() {
|
||||||
|
if (!editingNoteId) return;
|
||||||
|
if (!confirm("Delete this note?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await del(`/api/notes/${editingNoteId}`);
|
||||||
|
closeNoteModal();
|
||||||
|
await loadNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete note:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoString) {
|
||||||
|
if (!isoString) return "";
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.getElementById("create-note-btn").addEventListener("click", openNewNote);
|
||||||
|
document.getElementById("note-modal-close").addEventListener("click", closeNoteModal);
|
||||||
|
document.getElementById("note-modal-cancel").addEventListener("click", closeNoteModal);
|
||||||
|
document.getElementById("note-form").addEventListener("submit", handleNoteSubmit);
|
||||||
|
document.getElementById("delete-note-btn").addEventListener("click", deleteNote);
|
||||||
|
|
||||||
|
document.getElementById("note-content").addEventListener("input", updatePreview);
|
||||||
|
|
||||||
|
document.getElementById("note-goal").addEventListener("change", async (e) => {
|
||||||
|
const goalId = parseInt(e.target.value) || null;
|
||||||
|
savedGoalId = goalId;
|
||||||
|
await patch("/api/user/selected-goal", { goal_id: goalId });
|
||||||
|
populateTasks(goalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("filter-goal").addEventListener("change", async () => {
|
||||||
|
const goalId = parseInt(document.getElementById("filter-goal").value) || null;
|
||||||
|
savedGoalId = goalId;
|
||||||
|
await patch("/api/user/selected-goal", { goal_id: goalId });
|
||||||
|
loadNotes();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("filter-search").addEventListener("input", () => {
|
||||||
|
clearTimeout(filterTimer);
|
||||||
|
filterTimer = setTimeout(loadNotes, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadGoals();
|
||||||
|
loadNotes();
|
||||||
|
});
|
||||||
@ -3,22 +3,32 @@ let tasks = [];
|
|||||||
let selectedGoalId = null;
|
let selectedGoalId = null;
|
||||||
let selectedTaskId = null;
|
let selectedTaskId = null;
|
||||||
let sortableInstance = null;
|
let sortableInstance = null;
|
||||||
|
let persistTimer = null;
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
async function loadGoals() {
|
async function loadGoals() {
|
||||||
try {
|
try {
|
||||||
goals = await get("/api/goals");
|
goals = await get("/api/goals");
|
||||||
|
currentUser = await get("/api/auth/me");
|
||||||
const selector = document.getElementById("goal-selector");
|
const selector = document.getElementById("goal-selector");
|
||||||
const activatedGoals = goals.filter(g => g.activated);
|
const activatedGoals = goals.filter(g => g.activated);
|
||||||
|
|
||||||
selector.innerHTML = activatedGoals.map(goal =>
|
selector.innerHTML = activatedGoals.map(goal =>
|
||||||
`<option value="${goal.id}">${escapeHtml(goal.title)}</option>`
|
`<option value="${goal.id}">${escapeHtml(goal.title)}</option>`
|
||||||
).join("");
|
).join("");
|
||||||
|
|
||||||
if (activatedGoals.length > 0) {
|
const savedGoalId = currentUser.selected_goal_id;
|
||||||
|
const savedGoalExists = savedGoalId && activatedGoals.some(g => g.id === savedGoalId);
|
||||||
|
|
||||||
|
if (savedGoalExists) {
|
||||||
|
selectedGoalId = savedGoalId;
|
||||||
|
selector.value = savedGoalId;
|
||||||
|
} else if (activatedGoals.length > 0) {
|
||||||
selectedGoalId = activatedGoals[0].id;
|
selectedGoalId = activatedGoals[0].id;
|
||||||
selector.value = selectedGoalId;
|
selector.value = selectedGoalId;
|
||||||
await loadTasks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadTasks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load goals:", error);
|
console.error("Failed to load goals:", error);
|
||||||
}
|
}
|
||||||
@ -34,23 +44,47 @@ async function loadTasks() {
|
|||||||
initSortable();
|
initSortable();
|
||||||
initScrollFocus();
|
initScrollFocus();
|
||||||
|
|
||||||
const doingTask = tasks.find(t => t.status === "doing");
|
const currentGoal = goals.find(g => g.id === selectedGoalId);
|
||||||
if (doingTask) {
|
const savedTaskId = currentGoal ? currentGoal.selected_task_id : null;
|
||||||
scrollToTask(doingTask.id);
|
const savedTaskExists = savedTaskId && tasks.some(t => t.id === savedTaskId);
|
||||||
|
|
||||||
|
if (savedTaskExists) {
|
||||||
|
scrollToTask(savedTaskId);
|
||||||
if (isLandscapeMode()) {
|
if (isLandscapeMode()) {
|
||||||
selectTask(doingTask.id);
|
selectTask(savedTaskId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const doingTask = tasks.find(t => t.status === "doing");
|
||||||
|
if (doingTask) {
|
||||||
|
scrollToTask(doingTask.id);
|
||||||
|
if (isLandscapeMode()) {
|
||||||
|
selectTask(doingTask.id);
|
||||||
|
}
|
||||||
|
} else if (isLandscapeMode() && tasks.length > 0) {
|
||||||
|
selectTask(tasks[0].id);
|
||||||
}
|
}
|
||||||
} else if (isLandscapeMode() && tasks.length > 0) {
|
|
||||||
selectTask(tasks[0].id);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load tasks:", error);
|
console.error("Failed to load tasks:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistSelectedTask(taskId) {
|
||||||
|
if (!selectedGoalId) return;
|
||||||
|
try {
|
||||||
|
await patch(`/api/goals/${selectedGoalId}/selected-task`, { task_id: taskId });
|
||||||
|
const goal = goals.find(g => g.id === selectedGoalId);
|
||||||
|
if (goal) {
|
||||||
|
goal.selected_task_id = taskId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to persist selected task:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTasks() {
|
function renderTasks() {
|
||||||
const container = document.getElementById("tasks-list");
|
const container = document.getElementById("tasks-list");
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@ -71,11 +105,11 @@ function renderTasks() {
|
|||||||
|
|
||||||
function initSortable() {
|
function initSortable() {
|
||||||
const container = document.getElementById("tasks-list");
|
const container = document.getElementById("tasks-list");
|
||||||
|
|
||||||
if (sortableInstance) {
|
if (sortableInstance) {
|
||||||
sortableInstance.destroy();
|
sortableInstance.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
sortableInstance = Sortable.create(container, {
|
sortableInstance = Sortable.create(container, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: "sortable-ghost",
|
ghostClass: "sortable-ghost",
|
||||||
@ -84,15 +118,15 @@ function initSortable() {
|
|||||||
onEnd: async function(evt) {
|
onEnd: async function(evt) {
|
||||||
const taskId = evt.item.dataset.taskId;
|
const taskId = evt.item.dataset.taskId;
|
||||||
const newIndex = evt.newIndex;
|
const newIndex = evt.newIndex;
|
||||||
|
|
||||||
const prevTask = tasks[newIndex - 1];
|
const prevTask = tasks[newIndex - 1];
|
||||||
const nextTask = tasks[newIndex + 1];
|
const nextTask = tasks[newIndex + 1];
|
||||||
|
|
||||||
let prevOrder = prevTask ? prevTask.order : 0;
|
let prevOrder = prevTask ? prevTask.order : 0;
|
||||||
let nextOrder = nextTask ? nextTask.order : prevOrder + 2;
|
let nextOrder = nextTask ? nextTask.order : prevOrder + 2;
|
||||||
|
|
||||||
const newOrder = (prevOrder + nextOrder) / 2;
|
const newOrder = (prevOrder + nextOrder) / 2;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await patch(`/api/tasks/${taskId}/order`, { order: newOrder });
|
await patch(`/api/tasks/${taskId}/order`, { order: newOrder });
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
@ -117,10 +151,10 @@ function scrollToTask(taskId) {
|
|||||||
|
|
||||||
function initScrollFocus() {
|
function initScrollFocus() {
|
||||||
const scrollView = document.getElementById("scroll-view");
|
const scrollView = document.getElementById("scroll-view");
|
||||||
|
|
||||||
scrollView.removeEventListener("scroll", handleScrollFocus);
|
scrollView.removeEventListener("scroll", handleScrollFocus);
|
||||||
scrollView.addEventListener("scroll", handleScrollFocus);
|
scrollView.addEventListener("scroll", handleScrollFocus);
|
||||||
|
|
||||||
handleScrollFocus();
|
handleScrollFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,8 +184,12 @@ function handleScrollFocus() {
|
|||||||
if (closestItem) {
|
if (closestItem) {
|
||||||
closestItem.classList.add("in-focus");
|
closestItem.classList.add("in-focus");
|
||||||
|
|
||||||
|
const taskId = parseInt(closestItem.dataset.taskId);
|
||||||
|
|
||||||
|
clearTimeout(persistTimer);
|
||||||
|
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
|
||||||
|
|
||||||
if (isLandscapeMode()) {
|
if (isLandscapeMode()) {
|
||||||
const taskId = parseInt(closestItem.dataset.taskId);
|
|
||||||
if (taskId !== selectedTaskId) {
|
if (taskId !== selectedTaskId) {
|
||||||
selectTask(taskId);
|
selectTask(taskId);
|
||||||
}
|
}
|
||||||
@ -188,27 +226,27 @@ function closeSidePanel() {
|
|||||||
|
|
||||||
async function saveTask() {
|
async function saveTask() {
|
||||||
if (!selectedTaskId) return;
|
if (!selectedTaskId) return;
|
||||||
|
|
||||||
const error = document.getElementById("side-panel-error");
|
const error = document.getElementById("side-panel-error");
|
||||||
error.textContent = "";
|
error.textContent = "";
|
||||||
|
|
||||||
const title = document.getElementById("edit-task-title").value.trim();
|
const title = document.getElementById("edit-task-title").value.trim();
|
||||||
const desc = document.getElementById("edit-task-desc").value;
|
const desc = document.getElementById("edit-task-desc").value;
|
||||||
const status = document.getElementById("edit-task-status").value;
|
const status = document.getElementById("edit-task-status").value;
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
error.textContent = "Title is required";
|
error.textContent = "Title is required";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await put(`/api/tasks/${selectedTaskId}`, { title, desc });
|
await put(`/api/tasks/${selectedTaskId}`, { title, desc });
|
||||||
|
|
||||||
const currentTask = tasks.find(t => t.id === selectedTaskId);
|
const currentTask = tasks.find(t => t.id === selectedTaskId);
|
||||||
if (status !== currentTask?.status) {
|
if (status !== currentTask?.status) {
|
||||||
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
|
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.textContent = err.message;
|
error.textContent = err.message;
|
||||||
@ -217,9 +255,9 @@ async function saveTask() {
|
|||||||
|
|
||||||
async function deleteTask() {
|
async function deleteTask() {
|
||||||
if (!selectedTaskId) return;
|
if (!selectedTaskId) return;
|
||||||
|
|
||||||
if (!confirm("Are you sure you want to delete this task?")) return;
|
if (!confirm("Are you sure you want to delete this task?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await del(`/api/tasks/${selectedTaskId}`);
|
await del(`/api/tasks/${selectedTaskId}`);
|
||||||
closeSidePanel();
|
closeSidePanel();
|
||||||
@ -246,21 +284,21 @@ async function handleTaskSubmit(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const error = document.getElementById("task-error");
|
const error = document.getElementById("task-error");
|
||||||
error.textContent = "";
|
error.textContent = "";
|
||||||
|
|
||||||
const title = document.getElementById("task-title").value.trim();
|
const title = document.getElementById("task-title").value.trim();
|
||||||
const desc = document.getElementById("task-desc").value;
|
const desc = document.getElementById("task-desc").value;
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
error.textContent = "Title is required";
|
error.textContent = "Title is required";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const goalId = parseInt(document.getElementById("goal-selector").value);
|
const goalId = parseInt(document.getElementById("goal-selector").value);
|
||||||
if (!goalId) {
|
if (!goalId) {
|
||||||
error.textContent = "Please select a goal first";
|
error.textContent = "Please select a goal first";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await post("/api/tasks", { goal_id: goalId, title, desc });
|
await post("/api/tasks", { goal_id: goalId, title, desc });
|
||||||
closeTaskModal();
|
closeTaskModal();
|
||||||
@ -284,16 +322,16 @@ function formatTime(isoString) {
|
|||||||
|
|
||||||
function initWheelScroll() {
|
function initWheelScroll() {
|
||||||
const scrollView = document.getElementById("scroll-view");
|
const scrollView = document.getElementById("scroll-view");
|
||||||
|
|
||||||
scrollView.addEventListener("wheel", (e) => {
|
scrollView.addEventListener("wheel", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const taskItem = scrollView.querySelector(".task-item");
|
const taskItem = scrollView.querySelector(".task-item");
|
||||||
if (!taskItem) return;
|
if (!taskItem) return;
|
||||||
|
|
||||||
const taskHeight = taskItem.offsetHeight + 8;
|
const taskHeight = taskItem.offsetHeight + 8;
|
||||||
const direction = e.deltaY > 0 ? 1 : -1;
|
const direction = e.deltaY > 0 ? 1 : -1;
|
||||||
|
|
||||||
scrollView.scrollBy({
|
scrollView.scrollBy({
|
||||||
top: taskHeight * direction,
|
top: taskHeight * direction,
|
||||||
behavior: "smooth"
|
behavior: "smooth"
|
||||||
@ -302,20 +340,21 @@ function initWheelScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
document.getElementById("goal-selector").addEventListener("change", (e) => {
|
document.getElementById("goal-selector").addEventListener("change", async (e) => {
|
||||||
selectedGoalId = parseInt(e.target.value);
|
selectedGoalId = parseInt(e.target.value);
|
||||||
|
await patch("/api/user/selected-goal", { goal_id: selectedGoalId });
|
||||||
loadTasks();
|
loadTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("create-task-btn").addEventListener("click", openTaskModal);
|
document.getElementById("create-task-btn").addEventListener("click", openTaskModal);
|
||||||
document.getElementById("task-modal-close").addEventListener("click", closeTaskModal);
|
document.getElementById("task-modal-close").addEventListener("click", closeTaskModal);
|
||||||
document.getElementById("task-modal-cancel").addEventListener("click", closeTaskModal);
|
document.getElementById("task-modal-cancel").addEventListener("click", closeTaskModal);
|
||||||
document.getElementById("task-form").addEventListener("submit", handleTaskSubmit);
|
document.getElementById("task-form").addEventListener("submit", handleTaskSubmit);
|
||||||
|
|
||||||
document.getElementById("side-panel-close").addEventListener("click", closeSidePanel);
|
document.getElementById("side-panel-close").addEventListener("click", closeSidePanel);
|
||||||
document.getElementById("save-task-btn").addEventListener("click", saveTask);
|
document.getElementById("save-task-btn").addEventListener("click", saveTask);
|
||||||
document.getElementById("delete-task-btn").addEventListener("click", deleteTask);
|
document.getElementById("delete-task-btn").addEventListener("click", deleteTask);
|
||||||
|
|
||||||
initWheelScroll();
|
initWheelScroll();
|
||||||
loadGoals();
|
loadGoals();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/goals" class="nav-link">Goals</a>
|
<a href="/goals" class="nav-link">Goals</a>
|
||||||
<a href="/tasks" class="nav-link">Tasks</a>
|
<a href="/tasks" class="nav-link">Tasks</a>
|
||||||
|
<a href="/notes" class="nav-link">Notes</a>
|
||||||
<a href="/admin" class="nav-link" id="admin-link" style="display: none;">Admin</a>
|
<a href="/admin" class="nav-link" id="admin-link" style="display: none;">Admin</a>
|
||||||
<span id="user-info" class="nav-user"></span>
|
<span id="user-info" class="nav-user"></span>
|
||||||
<button id="logout-btn" class="nav-btn">Logout</button>
|
<button id="logout-btn" class="nav-btn">Logout</button>
|
||||||
|
|||||||
83
templates/notes.html
Normal file
83
templates/notes.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Notes - GoalsBreakDown{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/notes.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="notes-page">
|
||||||
|
<div class="notes-header">
|
||||||
|
<h1>Notes</h1>
|
||||||
|
<button id="create-note-btn" class="btn-success">+ New Note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-goal">Goal:</label>
|
||||||
|
<select id="filter-goal">
|
||||||
|
<option value="">All Goals</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group search-group">
|
||||||
|
<input type="text" id="filter-search" placeholder="Search notes...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notes-list" class="notes-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="note-modal" class="modal">
|
||||||
|
<div class="modal-content note-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="note-modal-title">New Note</h2>
|
||||||
|
<button class="modal-close" id="note-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="note-form">
|
||||||
|
<input type="hidden" id="note-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note-title">Title</label>
|
||||||
|
<input type="text" id="note-title" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note-goal">Goal</label>
|
||||||
|
<select id="note-goal">
|
||||||
|
<option value="">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note-task">Task</label>
|
||||||
|
<select id="note-task">
|
||||||
|
<option value="">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group note-editor">
|
||||||
|
<div class="editor-panes">
|
||||||
|
<div class="editor-pane">
|
||||||
|
<label>Content (Markdown)</label>
|
||||||
|
<textarea id="note-content" rows="16"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="preview-pane">
|
||||||
|
<label>Preview</label>
|
||||||
|
<div id="note-preview" class="markdown-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="note-error" class="error-message"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" id="note-modal-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn-danger" id="delete-note-btn" style="display:none;">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@5.1.0/marked.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/notes.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user