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:
Yuyao Huang 2026-05-08 17:42:42 +08:00
parent 594bf65715
commit 3c325bdb0f
8 changed files with 871 additions and 45 deletions

95
app.py
View File

@ -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():

View File

@ -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()

View File

@ -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
View 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
View 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();
});

View File

@ -3,10 +3,13 @@ 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);
@ -14,11 +17,18 @@ async function loadGoals() {
`<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,6 +44,16 @@ async function loadTasks() {
initSortable(); initSortable();
initScrollFocus(); initScrollFocus();
const currentGoal = goals.find(g => g.id === selectedGoalId);
const savedTaskId = currentGoal ? currentGoal.selected_task_id : null;
const savedTaskExists = savedTaskId && tasks.some(t => t.id === savedTaskId);
if (savedTaskExists) {
scrollToTask(savedTaskId);
if (isLandscapeMode()) {
selectTask(savedTaskId);
}
} else {
const doingTask = tasks.find(t => t.status === "doing"); const doingTask = tasks.find(t => t.status === "doing");
if (doingTask) { if (doingTask) {
scrollToTask(doingTask.id); scrollToTask(doingTask.id);
@ -43,11 +63,25 @@ async function loadTasks() {
} else if (isLandscapeMode() && tasks.length > 0) { } else if (isLandscapeMode() && tasks.length > 0) {
selectTask(tasks[0].id); 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");
@ -150,8 +184,12 @@ function handleScrollFocus() {
if (closestItem) { if (closestItem) {
closestItem.classList.add("in-focus"); closestItem.classList.add("in-focus");
if (isLandscapeMode()) {
const taskId = parseInt(closestItem.dataset.taskId); const taskId = parseInt(closestItem.dataset.taskId);
clearTimeout(persistTimer);
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
if (isLandscapeMode()) {
if (taskId !== selectedTaskId) { if (taskId !== selectedTaskId) {
selectTask(taskId); selectTask(taskId);
} }
@ -302,8 +340,9 @@ 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();
}); });

View File

@ -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
View 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">&times;</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 %}